Complete batch 012 (golden set diff) and 013 (advisory chat), fix build errors

Sprints completed:
- SPRINT_20260110_012_* (golden set diff layer - 10 sprints)
- SPRINT_20260110_013_* (advisory chat - 4 sprints)

Build fixes applied:
- Fix namespace conflicts with Microsoft.Extensions.Options.Options.Create
- Fix VexDecisionReachabilityIntegrationTests API drift (major rewrite)
- Fix VexSchemaValidationTests FluentAssertions method name
- Fix FixChainGateIntegrationTests ambiguous type references
- Fix AdvisoryAI test files required properties and namespace aliases
- Add stub types for CveMappingController (ICveSymbolMappingService)
- Fix VerdictBuilderService static context issue

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
master
2026-01-11 10:09:07 +02:00
parent a3b2f30a11
commit 7f7eb8b228
232 changed files with 58979 additions and 91 deletions

View File

@@ -0,0 +1,263 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
// Sprint: SPRINT_20260110_012_008_POLICY
// Task: FCG-003 - Policy Engine Integration
using System.Collections.Immutable;
using System.Globalization;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Gates;
using StellaOps.Policy.TrustLattice;
namespace StellaOps.Policy.Predicates.FixChain;
/// <summary>
/// Adapter that bridges IFixChainGatePredicate to IPolicyGate for registry integration.
/// </summary>
public sealed class FixChainGateAdapter : IPolicyGate
{
private readonly IFixChainGatePredicate _predicate;
private readonly FixChainGateParameters _defaultParameters;
private readonly ILogger<FixChainGateAdapter> _logger;
public FixChainGateAdapter(
IFixChainGatePredicate predicate,
FixChainGateParameters? defaultParameters = null,
ILogger<FixChainGateAdapter>? logger = null)
{
_predicate = predicate ?? throw new ArgumentNullException(nameof(predicate));
_defaultParameters = defaultParameters ?? new FixChainGateParameters();
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<FixChainGateAdapter>.Instance;
}
/// <inheritdoc />
public async Task<GateResult> EvaluateAsync(
MergeResult mergeResult,
PolicyGateContext context,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(mergeResult);
ArgumentNullException.ThrowIfNull(context);
// Skip if no CVE ID in context
if (string.IsNullOrEmpty(context.CveId))
{
return CreatePassResult("no_cve_context", ImmutableDictionary<string, object>.Empty);
}
// Build FixChain gate context from policy context
var fixChainContext = new FixChainGateContext
{
CveId = context.CveId,
ComponentPurl = context.SubjectKey ?? string.Empty,
Severity = context.Severity ?? "unknown",
CvssScore = 0m, // Not available in policy context
BinarySha256 = null, // Not available in policy context
CvePublishedAt = null, // Would need to be enriched
Environment = context.Environment,
Metadata = context.Metadata
};
try
{
var result = await _predicate.EvaluateAsync(
fixChainContext,
_defaultParameters,
ct).ConfigureAwait(false);
var details = result.Details ?? ImmutableDictionary<string, object>.Empty;
details = details
.Add("outcome", result.Outcome.ToString())
.Add("action", result.Action.ToString())
.Add("evaluated_at", result.EvaluatedAt.ToString("O", CultureInfo.InvariantCulture));
if (result.Attestation is not null)
{
details = details
.Add("attestation_digest", result.Attestation.ContentDigest)
.Add("attestation_verdict", result.Attestation.VerdictStatus)
.Add("attestation_confidence", result.Attestation.Confidence);
}
if (!result.Recommendations.IsDefaultOrEmpty)
{
details = details.Add("recommendations", result.Recommendations.ToArray());
}
if (!result.CliCommands.IsDefaultOrEmpty)
{
details = details.Add("cli_commands", result.CliCommands.ToArray());
}
return new GateResult
{
GateName = nameof(FixChainGateAdapter),
Passed = result.Passed,
Reason = result.Reason,
Details = details
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error evaluating FixChain gate for {CveId}", context.CveId);
return new GateResult
{
GateName = nameof(FixChainGateAdapter),
Passed = false,
Reason = "fixchain_evaluation_error",
Details = ImmutableDictionary<string, object>.Empty
.Add("error", ex.Message)
.Add("cve_id", context.CveId)
};
}
}
private static GateResult CreatePassResult(string reason, ImmutableDictionary<string, object> details)
{
return new GateResult
{
GateName = nameof(FixChainGateAdapter),
Passed = true,
Reason = reason,
Details = details
};
}
}
/// <summary>
/// Result aggregator for batch FixChain gate evaluations.
/// </summary>
public sealed class FixChainGateBatchResult
{
/// <summary>Overall pass status.</summary>
public required bool AllPassed { get; init; }
/// <summary>Individual results by CVE ID.</summary>
public required ImmutableDictionary<string, FixChainGateResult> Results { get; init; }
/// <summary>Blocking results.</summary>
public ImmutableArray<FixChainGateResult> BlockingResults { get; init; } = [];
/// <summary>Warning results.</summary>
public ImmutableArray<FixChainGateResult> WarningResults { get; init; } = [];
/// <summary>Aggregated recommendations.</summary>
public ImmutableArray<string> AggregatedRecommendations { get; init; } = [];
/// <summary>Aggregated CLI commands.</summary>
public ImmutableArray<string> AggregatedCliCommands { get; init; } = [];
}
/// <summary>
/// Service for batch FixChain gate evaluation.
/// </summary>
public interface IFixChainGateBatchService
{
/// <summary>
/// Evaluates multiple findings against FixChain gates.
/// </summary>
/// <param name="contexts">Gate contexts to evaluate.</param>
/// <param name="parameters">Gate parameters.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Aggregated batch result.</returns>
Task<FixChainGateBatchResult> EvaluateBatchAsync(
IReadOnlyList<FixChainGateContext> contexts,
FixChainGateParameters parameters,
CancellationToken ct = default);
}
/// <summary>
/// Default implementation of batch FixChain gate service.
/// </summary>
public sealed class FixChainGateBatchService : IFixChainGateBatchService
{
private readonly IFixChainGatePredicate _predicate;
private readonly ILogger<FixChainGateBatchService> _logger;
public FixChainGateBatchService(
IFixChainGatePredicate predicate,
ILogger<FixChainGateBatchService>? logger = null)
{
_predicate = predicate ?? throw new ArgumentNullException(nameof(predicate));
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<FixChainGateBatchService>.Instance;
}
/// <inheritdoc />
public async Task<FixChainGateBatchResult> EvaluateBatchAsync(
IReadOnlyList<FixChainGateContext> contexts,
FixChainGateParameters parameters,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(contexts);
ArgumentNullException.ThrowIfNull(parameters);
var results = new Dictionary<string, FixChainGateResult>();
var blocking = new List<FixChainGateResult>();
var warnings = new List<FixChainGateResult>();
var recommendations = new HashSet<string>();
var cliCommands = new HashSet<string>();
foreach (var context in contexts)
{
ct.ThrowIfCancellationRequested();
try
{
var result = await _predicate.EvaluateAsync(context, parameters, ct).ConfigureAwait(false);
results[context.CveId] = result;
if (!result.Passed)
{
if (result.Action == FixChainGateAction.Block)
{
blocking.Add(result);
}
else
{
warnings.Add(result);
}
}
foreach (var rec in result.Recommendations)
{
recommendations.Add(rec);
}
foreach (var cmd in result.CliCommands)
{
cliCommands.Add(cmd);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error evaluating FixChain gate for {CveId}", context.CveId);
// Treat errors as blocking
var errorResult = new FixChainGateResult
{
Passed = false,
Outcome = FixChainGateOutcome.AttestationRequired,
Reason = string.Format(
CultureInfo.InvariantCulture,
"Evaluation error: {0}",
ex.Message),
Action = parameters.FailureAction,
EvaluatedAt = DateTimeOffset.UtcNow
};
results[context.CveId] = errorResult;
blocking.Add(errorResult);
}
}
return new FixChainGateBatchResult
{
AllPassed = blocking.Count == 0,
Results = results.ToImmutableDictionary(),
BlockingResults = [.. blocking],
WarningResults = [.. warnings],
AggregatedRecommendations = [.. recommendations],
AggregatedCliCommands = [.. cliCommands]
};
}
}

View File

@@ -0,0 +1,89 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
// Sprint: SPRINT_20260110_012_008_POLICY
// Task: FCG-003 - Policy Engine Integration
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Policy.Gates;
namespace StellaOps.Policy.Predicates.FixChain;
/// <summary>
/// Extension methods for registering FixChain gate services.
/// </summary>
public static class FixChainGateExtensions
{
/// <summary>
/// Adds FixChain gate predicate services to the service collection.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration section for options.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddFixChainGate(
this IServiceCollection services,
IConfiguration? configuration = null)
{
ArgumentNullException.ThrowIfNull(services);
// Register options
if (configuration is not null)
{
services.AddOptions<FixChainGateOptions>()
.Bind(configuration.GetSection("Policy:Predicates:FixChainGate"))
.ValidateDataAnnotations()
.ValidateOnStart();
}
else
{
services.TryAddSingleton(new FixChainGateOptions());
}
// Register core services
services.TryAddSingleton<IFixChainGatePredicate, FixChainGatePredicate>();
services.TryAddSingleton<IFixChainGateBatchService, FixChainGateBatchService>();
// Register adapter for policy gate registry
services.TryAddSingleton<FixChainGateAdapter>();
return services;
}
/// <summary>
/// Adds FixChain gate predicate services with custom options.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configureOptions">Options configuration delegate.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddFixChainGate(
this IServiceCollection services,
Action<FixChainGateOptions> configureOptions)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configureOptions);
services.Configure(configureOptions);
// Register core services
services.TryAddSingleton<IFixChainGatePredicate, FixChainGatePredicate>();
services.TryAddSingleton<IFixChainGateBatchService, FixChainGateBatchService>();
// Register adapter for policy gate registry
services.TryAddSingleton<FixChainGateAdapter>();
return services;
}
/// <summary>
/// Registers the FixChain gate with the policy gate registry.
/// </summary>
/// <param name="registry">Policy gate registry.</param>
/// <returns>Registry for chaining.</returns>
public static IPolicyGateRegistry RegisterFixChainGate(this IPolicyGateRegistry registry)
{
ArgumentNullException.ThrowIfNull(registry);
registry.Register<FixChainGateAdapter>("fixChainRequired");
return registry;
}
}

View File

@@ -0,0 +1,90 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
// Sprint: SPRINT_20260110_012_008_POLICY
// Task: FCG-003 - Policy Engine Integration (Metrics)
using System.Diagnostics.Metrics;
namespace StellaOps.Policy.Predicates.FixChain;
/// <summary>
/// OpenTelemetry metrics for FixChain gate evaluations.
/// </summary>
public static class FixChainGateMetrics
{
/// <summary>Meter name for FixChain gate metrics.</summary>
public const string MeterName = "StellaOps.Policy.FixChainGate";
private static readonly Meter Meter = new(MeterName, "1.0.0");
/// <summary>Total gate evaluations.</summary>
public static readonly Counter<long> EvaluationsTotal = Meter.CreateCounter<long>(
"policy_fixchain_gate_evaluations_total",
unit: "{evaluations}",
description: "Total number of FixChain gate evaluations");
/// <summary>Gate passes.</summary>
public static readonly Counter<long> PassesTotal = Meter.CreateCounter<long>(
"policy_fixchain_gate_passes_total",
unit: "{passes}",
description: "Total number of FixChain gate passes");
/// <summary>Gate failures (blocks).</summary>
public static readonly Counter<long> BlocksTotal = Meter.CreateCounter<long>(
"policy_fixchain_gate_blocks_total",
unit: "{blocks}",
description: "Total number of FixChain gate blocks");
/// <summary>Gate warnings.</summary>
public static readonly Counter<long> WarningsTotal = Meter.CreateCounter<long>(
"policy_fixchain_gate_warnings_total",
unit: "{warnings}",
description: "Total number of FixChain gate warnings");
/// <summary>Evaluation duration.</summary>
public static readonly Histogram<double> EvaluationDuration = Meter.CreateHistogram<double>(
"policy_fixchain_gate_evaluation_duration_seconds",
unit: "s",
description: "Duration of FixChain gate evaluations");
/// <summary>Evaluation errors.</summary>
public static readonly Counter<long> ErrorsTotal = Meter.CreateCounter<long>(
"policy_fixchain_gate_errors_total",
unit: "{errors}",
description: "Total number of FixChain gate evaluation errors");
/// <summary>
/// Records a gate evaluation.
/// </summary>
public static void RecordEvaluation(
FixChainGateOutcome outcome,
bool passed,
FixChainGateAction action,
double durationSeconds)
{
var outcomeTag = new KeyValuePair<string, object?>("outcome", outcome.ToString());
EvaluationsTotal.Add(1, outcomeTag);
EvaluationDuration.Record(durationSeconds, outcomeTag);
if (passed)
{
PassesTotal.Add(1, outcomeTag);
}
else if (action == FixChainGateAction.Block)
{
BlocksTotal.Add(1, outcomeTag);
}
else
{
WarningsTotal.Add(1, outcomeTag);
}
}
/// <summary>
/// Records an evaluation error.
/// </summary>
public static void RecordError(string errorType)
{
ErrorsTotal.Add(1, new KeyValuePair<string, object?>("error_type", errorType));
}
}

View File

@@ -0,0 +1,468 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
// Sprint: SPRINT_20260110_012_008_POLICY
// Task: FCG-006 - Notification Integration
using System.Collections.Immutable;
using System.Globalization;
using Microsoft.Extensions.Logging;
namespace StellaOps.Policy.Predicates.FixChain;
/// <summary>
/// Interface for FixChain gate notifications.
/// </summary>
public interface IFixChainGateNotifier
{
/// <summary>
/// Notifies when a gate blocks a release.
/// </summary>
/// <param name="notification">Notification content.</param>
/// <param name="ct">Cancellation token.</param>
Task NotifyGateBlockedAsync(
GateBlockedNotification notification,
CancellationToken ct = default);
/// <summary>
/// Notifies when a gate issues a warning.
/// </summary>
/// <param name="notification">Notification content.</param>
/// <param name="ct">Cancellation token.</param>
Task NotifyGateWarningAsync(
GateWarningNotification notification,
CancellationToken ct = default);
/// <summary>
/// Notifies when a batch evaluation completes with issues.
/// </summary>
/// <param name="notification">Batch notification content.</param>
/// <param name="ct">Cancellation token.</param>
Task NotifyBatchResultAsync(
GateBatchNotification notification,
CancellationToken ct = default);
}
/// <summary>
/// Notification content for gate block.
/// </summary>
public sealed record GateBlockedNotification
{
/// <summary>CVE identifier.</summary>
public required string CveId { get; init; }
/// <summary>Component affected.</summary>
public required string Component { get; init; }
/// <summary>Severity level.</summary>
public required string Severity { get; init; }
/// <summary>Block reason.</summary>
public required string Reason { get; init; }
/// <summary>Gate outcome.</summary>
public required FixChainGateOutcome Outcome { get; init; }
/// <summary>Recommendations for resolution.</summary>
public ImmutableArray<string> Recommendations { get; init; } = [];
/// <summary>CLI commands to help resolve.</summary>
public ImmutableArray<string> CliCommands { get; init; } = [];
/// <summary>Policy name that blocked.</summary>
public required string PolicyName { get; init; }
/// <summary>Artifact reference.</summary>
public string? ArtifactRef { get; init; }
/// <summary>When the block occurred.</summary>
public required DateTimeOffset BlockedAt { get; init; }
/// <summary>Target environment.</summary>
public string? Environment { get; init; }
/// <summary>Additional context.</summary>
public ImmutableDictionary<string, string>? Context { get; init; }
}
/// <summary>
/// Notification content for gate warning.
/// </summary>
public sealed record GateWarningNotification
{
/// <summary>CVE identifier.</summary>
public required string CveId { get; init; }
/// <summary>Component affected.</summary>
public required string Component { get; init; }
/// <summary>Severity level.</summary>
public required string Severity { get; init; }
/// <summary>Warning reason.</summary>
public required string Reason { get; init; }
/// <summary>Gate outcome.</summary>
public required FixChainGateOutcome Outcome { get; init; }
/// <summary>Recommendations for resolution.</summary>
public ImmutableArray<string> Recommendations { get; init; } = [];
/// <summary>Policy name that warned.</summary>
public required string PolicyName { get; init; }
/// <summary>When the warning occurred.</summary>
public required DateTimeOffset WarnedAt { get; init; }
}
/// <summary>
/// Notification content for batch evaluation.
/// </summary>
public sealed record GateBatchNotification
{
/// <summary>Artifact reference.</summary>
public required string ArtifactRef { get; init; }
/// <summary>Policy name.</summary>
public required string PolicyName { get; init; }
/// <summary>Total findings evaluated.</summary>
public required int TotalFindings { get; init; }
/// <summary>Number of blocking issues.</summary>
public required int BlockingCount { get; init; }
/// <summary>Number of warnings.</summary>
public required int WarningCount { get; init; }
/// <summary>Overall result (allowed, blocked).</summary>
public required string OverallResult { get; init; }
/// <summary>Summary of blocking issues.</summary>
public ImmutableArray<BlockingSummary> BlockingSummaries { get; init; } = [];
/// <summary>Aggregated recommendations.</summary>
public ImmutableArray<string> Recommendations { get; init; } = [];
/// <summary>When evaluation completed.</summary>
public required DateTimeOffset EvaluatedAt { get; init; }
}
/// <summary>
/// Summary of a blocking issue.
/// </summary>
public sealed record BlockingSummary
{
/// <summary>CVE identifier.</summary>
public required string CveId { get; init; }
/// <summary>Component.</summary>
public required string Component { get; init; }
/// <summary>Reason.</summary>
public required string Reason { get; init; }
}
/// <summary>
/// Default implementation of FixChain gate notifier.
/// Logs notifications and can be extended with channel-specific implementations.
/// </summary>
public sealed class FixChainGateNotifier : IFixChainGateNotifier
{
private readonly ILogger<FixChainGateNotifier> _logger;
private readonly IEnumerable<INotificationChannel> _channels;
public FixChainGateNotifier(
ILogger<FixChainGateNotifier>? logger = null,
IEnumerable<INotificationChannel>? channels = null)
{
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<FixChainGateNotifier>.Instance;
_channels = channels ?? [];
}
/// <inheritdoc />
public async Task NotifyGateBlockedAsync(
GateBlockedNotification notification,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(notification);
_logger.LogWarning(
"FixChain gate BLOCKED release for {CveId} on {Component}: {Reason}",
notification.CveId,
notification.Component,
notification.Reason);
var message = FormatBlockedMessage(notification);
foreach (var channel in _channels)
{
try
{
await channel.SendAsync(
"fixchain_gate_blocked",
message,
NotificationSeverity.Error,
ct).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send block notification via {Channel}", channel.GetType().Name);
}
}
}
/// <inheritdoc />
public async Task NotifyGateWarningAsync(
GateWarningNotification notification,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(notification);
_logger.LogWarning(
"FixChain gate WARNING for {CveId} on {Component}: {Reason}",
notification.CveId,
notification.Component,
notification.Reason);
var message = FormatWarningMessage(notification);
foreach (var channel in _channels)
{
try
{
await channel.SendAsync(
"fixchain_gate_warning",
message,
NotificationSeverity.Warning,
ct).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send warning notification via {Channel}", channel.GetType().Name);
}
}
}
/// <inheritdoc />
public async Task NotifyBatchResultAsync(
GateBatchNotification notification,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(notification);
if (notification.BlockingCount > 0)
{
_logger.LogWarning(
"FixChain gate batch evaluation: {BlockingCount} blocking, {WarningCount} warnings for {Artifact}",
notification.BlockingCount,
notification.WarningCount,
notification.ArtifactRef);
}
else if (notification.WarningCount > 0)
{
_logger.LogInformation(
"FixChain gate batch evaluation: {WarningCount} warnings for {Artifact}",
notification.WarningCount,
notification.ArtifactRef);
}
if (notification.BlockingCount > 0 || notification.WarningCount > 0)
{
var message = FormatBatchMessage(notification);
var severity = notification.BlockingCount > 0
? NotificationSeverity.Error
: NotificationSeverity.Warning;
foreach (var channel in _channels)
{
try
{
await channel.SendAsync(
"fixchain_gate_batch",
message,
severity,
ct).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send batch notification via {Channel}", channel.GetType().Name);
}
}
}
}
private static NotificationMessage FormatBlockedMessage(GateBlockedNotification n)
{
var title = string.Format(
CultureInfo.InvariantCulture,
"Release Blocked: {0} ({1})",
n.CveId,
n.Severity.ToUpperInvariant());
var body = string.Format(
CultureInfo.InvariantCulture,
"**Component:** {0}\n**Reason:** {1}\n**Policy:** {2}\n**Environment:** {3}",
n.Component,
n.Reason,
n.PolicyName,
n.Environment ?? "production");
if (!n.Recommendations.IsDefaultOrEmpty)
{
body += "\n\n**Recommendations:**\n" +
string.Join("\n", n.Recommendations.Select(r => $"- {r}"));
}
if (!n.CliCommands.IsDefaultOrEmpty)
{
body += "\n\n**CLI Commands:**\n" +
string.Join("\n", n.CliCommands.Select(c => $"```\n{c}\n```"));
}
return new NotificationMessage
{
Title = title,
Body = body,
Fields =
[
new NotificationField("CVE", n.CveId),
new NotificationField("Severity", n.Severity),
new NotificationField("Component", n.Component),
new NotificationField("Outcome", n.Outcome.ToString())
]
};
}
private static NotificationMessage FormatWarningMessage(GateWarningNotification n)
{
var title = string.Format(
CultureInfo.InvariantCulture,
"Release Warning: {0} ({1})",
n.CveId,
n.Severity.ToUpperInvariant());
var body = string.Format(
CultureInfo.InvariantCulture,
"**Component:** {0}\n**Reason:** {1}\n**Policy:** {2}",
n.Component,
n.Reason,
n.PolicyName);
if (!n.Recommendations.IsDefaultOrEmpty)
{
body += "\n\n**Recommendations:**\n" +
string.Join("\n", n.Recommendations.Select(r => $"- {r}"));
}
return new NotificationMessage
{
Title = title,
Body = body,
Fields =
[
new NotificationField("CVE", n.CveId),
new NotificationField("Severity", n.Severity),
new NotificationField("Component", n.Component)
]
};
}
private static NotificationMessage FormatBatchMessage(GateBatchNotification n)
{
var title = string.Format(
CultureInfo.InvariantCulture,
"Release Gate Evaluation: {0}",
n.OverallResult.ToUpperInvariant());
var body = string.Format(
CultureInfo.InvariantCulture,
"**Artifact:** {0}\n**Policy:** {1}\n**Findings:** {2} total, {3} blocking, {4} warnings",
n.ArtifactRef,
n.PolicyName,
n.TotalFindings,
n.BlockingCount,
n.WarningCount);
if (!n.BlockingSummaries.IsDefaultOrEmpty)
{
body += "\n\n**Blocking Issues:**\n" +
string.Join("\n", n.BlockingSummaries.Select(s =>
string.Format(CultureInfo.InvariantCulture, "- {0} ({1}): {2}", s.CveId, s.Component, s.Reason)));
}
if (!n.Recommendations.IsDefaultOrEmpty)
{
body += "\n\n**Recommendations:**\n" +
string.Join("\n", n.Recommendations.Take(5).Select(r => $"- {r}"));
if (n.Recommendations.Length > 5)
{
body += string.Format(
CultureInfo.InvariantCulture,
"\n... and {0} more",
n.Recommendations.Length - 5);
}
}
return new NotificationMessage
{
Title = title,
Body = body,
Fields =
[
new NotificationField("Result", n.OverallResult),
new NotificationField("Blocking", n.BlockingCount.ToString(CultureInfo.InvariantCulture)),
new NotificationField("Warnings", n.WarningCount.ToString(CultureInfo.InvariantCulture))
]
};
}
}
/// <summary>
/// Notification channel interface for extensibility.
/// </summary>
public interface INotificationChannel
{
/// <summary>
/// Sends a notification message.
/// </summary>
Task SendAsync(
string eventType,
NotificationMessage message,
NotificationSeverity severity,
CancellationToken ct = default);
}
/// <summary>
/// Notification severity levels.
/// </summary>
public enum NotificationSeverity
{
/// <summary>Informational.</summary>
Info,
/// <summary>Warning.</summary>
Warning,
/// <summary>Error/Critical.</summary>
Error
}
/// <summary>
/// Notification message content.
/// </summary>
public sealed record NotificationMessage
{
/// <summary>Message title.</summary>
public required string Title { get; init; }
/// <summary>Message body (markdown supported).</summary>
public required string Body { get; init; }
/// <summary>Structured fields.</summary>
public ImmutableArray<NotificationField> Fields { get; init; } = [];
}
/// <summary>
/// A structured field in a notification.
/// </summary>
public sealed record NotificationField(string Name, string Value);

View File

@@ -0,0 +1,696 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
// Sprint: SPRINT_20260110_012_008_POLICY
// Task: FCG-001, FCG-002 - FixChainGate Predicate Interface and Implementation
using System.Collections.Immutable;
using System.Globalization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.BinaryIndex.GoldenSet;
using StellaOps.RiskEngine.Core.Providers.FixChain;
namespace StellaOps.Policy.Predicates.FixChain;
/// <summary>
/// Policy predicate that gates release promotion based on fix verification status.
/// </summary>
public interface IFixChainGatePredicate
{
/// <summary>Predicate identifier for policy configuration.</summary>
string PredicateId { get; }
/// <summary>
/// Evaluates whether a finding passes the fix verification gate.
/// </summary>
/// <param name="context">Gate evaluation context.</param>
/// <param name="parameters">Gate configuration parameters.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Evaluation result with outcome and recommendations.</returns>
Task<FixChainGateResult> EvaluateAsync(
FixChainGateContext context,
FixChainGateParameters parameters,
CancellationToken ct = default);
}
/// <summary>
/// Context for fix chain gate evaluation.
/// </summary>
public sealed record FixChainGateContext
{
/// <summary>CVE identifier.</summary>
public required string CveId { get; init; }
/// <summary>Component PURL.</summary>
public required string ComponentPurl { get; init; }
/// <summary>Severity level (critical, high, medium, low).</summary>
public required string Severity { get; init; }
/// <summary>CVSS score.</summary>
public required decimal CvssScore { get; init; }
/// <summary>Binary SHA-256 digest (optional).</summary>
public string? BinarySha256 { get; init; }
/// <summary>CVE publication date (for grace period calculation).</summary>
public DateTimeOffset? CvePublishedAt { get; init; }
/// <summary>Target environment (production, staging, development).</summary>
public string Environment { get; init; } = "production";
/// <summary>Mutable metadata for audit trail.</summary>
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Parameters for fix chain gate configuration.
/// </summary>
public sealed record FixChainGateParameters
{
/// <summary>
/// Severities that require fix verification.
/// </summary>
public ImmutableArray<string> Severities { get; init; } = ["critical", "high"];
/// <summary>
/// Minimum confidence for "fixed" verdict to pass.
/// </summary>
public decimal MinConfidence { get; init; } = 0.85m;
/// <summary>
/// Whether "inconclusive" verdicts pass the gate.
/// </summary>
public bool AllowInconclusive { get; init; }
/// <summary>
/// Grace period (days) after CVE publication before gate applies.
/// </summary>
public int GracePeriodDays { get; init; } = 7;
/// <summary>
/// Whether to require approved golden set.
/// </summary>
public bool RequireApprovedGoldenSet { get; init; } = true;
/// <summary>
/// Action to take when gate fails (block, warn).
/// </summary>
public FixChainGateAction FailureAction { get; init; } = FixChainGateAction.Block;
}
/// <summary>
/// Action to take on gate failure.
/// </summary>
public enum FixChainGateAction
{
/// <summary>Block the release.</summary>
Block,
/// <summary>Warn but allow the release.</summary>
Warn
}
/// <summary>
/// Result of fix chain gate evaluation.
/// </summary>
public sealed record FixChainGateResult
{
/// <summary>Whether the gate passed.</summary>
public required bool Passed { get; init; }
/// <summary>Outcome classification.</summary>
public required FixChainGateOutcome Outcome { get; init; }
/// <summary>Human-readable reason.</summary>
public required string Reason { get; init; }
/// <summary>Action taken (block, warn, allow).</summary>
public required FixChainGateAction Action { get; init; }
/// <summary>Attestation information if found.</summary>
public FixChainAttestationInfo? Attestation { get; init; }
/// <summary>Actionable recommendations for resolution.</summary>
public ImmutableArray<string> Recommendations { get; init; } = [];
/// <summary>CLI commands to help resolve.</summary>
public ImmutableArray<string> CliCommands { get; init; } = [];
/// <summary>When the evaluation was performed.</summary>
public DateTimeOffset EvaluatedAt { get; init; }
/// <summary>Additional details for audit.</summary>
public ImmutableDictionary<string, object>? Details { get; init; }
}
/// <summary>
/// Outcome classification for fix chain gate evaluation.
/// </summary>
public enum FixChainGateOutcome
{
/// <summary>Fix verified with sufficient confidence.</summary>
FixVerified,
/// <summary>Severity does not require verification.</summary>
SeverityExempt,
/// <summary>Within grace period.</summary>
GracePeriod,
/// <summary>No attestation and severity requires it.</summary>
AttestationRequired,
/// <summary>Attestation exists but confidence too low.</summary>
InsufficientConfidence,
/// <summary>Verdict is "inconclusive" and not allowed.</summary>
InconclusiveNotAllowed,
/// <summary>Verdict is "still_vulnerable".</summary>
StillVulnerable,
/// <summary>Golden set not approved.</summary>
GoldenSetNotApproved,
/// <summary>Partial fix applied.</summary>
PartialFix
}
/// <summary>
/// Attestation information for gate result.
/// </summary>
public sealed record FixChainAttestationInfo
{
/// <summary>Content digest.</summary>
public required string ContentDigest { get; init; }
/// <summary>Verdict status.</summary>
public required string VerdictStatus { get; init; }
/// <summary>Confidence score.</summary>
public required decimal Confidence { get; init; }
/// <summary>Golden set ID.</summary>
public string? GoldenSetId { get; init; }
/// <summary>When verification was performed.</summary>
public required DateTimeOffset VerifiedAt { get; init; }
/// <summary>Rationale items.</summary>
public ImmutableArray<string> Rationale { get; init; } = [];
}
/// <summary>
/// Configuration options for FixChainGate.
/// </summary>
public sealed record FixChainGateOptions
{
/// <summary>Whether the gate is enabled.</summary>
public bool Enabled { get; init; } = true;
/// <summary>Default minimum confidence threshold.</summary>
public decimal DefaultMinConfidence { get; init; } = 0.85m;
/// <summary>Default grace period in days.</summary>
public int DefaultGracePeriodDays { get; init; } = 7;
/// <summary>Whether to send notifications on block.</summary>
public bool NotifyOnBlock { get; init; } = true;
/// <summary>Whether to send notifications on warn.</summary>
public bool NotifyOnWarn { get; init; } = true;
}
/// <summary>
/// Default implementation of fix chain gate predicate.
/// </summary>
public sealed class FixChainGatePredicate : IFixChainGatePredicate
{
private readonly IFixChainAttestationClient _attestationClient;
private readonly IGoldenSetStore _goldenSetStore;
private readonly TimeProvider _timeProvider;
private readonly ILogger<FixChainGatePredicate> _logger;
private readonly FixChainGateOptions _options;
/// <inheritdoc />
public string PredicateId => "fixChainRequired";
public FixChainGatePredicate(
IFixChainAttestationClient attestationClient,
IGoldenSetStore goldenSetStore,
IOptionsMonitor<FixChainGateOptions>? options = null,
ILogger<FixChainGatePredicate>? logger = null,
TimeProvider? timeProvider = null)
{
_attestationClient = attestationClient ?? throw new ArgumentNullException(nameof(attestationClient));
_goldenSetStore = goldenSetStore ?? throw new ArgumentNullException(nameof(goldenSetStore));
_options = options?.CurrentValue ?? new FixChainGateOptions();
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<FixChainGatePredicate>.Instance;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public async Task<FixChainGateResult> EvaluateAsync(
FixChainGateContext context,
FixChainGateParameters parameters,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(parameters);
var now = _timeProvider.GetUtcNow();
// Check if gate is enabled
if (!_options.Enabled)
{
return CreatePassResult(
FixChainGateOutcome.SeverityExempt,
"Gate disabled",
parameters.FailureAction,
now);
}
// 1. Check if severity requires verification
if (!parameters.Severities.Contains(context.Severity, StringComparer.OrdinalIgnoreCase))
{
_logger.LogDebug(
"Severity {Severity} does not require fix verification for {CveId}",
context.Severity,
context.CveId);
return CreatePassResult(
FixChainGateOutcome.SeverityExempt,
string.Format(
CultureInfo.InvariantCulture,
"Severity '{0}' does not require fix verification",
context.Severity),
parameters.FailureAction,
now);
}
// 2. Check grace period
if (context.CvePublishedAt.HasValue && parameters.GracePeriodDays > 0)
{
var gracePeriodEnd = context.CvePublishedAt.Value.AddDays(parameters.GracePeriodDays);
if (now < gracePeriodEnd)
{
_logger.LogDebug(
"CVE {CveId} within grace period until {GracePeriodEnd}",
context.CveId,
gracePeriodEnd);
return new FixChainGateResult
{
Passed = true,
Outcome = FixChainGateOutcome.GracePeriod,
Reason = string.Format(
CultureInfo.InvariantCulture,
"Within grace period until {0:yyyy-MM-dd}",
gracePeriodEnd),
Action = FixChainGateAction.Warn,
Recommendations =
[
string.Format(
CultureInfo.InvariantCulture,
"Create golden set for {0} before grace period ends",
context.CveId)
],
CliCommands =
[
string.Format(
CultureInfo.InvariantCulture,
"stella scanner golden init --cve {0} --component {1}",
context.CveId,
ExtractComponentName(context.ComponentPurl))
],
EvaluatedAt = now,
Details = ImmutableDictionary<string, object>.Empty
.Add("grace_period_end", gracePeriodEnd.ToString("O", CultureInfo.InvariantCulture))
};
}
}
// 3. Query for FixChain attestation
var attestation = await _attestationClient.GetFixChainAsync(
context.CveId,
context.BinarySha256 ?? string.Empty,
context.ComponentPurl,
ct).ConfigureAwait(false);
if (attestation is null)
{
_logger.LogInformation(
"No FixChain attestation found for {CveId} on {Component}",
context.CveId,
context.ComponentPurl);
return new FixChainGateResult
{
Passed = false,
Outcome = FixChainGateOutcome.AttestationRequired,
Reason = string.Format(
CultureInfo.InvariantCulture,
"No FixChain attestation found for {0}",
context.CveId),
Action = parameters.FailureAction,
Recommendations =
[
string.Format(
CultureInfo.InvariantCulture,
"Create golden set for {0}",
context.CveId),
"Run fix verification analysis",
"Create FixChain attestation"
],
CliCommands =
[
string.Format(
CultureInfo.InvariantCulture,
"stella scanner golden init --cve {0} --component {1}",
context.CveId,
ExtractComponentName(context.ComponentPurl)),
string.Format(
CultureInfo.InvariantCulture,
"stella scanner golden verify --cve {0}",
context.CveId)
],
EvaluatedAt = now,
Details = ImmutableDictionary<string, object>.Empty
.Add("cve_id", context.CveId)
.Add("component", context.ComponentPurl)
};
}
// 4. Check golden set approval status
if (parameters.RequireApprovedGoldenSet && attestation.GoldenSetId is not null)
{
var goldenSet = await _goldenSetStore.GetAsync(attestation.GoldenSetId, ct).ConfigureAwait(false);
if (goldenSet is null || goldenSet.Status != GoldenSetStatus.Approved)
{
_logger.LogInformation(
"Golden set {GoldenSetId} not approved for {CveId}",
attestation.GoldenSetId,
context.CveId);
return new FixChainGateResult
{
Passed = false,
Outcome = FixChainGateOutcome.GoldenSetNotApproved,
Reason = "Golden set has not been reviewed and approved",
Action = parameters.FailureAction,
Attestation = ToAttestationInfo(attestation),
Recommendations =
[
string.Format(
CultureInfo.InvariantCulture,
"Submit golden set {0} for review",
attestation.GoldenSetId)
],
CliCommands =
[
string.Format(
CultureInfo.InvariantCulture,
"stella scanner golden submit --id {0}",
attestation.GoldenSetId)
],
EvaluatedAt = now,
Details = ImmutableDictionary<string, object>.Empty
.Add("golden_set_id", attestation.GoldenSetId)
.Add("golden_set_status", goldenSet?.Status.ToString() ?? "not_found")
};
}
}
// 5. Evaluate verdict
return EvaluateVerdict(attestation, parameters, context, now);
}
private FixChainGateResult EvaluateVerdict(
FixChainAttestationData attestation,
FixChainGateParameters parameters,
FixChainGateContext context,
DateTimeOffset now)
{
var verdict = attestation.Verdict;
var attestationInfo = ToAttestationInfo(attestation);
switch (verdict.Status.ToUpperInvariant())
{
case "FIXED":
if (verdict.Confidence >= parameters.MinConfidence)
{
_logger.LogDebug(
"Fix verified for {CveId} with {Confidence:P0} confidence",
context.CveId,
verdict.Confidence);
// Store attestation digest in metadata for audit trail
if (context.Metadata is not null)
{
context.Metadata["fixchain_digest"] = attestation.ContentDigest;
context.Metadata["fixchain_confidence"] = verdict.Confidence.ToString("F2", CultureInfo.InvariantCulture);
}
return new FixChainGateResult
{
Passed = true,
Outcome = FixChainGateOutcome.FixVerified,
Reason = string.Format(
CultureInfo.InvariantCulture,
"Fix verified with {0:P0} confidence",
verdict.Confidence),
Action = FixChainGateAction.Warn, // Passed, so action is informational
Attestation = attestationInfo,
EvaluatedAt = now,
Details = ImmutableDictionary<string, object>.Empty
.Add("confidence", verdict.Confidence)
.Add("min_required", parameters.MinConfidence)
.Add("attestation_digest", attestation.ContentDigest)
};
}
else
{
_logger.LogInformation(
"Fix confidence {Confidence:P0} below threshold {Threshold:P0} for {CveId}",
verdict.Confidence,
parameters.MinConfidence,
context.CveId);
return new FixChainGateResult
{
Passed = false,
Outcome = FixChainGateOutcome.InsufficientConfidence,
Reason = string.Format(
CultureInfo.InvariantCulture,
"Confidence {0:P0} below required {1:P0}",
verdict.Confidence,
parameters.MinConfidence),
Action = parameters.FailureAction,
Attestation = attestationInfo,
Recommendations =
[
"Review golden set for completeness",
"Ensure all vulnerable targets are specified",
"Re-run verification with more comprehensive analysis"
],
CliCommands =
[
string.Format(
CultureInfo.InvariantCulture,
"stella scanner golden show --cve {0}",
context.CveId),
string.Format(
CultureInfo.InvariantCulture,
"stella scanner golden verify --cve {0} --verbose",
context.CveId)
],
EvaluatedAt = now,
Details = ImmutableDictionary<string, object>.Empty
.Add("confidence", verdict.Confidence)
.Add("min_required", parameters.MinConfidence)
.Add("gap", parameters.MinConfidence - verdict.Confidence)
};
}
case "PARTIAL":
_logger.LogInformation(
"Partial fix for {CveId} with {Confidence:P0} confidence",
context.CveId,
verdict.Confidence);
return new FixChainGateResult
{
Passed = parameters.AllowInconclusive, // Treat partial like inconclusive for allowance
Outcome = FixChainGateOutcome.PartialFix,
Reason = string.Format(
CultureInfo.InvariantCulture,
"Partial fix detected ({0:P0} confidence)",
verdict.Confidence),
Action = parameters.FailureAction,
Attestation = attestationInfo,
Recommendations =
[
"Complete the fix for remaining vulnerable paths",
"Review partial fix rationale"
],
EvaluatedAt = now,
Details = ImmutableDictionary<string, object>.Empty
.Add("confidence", verdict.Confidence)
.Add("rationale", verdict.Rationale.ToArray())
};
case "INCONCLUSIVE":
if (parameters.AllowInconclusive)
{
_logger.LogDebug(
"Inconclusive verdict allowed by policy for {CveId}",
context.CveId);
return new FixChainGateResult
{
Passed = true,
Outcome = FixChainGateOutcome.FixVerified, // Passed with caveat
Reason = "Inconclusive verdict allowed by policy",
Action = FixChainGateAction.Warn,
Attestation = attestationInfo,
Recommendations =
[
"Review verification results manually",
"Consider enhancing golden set"
],
EvaluatedAt = now,
Details = ImmutableDictionary<string, object>.Empty
.Add("verdict", "inconclusive")
.Add("allowed_by_policy", true)
};
}
else
{
_logger.LogInformation(
"Inconclusive verdict not allowed by policy for {CveId}",
context.CveId);
return new FixChainGateResult
{
Passed = false,
Outcome = FixChainGateOutcome.InconclusiveNotAllowed,
Reason = "Inconclusive verdict not allowed by policy",
Action = parameters.FailureAction,
Attestation = attestationInfo,
Recommendations =
[
"Enhance golden set with more specific targets",
"Obtain symbols for stripped binary",
"Manual review and exception process"
],
CliCommands =
[
string.Format(
CultureInfo.InvariantCulture,
"stella scanner golden edit --cve {0}",
context.CveId)
],
EvaluatedAt = now,
Details = ImmutableDictionary<string, object>.Empty
.Add("verdict", "inconclusive")
.Add("allowed_by_policy", false)
};
}
case "STILL_VULNERABLE":
case "NOT_FIXED":
_logger.LogWarning(
"Vulnerability still present for {CveId}",
context.CveId);
return new FixChainGateResult
{
Passed = false,
Outcome = FixChainGateOutcome.StillVulnerable,
Reason = "Verification indicates vulnerability still present",
Action = parameters.FailureAction,
Attestation = attestationInfo,
Recommendations =
[
"Ensure correct patched binary is scanned",
"Verify patch was applied correctly",
"Contact vendor if patch is ineffective"
],
EvaluatedAt = now,
Details = ImmutableDictionary<string, object>.Empty
.Add("verdict", verdict.Status)
.Add("rationale", verdict.Rationale.ToArray())
};
default:
_logger.LogWarning(
"Unknown verdict status {Status} for {CveId}",
verdict.Status,
context.CveId);
return new FixChainGateResult
{
Passed = false,
Outcome = FixChainGateOutcome.AttestationRequired,
Reason = string.Format(
CultureInfo.InvariantCulture,
"Unknown verdict status: {0}",
verdict.Status),
Action = parameters.FailureAction,
Attestation = attestationInfo,
EvaluatedAt = now
};
}
}
private static FixChainGateResult CreatePassResult(
FixChainGateOutcome outcome,
string reason,
FixChainGateAction action,
DateTimeOffset evaluatedAt)
{
return new FixChainGateResult
{
Passed = true,
Outcome = outcome,
Reason = reason,
Action = action,
EvaluatedAt = evaluatedAt
};
}
private static FixChainAttestationInfo ToAttestationInfo(FixChainAttestationData data)
{
return new FixChainAttestationInfo
{
ContentDigest = data.ContentDigest,
VerdictStatus = data.Verdict.Status,
Confidence = data.Verdict.Confidence,
GoldenSetId = data.GoldenSetId,
VerifiedAt = data.VerifiedAt,
Rationale = data.Verdict.Rationale
};
}
private static string ExtractComponentName(string purl)
{
// Extract component name from PURL (e.g., "pkg:npm/lodash@4.17.21" -> "lodash")
if (string.IsNullOrEmpty(purl))
{
return "component";
}
var atIndex = purl.IndexOf('@', StringComparison.Ordinal);
var lastSlashIndex = purl.LastIndexOf('/');
if (lastSlashIndex >= 0 && (atIndex < 0 || atIndex > lastSlashIndex))
{
var name = atIndex > lastSlashIndex
? purl.Substring(lastSlashIndex + 1, atIndex - lastSlashIndex - 1)
: purl[(lastSlashIndex + 1)..];
return name;
}
return purl;
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Policy/StellaOps.Policy.csproj" />
<ProjectReference Include="../../../RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/StellaOps.RiskEngine.Core.csproj" />
<ProjectReference Include="../../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.GoldenSet/StellaOps.BinaryIndex.GoldenSet.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,427 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
// Sprint: SPRINT_20260110_012_008_POLICY
// Task: FCG-001 through FCG-003 - FixChain Gate Predicate
using System.Collections.Immutable;
using System.Globalization;
using StellaOps.Policy.TrustLattice;
namespace StellaOps.Policy.Gates;
/// <summary>
/// Options for the FixChain verification gate.
/// </summary>
public sealed record FixChainGateOptions
{
/// <summary>Whether the gate is enabled.</summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Severities that require fix verification.
/// Default: critical and high.
/// </summary>
public IReadOnlyList<string> RequiredSeverities { get; init; } = ["critical", "high"];
/// <summary>
/// Minimum confidence for the fix to be considered verified.
/// </summary>
public decimal MinimumConfidence { get; init; } = 0.85m;
/// <summary>
/// Whether to allow inconclusive verdicts to pass (with warning).
/// </summary>
public bool AllowInconclusive { get; init; } = false;
/// <summary>
/// Grace period (days) after CVE publication before requiring fix verification.
/// Allows time for golden set creation.
/// </summary>
public int GracePeriodDays { get; init; } = 7;
/// <summary>
/// Maximum age (hours) for cached fix verification status.
/// </summary>
public int MaxStatusAgeHours { get; init; } = 24;
/// <summary>
/// Environment-specific minimum confidence overrides.
/// </summary>
public IReadOnlyDictionary<string, decimal> EnvironmentConfidence { get; init; } =
new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase)
{
["production"] = 0.95m,
["staging"] = 0.85m,
["development"] = 0.70m,
};
/// <summary>
/// Environment-specific severity requirements.
/// </summary>
public IReadOnlyDictionary<string, IReadOnlyList<string>> EnvironmentSeverities { get; init; } =
new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase)
{
["production"] = ["critical", "high"],
["staging"] = ["critical"],
["development"] = [],
};
}
/// <summary>
/// Context providing FixChain verification data to the gate.
/// </summary>
public sealed record FixChainGateContext
{
/// <summary>Whether a FixChain attestation exists for this finding.</summary>
public bool HasAttestation { get; init; }
/// <summary>Verdict from FixChain verification: fixed, partial, not_fixed, inconclusive.</summary>
public string? Verdict { get; init; }
/// <summary>Confidence score from the verification (0.0 - 1.0).</summary>
public decimal? Confidence { get; init; }
/// <summary>When the verification was performed.</summary>
public DateTimeOffset? VerifiedAt { get; init; }
/// <summary>Attestation digest for audit trail.</summary>
public string? AttestationDigest { get; init; }
/// <summary>Golden set ID used for verification.</summary>
public string? GoldenSetId { get; init; }
/// <summary>CVE publication date (for grace period calculation).</summary>
public DateTimeOffset? CvePublishedAt { get; init; }
/// <summary>Rationale from the verification.</summary>
public IReadOnlyList<string> Rationale { get; init; } = [];
}
/// <summary>
/// Result of FixChain gate evaluation.
/// </summary>
public sealed record FixChainGateResult
{
/// <summary>Whether the gate passed.</summary>
public required bool Passed { get; init; }
/// <summary>Gate decision: allow, warn, block.</summary>
public required string Decision { get; init; }
/// <summary>Human-readable reason for the decision.</summary>
public required string Reason { get; init; }
/// <summary>Detailed evidence for the decision.</summary>
public ImmutableDictionary<string, object> Details { get; init; } =
ImmutableDictionary<string, object>.Empty;
/// <summary>Decision constants.</summary>
public const string DecisionAllow = "allow";
public const string DecisionWarn = "warn";
public const string DecisionBlock = "block";
}
/// <summary>
/// Policy gate that requires fix verification for critical vulnerabilities.
/// </summary>
public sealed class FixChainGate : IPolicyGate
{
private readonly FixChainGateOptions _options;
private readonly TimeProvider _timeProvider;
private readonly IFixChainStatusProvider? _statusProvider;
public FixChainGate(FixChainGateOptions options, TimeProvider timeProvider)
: this(options, timeProvider, null)
{
}
public FixChainGate(
FixChainGateOptions options,
TimeProvider timeProvider,
IFixChainStatusProvider? statusProvider)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_statusProvider = statusProvider;
}
/// <inheritdoc />
public async Task<GateResult> EvaluateAsync(
MergeResult mergeResult,
PolicyGateContext context,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(mergeResult);
ArgumentNullException.ThrowIfNull(context);
if (!_options.Enabled)
{
return CreateResult(true, "FixChain gate disabled");
}
// Determine severity requirements for this environment
var requiredSeverities = GetRequiredSeverities(context.Environment);
if (requiredSeverities.Count == 0)
{
return CreateResult(true, "No severity requirements for this environment");
}
// Check if this finding's severity requires verification
var severity = context.Severity?.ToLowerInvariant() ?? "unknown";
if (!requiredSeverities.Contains(severity, StringComparer.OrdinalIgnoreCase))
{
return CreateResult(true, $"Severity '{severity}' does not require fix verification");
}
// Get FixChain context from metadata or provider
var fixChainContext = await GetFixChainContextAsync(context, ct);
// Check grace period
if (IsInGracePeriod(fixChainContext))
{
return CreateResult(
true,
"Within grace period for golden set creation",
new Dictionary<string, object>
{
["gracePeriodDays"] = _options.GracePeriodDays,
["cvePublishedAt"] = fixChainContext.CvePublishedAt?.ToString("o", CultureInfo.InvariantCulture) ?? "unknown"
});
}
// No attestation found
if (!fixChainContext.HasAttestation)
{
return CreateResult(
false,
$"Fix verification required for {severity} vulnerability but no FixChain attestation found",
new Dictionary<string, object>
{
["severity"] = severity,
["cveId"] = context.CveId ?? "unknown"
});
}
// Evaluate verdict
return EvaluateVerdict(fixChainContext, context.Environment, severity);
}
/// <summary>
/// Evaluates a FixChain context directly (for standalone use).
/// </summary>
public FixChainGateResult EvaluateDirect(
FixChainGateContext fixChainContext,
string environment,
string severity)
{
ArgumentNullException.ThrowIfNull(fixChainContext);
if (!_options.Enabled)
{
return new FixChainGateResult
{
Passed = true,
Decision = FixChainGateResult.DecisionAllow,
Reason = "FixChain gate disabled"
};
}
var requiredSeverities = GetRequiredSeverities(environment);
if (!requiredSeverities.Contains(severity, StringComparer.OrdinalIgnoreCase))
{
return new FixChainGateResult
{
Passed = true,
Decision = FixChainGateResult.DecisionAllow,
Reason = $"Severity '{severity}' does not require fix verification"
};
}
if (IsInGracePeriod(fixChainContext))
{
return new FixChainGateResult
{
Passed = true,
Decision = FixChainGateResult.DecisionAllow,
Reason = "Within grace period for golden set creation"
};
}
if (!fixChainContext.HasAttestation)
{
return new FixChainGateResult
{
Passed = false,
Decision = FixChainGateResult.DecisionBlock,
Reason = $"Fix verification required for {severity} vulnerability but no FixChain attestation found"
};
}
var gateResult = EvaluateVerdict(fixChainContext, environment, severity);
return new FixChainGateResult
{
Passed = gateResult.Passed,
Decision = gateResult.Passed ? FixChainGateResult.DecisionAllow :
(fixChainContext.Verdict == "inconclusive" && _options.AllowInconclusive
? FixChainGateResult.DecisionWarn
: FixChainGateResult.DecisionBlock),
Reason = gateResult.Reason ?? "Unknown",
Details = gateResult.Details
};
}
private GateResult EvaluateVerdict(
FixChainGateContext fixChainContext,
string environment,
string severity)
{
var verdict = fixChainContext.Verdict?.ToLowerInvariant() ?? "unknown";
var confidence = fixChainContext.Confidence ?? 0m;
var minConfidence = GetMinimumConfidence(environment);
var details = new Dictionary<string, object>
{
["verdict"] = verdict,
["confidence"] = confidence,
["minConfidence"] = minConfidence,
["severity"] = severity,
["attestationDigest"] = fixChainContext.AttestationDigest ?? "none",
["goldenSetId"] = fixChainContext.GoldenSetId ?? "none"
};
return verdict switch
{
"fixed" when confidence >= minConfidence =>
CreateResult(true, "Fix verified with sufficient confidence", details),
"fixed" =>
CreateResult(
false,
string.Format(
CultureInfo.InvariantCulture,
"Fix verified but confidence {0:P0} below minimum {1:P0}",
confidence, minConfidence),
details),
"partial" =>
CreateResult(
false,
"Partial fix detected - vulnerability not fully addressed",
details),
"not_fixed" =>
CreateResult(
false,
"Verification confirms vulnerability is NOT fixed",
details),
"inconclusive" when _options.AllowInconclusive =>
CreateResult(
true,
"Verification inconclusive but allowed by policy",
details),
"inconclusive" =>
CreateResult(
false,
"Verification inconclusive and inconclusive verdicts not allowed",
details),
_ => CreateResult(
false,
$"Unknown verdict: {verdict}",
details)
};
}
private IReadOnlyList<string> GetRequiredSeverities(string environment)
{
if (_options.EnvironmentSeverities.TryGetValue(environment, out var severities))
{
return severities;
}
return _options.RequiredSeverities;
}
private decimal GetMinimumConfidence(string environment)
{
if (_options.EnvironmentConfidence.TryGetValue(environment, out var confidence))
{
return confidence;
}
return _options.MinimumConfidence;
}
private bool IsInGracePeriod(FixChainGateContext context)
{
if (_options.GracePeriodDays <= 0 || !context.CvePublishedAt.HasValue)
{
return false;
}
var now = _timeProvider.GetUtcNow();
var deadline = context.CvePublishedAt.Value.AddDays(_options.GracePeriodDays);
return now < deadline;
}
private async Task<FixChainGateContext> GetFixChainContextAsync(
PolicyGateContext context,
CancellationToken ct)
{
// Try to get from status provider if available
if (_statusProvider != null && !string.IsNullOrEmpty(context.CveId))
{
var status = await _statusProvider.GetStatusAsync(context.CveId, context.SubjectKey, ct);
if (status != null)
{
return status;
}
}
// Fall back to metadata if available
if (context.Metadata != null)
{
return new FixChainGateContext
{
HasAttestation = context.Metadata.TryGetValue("fixchain.hasAttestation", out var hasAtt)
&& bool.TryParse(hasAtt, out var b) && b,
Verdict = context.Metadata.GetValueOrDefault("fixchain.verdict"),
Confidence = context.Metadata.TryGetValue("fixchain.confidence", out var conf)
&& decimal.TryParse(conf, CultureInfo.InvariantCulture, out var c) ? c : null,
AttestationDigest = context.Metadata.GetValueOrDefault("fixchain.attestationDigest"),
GoldenSetId = context.Metadata.GetValueOrDefault("fixchain.goldenSetId"),
CvePublishedAt = context.Metadata.TryGetValue("fixchain.cvePublishedAt", out var pub)
&& DateTimeOffset.TryParse(pub, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var d) ? d : null
};
}
return new FixChainGateContext { HasAttestation = false };
}
private static GateResult CreateResult(
bool passed,
string reason,
Dictionary<string, object>? details = null)
{
return new GateResult
{
GateName = "FixChainGate",
Passed = passed,
Reason = reason,
Details = details?.ToImmutableDictionary() ?? ImmutableDictionary<string, object>.Empty
};
}
}
/// <summary>
/// Provider interface for retrieving FixChain verification status.
/// </summary>
public interface IFixChainStatusProvider
{
/// <summary>
/// Gets the FixChain verification status for a CVE and subject.
/// </summary>
Task<FixChainGateContext?> GetStatusAsync(
string cveId,
string? subjectKey,
CancellationToken ct = default);
}