old sprints work, new sprints for exposing functionality via cli, improve code_of_conduct and other agents instructions

This commit is contained in:
master
2026-01-15 18:37:59 +02:00
parent c631bacee2
commit 88a85cdd92
208 changed files with 32271 additions and 2287 deletions

View File

@@ -0,0 +1,316 @@
// <copyright file="DeterminizationConfigEndpoints.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_20260112_012_POLICY_determinization_reanalysis_config (POLICY-CONFIG-004)
// </copyright>
using System.Security.Claims;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Determinization;
namespace StellaOps.Policy.Engine.Endpoints;
/// <summary>
/// API endpoints for determinization configuration.
/// Sprint: SPRINT_20260112_012_POLICY_determinization_reanalysis_config (POLICY-CONFIG-004)
/// </summary>
public static class DeterminizationConfigEndpoints
{
/// <summary>
/// Maps determinization config endpoints.
/// </summary>
public static IEndpointRouteBuilder MapDeterminizationConfigEndpoints(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/v1/policy/config/determinization")
.WithTags("Determinization Configuration");
// Read endpoints (policy viewer access)
group.MapGet("", GetEffectiveConfig)
.WithName("GetEffectiveDeterminizationConfig")
.WithSummary("Get effective determinization configuration for the current tenant")
.Produces<EffectiveConfigResponse>(StatusCodes.Status200OK)
.RequireAuthorization("PolicyViewer");
group.MapGet("/defaults", GetDefaultConfig)
.WithName("GetDefaultDeterminizationConfig")
.WithSummary("Get default determinization configuration")
.Produces<DeterminizationOptions>(StatusCodes.Status200OK)
.RequireAuthorization("PolicyViewer");
group.MapGet("/audit", GetAuditHistory)
.WithName("GetDeterminizationConfigAuditHistory")
.WithSummary("Get audit history for determinization configuration changes")
.Produces<AuditHistoryResponse>(StatusCodes.Status200OK)
.RequireAuthorization("PolicyViewer");
// Write endpoints (policy admin access)
group.MapPut("", UpdateConfig)
.WithName("UpdateDeterminizationConfig")
.WithSummary("Update determinization configuration for the current tenant")
.Produces<EffectiveConfigResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization("PolicyAdmin");
group.MapPost("/validate", ValidateConfig)
.WithName("ValidateDeterminizationConfig")
.WithSummary("Validate determinization configuration without saving")
.Produces<ValidationResponse>(StatusCodes.Status200OK)
.RequireAuthorization("PolicyViewer");
return endpoints;
}
private static async Task<IResult> GetEffectiveConfig(
HttpContext context,
IDeterminizationConfigStore configStore,
ILogger<DeterminizationConfigEndpoints> logger,
CancellationToken ct)
{
var tenantId = GetTenantId(context);
logger.LogDebug("Getting effective determinization config for tenant {TenantId}", tenantId);
var config = await configStore.GetEffectiveConfigAsync(tenantId, ct);
return Results.Ok(new EffectiveConfigResponse
{
Config = config.Config,
IsDefault = config.IsDefault,
TenantId = config.TenantId,
LastUpdatedAt = config.LastUpdatedAt,
LastUpdatedBy = config.LastUpdatedBy,
Version = config.Version
});
}
private static IResult GetDefaultConfig(
ILogger<DeterminizationConfigEndpoints> logger)
{
logger.LogDebug("Getting default determinization config");
return Results.Ok(new DeterminizationOptions());
}
private static async Task<IResult> GetAuditHistory(
HttpContext context,
IDeterminizationConfigStore configStore,
ILogger<DeterminizationConfigEndpoints> logger,
int limit = 50,
CancellationToken ct = default)
{
var tenantId = GetTenantId(context);
logger.LogDebug("Getting audit history for tenant {TenantId}", tenantId);
var entries = await configStore.GetAuditHistoryAsync(tenantId, limit, ct);
return Results.Ok(new AuditHistoryResponse
{
Entries = entries.Select(e => new AuditEntryDto
{
Id = e.Id,
ChangedAt = e.ChangedAt,
Actor = e.Actor,
Reason = e.Reason,
Source = e.Source,
Summary = e.Summary
}).ToList()
});
}
private static async Task<IResult> UpdateConfig(
HttpContext context,
IDeterminizationConfigStore configStore,
ILogger<DeterminizationConfigEndpoints> logger,
UpdateConfigRequest request,
CancellationToken ct)
{
var tenantId = GetTenantId(context);
var actor = GetActorId(context);
logger.LogInformation(
"Updating determinization config for tenant {TenantId} by {Actor}: {Reason}",
tenantId,
actor,
request.Reason);
// Validate config
var validation = ValidateConfigInternal(request.Config);
if (!validation.IsValid)
{
return Results.BadRequest(new { errors = validation.Errors });
}
// Save with audit
await configStore.SaveConfigAsync(
tenantId,
request.Config,
new ConfigAuditInfo
{
Actor = actor,
Reason = request.Reason,
Source = "API",
CorrelationId = context.TraceIdentifier
},
ct);
// Return updated config
var updated = await configStore.GetEffectiveConfigAsync(tenantId, ct);
return Results.Ok(new EffectiveConfigResponse
{
Config = updated.Config,
IsDefault = updated.IsDefault,
TenantId = updated.TenantId,
LastUpdatedAt = updated.LastUpdatedAt,
LastUpdatedBy = updated.LastUpdatedBy,
Version = updated.Version
});
}
private static IResult ValidateConfig(
ValidateConfigRequest request,
ILogger<DeterminizationConfigEndpoints> logger)
{
logger.LogDebug("Validating determinization config");
var validation = ValidateConfigInternal(request.Config);
return Results.Ok(new ValidationResponse
{
IsValid = validation.IsValid,
Errors = validation.Errors,
Warnings = validation.Warnings
});
}
private static (bool IsValid, List<string> Errors, List<string> Warnings) ValidateConfigInternal(
DeterminizationOptions config)
{
var errors = new List<string>();
var warnings = new List<string>();
// Validate trigger config
if (config.Triggers.EpssDeltaThreshold < 0 || config.Triggers.EpssDeltaThreshold > 1)
{
errors.Add("EpssDeltaThreshold must be between 0 and 1");
}
if (config.Triggers.EpssDeltaThreshold < 0.1)
{
warnings.Add("EpssDeltaThreshold below 0.1 may cause excessive reanalysis");
}
// Validate conflict policy
if (config.Conflicts.EscalationSeverityThreshold < 0 || config.Conflicts.EscalationSeverityThreshold > 1)
{
errors.Add("EscalationSeverityThreshold must be between 0 and 1");
}
if (config.Conflicts.ConflictTtlHours < 1)
{
errors.Add("ConflictTtlHours must be at least 1");
}
// Validate environment thresholds
ValidateThresholds(config.Thresholds.Development, "Development", errors, warnings);
ValidateThresholds(config.Thresholds.Staging, "Staging", errors, warnings);
ValidateThresholds(config.Thresholds.Production, "Production", errors, warnings);
return (errors.Count == 0, errors, warnings);
}
private static void ValidateThresholds(
EnvironmentThreshold threshold,
string envName,
List<string> errors,
List<string> warnings)
{
if (threshold.EpssThreshold < 0 || threshold.EpssThreshold > 1)
{
errors.Add($"{envName}.EpssThreshold must be between 0 and 1");
}
if (threshold.UncertaintyFactor < 0 || threshold.UncertaintyFactor > 1)
{
errors.Add($"{envName}.UncertaintyFactor must be between 0 and 1");
}
if (threshold.MinScore < 0 || threshold.MinScore > 100)
{
errors.Add($"{envName}.MinScore must be between 0 and 100");
}
if (threshold.MaxScore < threshold.MinScore)
{
errors.Add($"{envName}.MaxScore must be >= MinScore");
}
}
private static string GetTenantId(HttpContext context)
{
return context.User.FindFirstValue("tenant_id") ?? "default";
}
private static string GetActorId(HttpContext context)
{
return context.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? context.User.FindFirstValue("sub")
?? "system";
}
}
// DTOs
/// <summary>Effective config response.</summary>
public sealed record EffectiveConfigResponse
{
public required DeterminizationOptions Config { get; init; }
public required bool IsDefault { get; init; }
public string? TenantId { get; init; }
public DateTimeOffset? LastUpdatedAt { get; init; }
public string? LastUpdatedBy { get; init; }
public int Version { get; init; }
}
/// <summary>Update config request.</summary>
public sealed record UpdateConfigRequest
{
public required DeterminizationOptions Config { get; init; }
public required string Reason { get; init; }
}
/// <summary>Validate config request.</summary>
public sealed record ValidateConfigRequest
{
public required DeterminizationOptions Config { get; init; }
}
/// <summary>Validation response.</summary>
public sealed record ValidationResponse
{
public required bool IsValid { get; init; }
public required List<string> Errors { get; init; }
public required List<string> Warnings { get; init; }
}
/// <summary>Audit history response.</summary>
public sealed record AuditHistoryResponse
{
public required List<AuditEntryDto> Entries { get; init; }
}
/// <summary>Audit entry DTO.</summary>
public sealed record AuditEntryDto
{
public required Guid Id { get; init; }
public required DateTimeOffset ChangedAt { get; init; }
public required string Actor { get; init; }
public required string Reason { get; init; }
public string? Source { get; init; }
public string? Summary { get; init; }
}
/// <summary>Logger wrapper for DI.</summary>
file class DeterminizationConfigEndpoints { }

View File

@@ -211,6 +211,29 @@ internal static class UnknownsEndpoints
var hint = hintsRegistry.GetHint(u.ReasonCode);
var shortCode = ShortCodes.TryGetValue(u.ReasonCode, out var code) ? code : "U-RCH";
// Sprint: SPRINT_20260112_004_POLICY_unknowns_determinization_greyqueue (POLICY-UNK-003)
var triggersDto = u.Triggers.Count > 0
? u.Triggers.Select(t => new UnknownTriggerDto(
t.EventType,
t.EventVersion,
t.Source,
t.ReceivedAt,
t.CorrelationId)).ToList()
: null;
var conflictDto = u.ConflictInfo is { } ci
? new UnknownConflictInfoDto(
ci.HasConflict,
ci.Severity,
ci.SuggestedPath,
ci.Conflicts.Select(c => new UnknownConflictDetailDto(
c.Signal1,
c.Signal2,
c.Type,
c.Description,
c.Severity)).ToList())
: null;
return new UnknownDto(
u.Id,
u.PackageId,
@@ -228,7 +251,12 @@ internal static class UnknownsEndpoints
u.RemediationHint ?? hint.ShortHint,
hint.DetailedHint,
hint.AutomationRef,
u.EvidenceRefs.Select(e => new EvidenceRefDto(e.Type, e.Uri, e.Digest)).ToList());
u.EvidenceRefs.Select(e => new EvidenceRefDto(e.Type, e.Uri, e.Digest)).ToList(),
u.FingerprintId,
triggersDto,
u.NextActions.Count > 0 ? u.NextActions.ToList() : null,
conflictDto,
u.ObservationState);
}
private static readonly IReadOnlyDictionary<UnknownReasonCode, string> ShortCodes =
@@ -264,13 +292,50 @@ public sealed record UnknownDto(
string? RemediationHint,
string? DetailedHint,
string? AutomationCommand,
IReadOnlyList<EvidenceRefDto> EvidenceRefs);
IReadOnlyList<EvidenceRefDto> EvidenceRefs,
// Sprint: SPRINT_20260112_004_POLICY_unknowns_determinization_greyqueue (POLICY-UNK-003)
string? FingerprintId = null,
IReadOnlyList<UnknownTriggerDto>? Triggers = null,
IReadOnlyList<string>? NextActions = null,
UnknownConflictInfoDto? ConflictInfo = null,
string? ObservationState = null);
public sealed record EvidenceRefDto(
string Type,
string Uri,
string? Digest);
/// <summary>
/// Trigger that caused a reanalysis.
/// Sprint: SPRINT_20260112_004_POLICY_unknowns_determinization_greyqueue (POLICY-UNK-003)
/// </summary>
public sealed record UnknownTriggerDto(
string EventType,
int EventVersion,
string? Source,
DateTimeOffset ReceivedAt,
string? CorrelationId);
/// <summary>
/// Conflict information for an unknown.
/// Sprint: SPRINT_20260112_004_POLICY_unknowns_determinization_greyqueue (POLICY-UNK-003)
/// </summary>
public sealed record UnknownConflictInfoDto(
bool HasConflict,
double Severity,
string SuggestedPath,
IReadOnlyList<UnknownConflictDetailDto> Conflicts);
/// <summary>
/// Detail of a specific conflict.
/// </summary>
public sealed record UnknownConflictDetailDto(
string Signal1,
string Signal2,
string Type,
string Description,
double Severity);
/// <summary>Response containing a list of unknowns.</summary>
public sealed record UnknownsListResponse(IReadOnlyList<UnknownDto> Items, int TotalCount);

View File

@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Determinization.Evidence;
using StellaOps.Policy.Determinization.Models;
namespace StellaOps.Policy.Engine.Gates.Determinization;
@@ -62,14 +63,91 @@ public sealed class SignalSnapshotBuilder : ISignalSnapshotBuilder
private static string BuildSubjectKey(string cveId, string componentPurl)
=> $"{cveId}::{componentPurl}";
// Sprint: SPRINT_20260112_004_BE_policy_determinization_attested_rules (DET-ATT-002)
// Map signals to snapshot with anchor metadata support
private SignalSnapshot ApplySignal(SignalSnapshot snapshot, Signal signal)
{
// This is a placeholder implementation
// In a real implementation, this would map Signal objects to SignalState<T> instances
// based on signal type and update the appropriate field in the snapshot
var queriedAt = signal.ObservedAt;
return signal.Type.ToUpperInvariant() switch
{
"VEX" => snapshot with
{
Vex = MapSignalState<VexClaimSummary>(signal, queriedAt)
},
"EPSS" => snapshot with
{
Epss = MapSignalState<EpssEvidence>(signal, queriedAt)
},
"REACHABILITY" => snapshot with
{
Reachability = MapSignalState<ReachabilityEvidence>(signal, queriedAt)
},
"RUNTIME" => snapshot with
{
Runtime = MapSignalState<RuntimeEvidence>(signal, queriedAt)
},
"BACKPORT" => snapshot with
{
Backport = MapSignalState<BackportEvidence>(signal, queriedAt)
},
"SBOM" => snapshot with
{
Sbom = MapSignalState<SbomLineageEvidence>(signal, queriedAt)
},
"CVSS" => snapshot with
{
Cvss = MapSignalState<CvssEvidence>(signal, queriedAt)
},
_ => HandleUnknownSignalType(snapshot, signal.Type)
};
}
private SignalSnapshot HandleUnknownSignalType(SignalSnapshot snapshot, string signalType)
{
_logger.LogWarning("Unknown signal type: {Type}", signalType);
return snapshot;
}
/// <summary>
/// Maps a raw signal to a typed SignalState with proper evidence casting.
/// Handles anchor metadata propagation from stored evidence.
/// </summary>
private static SignalState<T> MapSignalState<T>(Signal signal, DateTimeOffset queriedAt)
{
if (signal.Evidence is null)
{
return SignalState<T>.Queried(default, queriedAt);
}
// Handle direct type match
if (signal.Evidence is T typedEvidence)
{
return SignalState<T>.Queried(typedEvidence, queriedAt);
}
// Handle JSON element deserialization (common when evidence comes from storage)
if (signal.Evidence is System.Text.Json.JsonElement jsonElement)
{
try
{
var deserialized = System.Text.Json.JsonSerializer.Deserialize<T>(
jsonElement.GetRawText(),
new System.Text.Json.JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return SignalState<T>.Queried(deserialized, queriedAt);
}
catch (System.Text.Json.JsonException)
{
return SignalState<T>.Failed($"Failed to deserialize {typeof(T).Name}", queriedAt);
}
}
// Cannot convert
return SignalState<T>.Failed($"Cannot convert {signal.Evidence.GetType().Name} to {typeof(T).Name}", queriedAt);
}
}
/// <summary>

View File

@@ -23,6 +23,69 @@ public sealed class DeterminizationRuleSet
public static DeterminizationRuleSet Default(DeterminizationOptions options) =>
new(new List<DeterminizationRule>
{
// Sprint: SPRINT_20260112_004_BE_policy_determinization_attested_rules (DET-ATT-003)
// Anchored rules have highest priority to short-circuit evaluation
// Rule 0a: Hard-fail if anchored VEX affected + anchored runtime telemetry confirms
new DeterminizationRule
{
Name = "AnchoredAffectedWithRuntimeHardFail",
Priority = 1,
Condition = (ctx, _) =>
ctx.SignalSnapshot.Vex.HasValue &&
ctx.SignalSnapshot.Vex.Value!.IsAnchored &&
string.Equals(ctx.SignalSnapshot.Vex.Value.Status, "affected", StringComparison.OrdinalIgnoreCase) &&
ctx.SignalSnapshot.Runtime.HasValue &&
ctx.SignalSnapshot.Runtime.Value!.IsAnchored &&
ctx.SignalSnapshot.Runtime.Value.Detected,
Action = (ctx, _) =>
DeterminizationResult.Blocked(
"Anchored VEX affected status combined with anchored runtime telemetry confirms active vulnerability - hard fail")
},
// Rule 0b: Allow if anchored VEX not_affected
new DeterminizationRule
{
Name = "AnchoredVexNotAffectedAllow",
Priority = 2,
Condition = (ctx, _) =>
ctx.SignalSnapshot.Vex.HasValue &&
ctx.SignalSnapshot.Vex.Value!.IsAnchored &&
(string.Equals(ctx.SignalSnapshot.Vex.Value.Status, "not_affected", StringComparison.OrdinalIgnoreCase) ||
string.Equals(ctx.SignalSnapshot.Vex.Value.Status, "fixed", StringComparison.OrdinalIgnoreCase)),
Action = (ctx, _) =>
DeterminizationResult.Allowed(
$"Anchored VEX statement indicates {ctx.SignalSnapshot.Vex.Value!.Status} - short-circuit allow")
},
// Rule 0c: Allow if anchored backport proof
new DeterminizationRule
{
Name = "AnchoredBackportProofAllow",
Priority = 3,
Condition = (ctx, _) =>
ctx.SignalSnapshot.Backport.HasValue &&
ctx.SignalSnapshot.Backport.Value!.IsAnchored &&
ctx.SignalSnapshot.Backport.Value.Detected,
Action = (ctx, _) =>
DeterminizationResult.Allowed(
$"Anchored backport proof confirms patch applied (source: {ctx.SignalSnapshot.Backport.Value!.Source}) - short-circuit allow")
},
// Rule 0d: Allow if anchored reachability not_reachable
new DeterminizationRule
{
Name = "AnchoredUnreachableAllow",
Priority = 4,
Condition = (ctx, _) =>
ctx.SignalSnapshot.Reachability.HasValue &&
ctx.SignalSnapshot.Reachability.Value!.IsAnchored &&
!ctx.SignalSnapshot.Reachability.Value.IsReachable,
Action = (ctx, _) =>
DeterminizationResult.Allowed(
"Anchored reachability analysis confirms code is unreachable - short-circuit allow")
},
// Rule 1: Escalate if runtime evidence shows vulnerable code loaded
new DeterminizationRule
{

View File

@@ -45,6 +45,11 @@ public sealed record DeterminizationResult
public static DeterminizationResult Quarantined(string reason, PolicyVerdictStatus status = PolicyVerdictStatus.Blocked) =>
new() { Status = status, Reason = reason };
// Sprint: SPRINT_20260112_004_BE_policy_determinization_attested_rules (DET-ATT-003)
/// <summary>Creates a hard-fail blocked result for anchored evidence confirming active vulnerability.</summary>
public static DeterminizationResult Blocked(string reason) =>
new() { Status = PolicyVerdictStatus.Blocked, Reason = reason, SuggestedState = ObservationState.Disputed };
public static DeterminizationResult Escalated(string reason, PolicyVerdictStatus status = PolicyVerdictStatus.Escalated) =>
new() { Status = status, Reason = reason };

View File

@@ -4,6 +4,7 @@ namespace StellaOps.Policy.Engine.Subscriptions;
/// <summary>
/// Events for signal updates that trigger re-evaluation.
/// Sprint: SPRINT_20260112_004_POLICY_unknowns_determinization_greyqueue (POLICY-UNK-005)
/// </summary>
public static class DeterminizationEventTypes
{
@@ -13,20 +14,39 @@ public static class DeterminizationEventTypes
public const string RuntimeUpdated = "runtime.updated";
public const string BackportUpdated = "backport.updated";
public const string ObservationStateChanged = "observation.state_changed";
// Sprint: SPRINT_20260112_004_POLICY_unknowns_determinization_greyqueue (POLICY-UNK-005)
// Additional event types for reanalysis triggers
public const string SbomUpdated = "sbom.updated";
public const string DsseValidationChanged = "dsse.validation_changed";
public const string RekorEntryAdded = "rekor.entry_added";
public const string PatchProofAdded = "patch.proof_added";
public const string ToolVersionChanged = "tool.version_changed";
}
/// <summary>
/// Event published when a signal is updated.
/// Sprint: SPRINT_20260112_004_POLICY_unknowns_determinization_greyqueue (POLICY-UNK-005)
/// </summary>
public sealed record SignalUpdatedEvent
{
public required string EventType { get; init; }
/// <summary>Event schema version (default: 1).</summary>
public int EventVersion { get; init; } = 1;
public required string CveId { get; init; }
public required string Purl { get; init; }
public required DateTimeOffset UpdatedAt { get; init; }
public required string Source { get; init; }
public object? NewValue { get; init; }
public object? PreviousValue { get; init; }
/// <summary>Correlation ID for tracing event chains.</summary>
public string? CorrelationId { get; init; }
/// <summary>Additional metadata for event processing.</summary>
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
}
/// <summary>

View File

@@ -1,36 +1,86 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Determinization;
using StellaOps.Policy.Determinization.Models;
using StellaOps.Policy.Engine.Gates;
namespace StellaOps.Policy.Engine.Subscriptions;
/// <summary>
/// Implementation of signal update handling.
/// Implementation of signal update handling with versioned event mapping.
/// Sprint: SPRINT_20260112_004_POLICY_unknowns_determinization_greyqueue (POLICY-UNK-005)
/// </summary>
public sealed class SignalUpdateHandler : ISignalUpdateSubscription
{
private readonly IObservationRepository _observations;
private readonly IDeterminizationGate _gate;
private readonly IEventPublisher _eventPublisher;
private readonly DeterminizationOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<SignalUpdateHandler> _logger;
// Sprint: SPRINT_20260112_004_POLICY_unknowns_determinization_greyqueue (POLICY-UNK-005)
// Event version registry for compatibility
private static readonly IReadOnlyDictionary<string, int> CurrentEventVersions = new Dictionary<string, int>
{
[DeterminizationEventTypes.EpssUpdated] = 1,
[DeterminizationEventTypes.VexUpdated] = 1,
[DeterminizationEventTypes.ReachabilityUpdated] = 1,
[DeterminizationEventTypes.RuntimeUpdated] = 1,
[DeterminizationEventTypes.BackportUpdated] = 1,
[DeterminizationEventTypes.SbomUpdated] = 1,
[DeterminizationEventTypes.DsseValidationChanged] = 1,
[DeterminizationEventTypes.RekorEntryAdded] = 1,
[DeterminizationEventTypes.PatchProofAdded] = 1,
[DeterminizationEventTypes.ToolVersionChanged] = 1
};
public SignalUpdateHandler(
IObservationRepository observations,
IDeterminizationGate gate,
IEventPublisher eventPublisher,
IOptions<DeterminizationOptions> options,
TimeProvider timeProvider,
ILogger<SignalUpdateHandler> logger)
{
_observations = observations;
_gate = gate;
_eventPublisher = eventPublisher;
_options = options.Value;
_timeProvider = timeProvider;
_logger = logger;
}
// Legacy constructor for backward compatibility
public SignalUpdateHandler(
IObservationRepository observations,
IDeterminizationGate gate,
IEventPublisher eventPublisher,
ILogger<SignalUpdateHandler> logger)
: this(observations, gate, eventPublisher,
Options.Create(new DeterminizationOptions()),
TimeProvider.System,
logger)
{
}
public async Task HandleAsync(SignalUpdatedEvent evt, CancellationToken ct = default)
{
// Sprint: SPRINT_20260112_004_POLICY_unknowns_determinization_greyqueue (POLICY-UNK-005)
// Check if this event type should trigger reanalysis
if (!ShouldTriggerReanalysis(evt))
{
_logger.LogDebug(
"Event {EventType}@{EventVersion} does not trigger reanalysis per config",
evt.EventType,
evt.EventVersion);
return;
}
_logger.LogInformation(
"Processing signal update: {EventType} for CVE {CveId} on {Purl}",
"Processing signal update: {EventType}@{EventVersion} for CVE {CveId} on {Purl}",
evt.EventType,
evt.EventVersion,
evt.CveId,
evt.Purl);
@@ -52,23 +102,107 @@ public sealed class SignalUpdateHandler : ISignalUpdateSubscription
}
}
/// <summary>
/// Determines if an event should trigger reanalysis based on config.
/// Sprint: SPRINT_20260112_004_POLICY_unknowns_determinization_greyqueue (POLICY-UNK-005)
/// </summary>
private bool ShouldTriggerReanalysis(SignalUpdatedEvent evt)
{
var triggers = _options.Triggers;
return evt.EventType switch
{
DeterminizationEventTypes.EpssUpdated =>
triggers.TriggerOnThresholdCrossing && MeetsEpssDeltaThreshold(evt),
DeterminizationEventTypes.VexUpdated =>
triggers.TriggerOnVexStatusChange,
DeterminizationEventTypes.ReachabilityUpdated or
DeterminizationEventTypes.RuntimeUpdated =>
triggers.TriggerOnRuntimeTelemetryChange,
DeterminizationEventTypes.BackportUpdated or
DeterminizationEventTypes.PatchProofAdded =>
triggers.TriggerOnPatchProofAdded,
DeterminizationEventTypes.DsseValidationChanged =>
triggers.TriggerOnDsseValidationChange,
DeterminizationEventTypes.RekorEntryAdded =>
triggers.TriggerOnRekorEntry,
DeterminizationEventTypes.ToolVersionChanged =>
triggers.TriggerOnToolVersionChange,
DeterminizationEventTypes.SbomUpdated =>
true, // Always trigger for SBOM changes
_ => true // Unknown events default to trigger
};
}
/// <summary>
/// Check if EPSS delta meets threshold.
/// </summary>
private bool MeetsEpssDeltaThreshold(SignalUpdatedEvent evt)
{
if (evt.Metadata is null ||
!evt.Metadata.TryGetValue("delta", out var deltaObj) ||
deltaObj is not double delta)
{
return true; // If no delta info, trigger anyway
}
return Math.Abs(delta) >= _options.Triggers.EpssDeltaThreshold;
}
/// <summary>
/// Gets the current version for an event type.
/// </summary>
public static int GetCurrentEventVersion(string eventType) =>
CurrentEventVersions.TryGetValue(eventType, out var version) ? version : 1;
/// <summary>
/// Checks if an event version is supported.
/// </summary>
public static bool IsVersionSupported(string eventType, int version)
{
if (!CurrentEventVersions.TryGetValue(eventType, out var currentVersion))
{
return true; // Unknown events are allowed
}
return version <= currentVersion;
}
private async Task ReEvaluateObservationAsync(
CveObservation obs,
SignalUpdatedEvent trigger,
CancellationToken ct)
{
// This is a placeholder for re-evaluation logic
// In a full implementation, this would:
// 1. Build PolicyGateContext from observation
// 2. Call gate.EvaluateDeterminizationAsync()
// 3. Compare new verdict with old verdict
// 4. Publish ObservationStateChangedEvent if state changed
// 5. Update observation in repository
_logger.LogDebug(
"Re-evaluating observation {ObservationId} after {EventType}",
"Re-evaluating observation {ObservationId} after {EventType}@{EventVersion}",
obs.Id,
trigger.EventType);
trigger.EventType,
trigger.EventVersion);
// Sprint: SPRINT_20260112_004_POLICY_unknowns_determinization_greyqueue (POLICY-UNK-005)
// Build reanalysis trigger for fingerprint
var reanalysisTrigger = new ReanalysisTrigger
{
EventType = trigger.EventType,
EventVersion = trigger.EventVersion,
Source = trigger.Source,
ReceivedAt = _timeProvider.GetUtcNow(),
CorrelationId = trigger.CorrelationId
};
// TODO: Full implementation would:
// 1. Build PolicyGateContext from observation
// 2. Call gate.EvaluateDeterminizationAsync() with trigger info
// 3. Compare new verdict with old verdict
// 4. If state changed, publish ObservationStateChangedEvent
// 5. Update observation in repository with new fingerprint
await Task.CompletedTask;
}

View File

@@ -2,6 +2,7 @@ namespace StellaOps.Policy.Determinization;
/// <summary>
/// Configuration options for the Determinization subsystem.
/// Sprint: SPRINT_20260112_012_POLICY_determinization_reanalysis_config (POLICY-CONFIG-001)
/// </summary>
public sealed record DeterminizationOptions
{
@@ -37,4 +38,174 @@ public sealed record DeterminizationOptions
/// <summary>Maximum retry attempts for failed signal queries (default: 3).</summary>
public int MaxSignalQueryRetries { get; init; } = 3;
// Sprint: SPRINT_20260112_012_POLICY_determinization_reanalysis_config (POLICY-CONFIG-001)
/// <summary>Reanalysis trigger configuration.</summary>
public ReanalysisTriggerConfig Triggers { get; init; } = new();
/// <summary>Conflict handling policy.</summary>
public ConflictHandlingPolicy ConflictPolicy { get; init; } = new();
/// <summary>Per-environment threshold overrides.</summary>
public EnvironmentThresholds EnvironmentThresholds { get; init; } = new();
}
/// <summary>
/// Configuration for reanalysis triggers.
/// Sprint: SPRINT_20260112_012_POLICY_determinization_reanalysis_config (POLICY-CONFIG-001)
/// </summary>
public sealed record ReanalysisTriggerConfig
{
/// <summary>Trigger on EPSS delta >= this value (default: 0.2).</summary>
public double EpssDeltaThreshold { get; init; } = 0.2;
/// <summary>Trigger when entropy crosses threshold (default: true).</summary>
public bool TriggerOnThresholdCrossing { get; init; } = true;
/// <summary>Trigger on new Rekor entry (default: true).</summary>
public bool TriggerOnRekorEntry { get; init; } = true;
/// <summary>Trigger on OpenVEX status change (default: true).</summary>
public bool TriggerOnVexStatusChange { get; init; } = true;
/// <summary>Trigger on runtime telemetry exploit/reachability change (default: true).</summary>
public bool TriggerOnRuntimeTelemetryChange { get; init; } = true;
/// <summary>Trigger on binary patch proof added (default: true).</summary>
public bool TriggerOnPatchProofAdded { get; init; } = true;
/// <summary>Trigger on DSSE validation state change (default: true).</summary>
public bool TriggerOnDsseValidationChange { get; init; } = true;
/// <summary>Trigger on tool version update (default: false).</summary>
public bool TriggerOnToolVersionChange { get; init; } = false;
/// <summary>Minimum interval between reanalyses in minutes (default: 15).</summary>
public int MinReanalysisIntervalMinutes { get; init; } = 15;
/// <summary>Maximum reanalyses per day per CVE (default: 10).</summary>
public int MaxReanalysesPerDayPerCve { get; init; } = 10;
}
/// <summary>
/// Conflict handling policy configuration.
/// Sprint: SPRINT_20260112_012_POLICY_determinization_reanalysis_config (POLICY-CONFIG-001)
/// </summary>
public sealed record ConflictHandlingPolicy
{
/// <summary>Action to take when VEX/reachability conflict is detected.</summary>
public ConflictAction VexReachabilityConflictAction { get; init; } = ConflictAction.RequireManualReview;
/// <summary>Action to take when static/runtime conflict is detected.</summary>
public ConflictAction StaticRuntimeConflictAction { get; init; } = ConflictAction.RequireManualReview;
/// <summary>Action to take when multiple VEX sources conflict.</summary>
public ConflictAction VexStatusConflictAction { get; init; } = ConflictAction.RequestVendorClarification;
/// <summary>Action to take when backport/status conflict is detected.</summary>
public ConflictAction BackportStatusConflictAction { get; init; } = ConflictAction.RequireManualReview;
/// <summary>Severity threshold above which conflicts require escalation (default: 0.85).</summary>
public double EscalationSeverityThreshold { get; init; } = 0.85;
/// <summary>Time-to-live for conflicts before auto-escalation in hours (default: 48).</summary>
public int ConflictTtlHours { get; init; } = 48;
/// <summary>Enable automatic conflict resolution for low-severity conflicts (default: false).</summary>
public bool EnableAutoResolution { get; init; } = false;
}
/// <summary>
/// Action to take when a conflict is detected.
/// </summary>
public enum ConflictAction
{
/// <summary>Log and continue with existing verdict.</summary>
LogAndContinue,
/// <summary>Require manual security review.</summary>
RequireManualReview,
/// <summary>Request clarification from vendor.</summary>
RequestVendorClarification,
/// <summary>Escalate to security steering committee.</summary>
EscalateToCommittee,
/// <summary>Block release until resolved.</summary>
BlockUntilResolved
}
/// <summary>
/// Per-environment threshold configuration.
/// Sprint: SPRINT_20260112_012_POLICY_determinization_reanalysis_config (POLICY-CONFIG-001)
/// </summary>
public sealed record EnvironmentThresholds
{
/// <summary>Development environment thresholds.</summary>
public EnvironmentThresholdValues Development { get; init; } = EnvironmentThresholdValues.Relaxed;
/// <summary>Staging environment thresholds.</summary>
public EnvironmentThresholdValues Staging { get; init; } = EnvironmentThresholdValues.Standard;
/// <summary>Production environment thresholds.</summary>
public EnvironmentThresholdValues Production { get; init; } = EnvironmentThresholdValues.Strict;
/// <summary>Get thresholds for a named environment.</summary>
public EnvironmentThresholdValues GetForEnvironment(string environmentName)
{
return environmentName?.ToUpperInvariant() switch
{
"DEV" or "DEVELOPMENT" => Development,
"STAGE" or "STAGING" or "QA" => Staging,
"PROD" or "PRODUCTION" => Production,
_ => Staging // Default to staging thresholds
};
}
}
/// <summary>
/// Threshold values for a specific environment.
/// </summary>
public sealed record EnvironmentThresholdValues
{
/// <summary>Maximum entropy allowed for pass verdict.</summary>
public double MaxPassEntropy { get; init; }
/// <summary>Minimum evidence count required for pass verdict.</summary>
public int MinEvidenceCount { get; init; }
/// <summary>Whether DSSE signing is required.</summary>
public bool RequireDsseSigning { get; init; }
/// <summary>Whether Rekor transparency is required.</summary>
public bool RequireRekorTransparency { get; init; }
/// <summary>Standard thresholds for staging-like environments.</summary>
public static EnvironmentThresholdValues Standard => new()
{
MaxPassEntropy = 0.40,
MinEvidenceCount = 2,
RequireDsseSigning = false,
RequireRekorTransparency = false
};
/// <summary>Relaxed thresholds for development environments.</summary>
public static EnvironmentThresholdValues Relaxed => new()
{
MaxPassEntropy = 0.60,
MinEvidenceCount = 1,
RequireDsseSigning = false,
RequireRekorTransparency = false
};
/// <summary>Strict thresholds for production environments.</summary>
public static EnvironmentThresholdValues Strict => new()
{
MaxPassEntropy = 0.25,
MinEvidenceCount = 3,
RequireDsseSigning = true,
RequireRekorTransparency = true
};
}

View File

@@ -48,4 +48,18 @@ public sealed record BackportEvidence
/// </summary>
[JsonPropertyName("confidence")]
public required double Confidence { get; init; }
// Sprint: SPRINT_20260112_004_BE_policy_determinization_attested_rules (DET-ATT-002)
/// <summary>
/// Anchor metadata for the backport evidence (DSSE envelope, Rekor, etc.).
/// </summary>
[JsonPropertyName("anchor")]
public EvidenceAnchor? Anchor { get; init; }
/// <summary>
/// Whether the backport evidence is anchored (has DSSE/Rekor attestation).
/// </summary>
[JsonIgnore]
public bool IsAnchored => Anchor?.Anchored == true;
}

View File

@@ -0,0 +1,94 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) 2025 StellaOps
// Sprint: SPRINT_20260112_004_BE_policy_determinization_attested_rules (DET-ATT-002)
// Task: Shared anchor metadata for all evidence types
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Evidence;
/// <summary>
/// Shared anchor metadata for cryptographically attested evidence.
/// Used across VEX, backport, runtime, and reachability evidence types.
/// </summary>
public sealed record EvidenceAnchor
{
/// <summary>
/// Whether the evidence is anchored with attestation.
/// </summary>
[JsonPropertyName("anchored")]
public required bool Anchored { get; init; }
/// <summary>
/// DSSE envelope digest (sha256:hex).
/// </summary>
[JsonPropertyName("envelope_digest")]
public string? EnvelopeDigest { get; init; }
/// <summary>
/// Predicate type of the attestation.
/// </summary>
[JsonPropertyName("predicate_type")]
public string? PredicateType { get; init; }
/// <summary>
/// Rekor log index if transparency-anchored.
/// </summary>
[JsonPropertyName("rekor_log_index")]
public long? RekorLogIndex { get; init; }
/// <summary>
/// Rekor entry ID if transparency-anchored.
/// </summary>
[JsonPropertyName("rekor_entry_id")]
public string? RekorEntryId { get; init; }
/// <summary>
/// Scope of the attestation (e.g., "finding", "package", "image").
/// </summary>
[JsonPropertyName("scope")]
public string? Scope { get; init; }
/// <summary>
/// Whether the attestation signature has been verified.
/// </summary>
[JsonPropertyName("verified")]
public bool? Verified { get; init; }
/// <summary>
/// Timestamp when the attestation was created (UTC).
/// </summary>
[JsonPropertyName("attested_at")]
public DateTimeOffset? AttestedAt { get; init; }
/// <summary>
/// Whether the evidence is Rekor-anchored (has log index).
/// </summary>
[JsonIgnore]
public bool IsRekorAnchored => RekorLogIndex.HasValue;
/// <summary>
/// Creates an unanchored evidence anchor.
/// </summary>
public static EvidenceAnchor Unanchored => new() { Anchored = false };
/// <summary>
/// Creates an anchored evidence anchor with basic info.
/// </summary>
public static EvidenceAnchor CreateAnchored(
string envelopeDigest,
string predicateType,
long? rekorLogIndex = null,
string? rekorEntryId = null,
bool? verified = null,
DateTimeOffset? attestedAt = null) => new()
{
Anchored = true,
EnvelopeDigest = envelopeDigest,
PredicateType = predicateType,
RekorLogIndex = rekorLogIndex,
RekorEntryId = rekorEntryId,
Verified = verified,
AttestedAt = attestedAt
};
}

View File

@@ -54,6 +54,20 @@ public sealed record ReachabilityEvidence
/// </summary>
[JsonIgnore]
public bool IsReachable => Status == ReachabilityStatus.Reachable;
// Sprint: SPRINT_20260112_004_BE_policy_determinization_attested_rules (DET-ATT-002)
/// <summary>
/// Anchor metadata for the reachability evidence (DSSE envelope, Rekor, etc.).
/// </summary>
[JsonPropertyName("anchor")]
public EvidenceAnchor? Anchor { get; init; }
/// <summary>
/// Whether the reachability evidence is anchored (has DSSE/Rekor attestation).
/// </summary>
[JsonIgnore]
public bool IsAnchored => Anchor?.Anchored == true;
}
/// <summary>

View File

@@ -49,4 +49,18 @@ public sealed record RuntimeEvidence
/// </summary>
[JsonIgnore]
public bool ObservedLoaded => Detected;
// Sprint: SPRINT_20260112_004_BE_policy_determinization_attested_rules (DET-ATT-002)
/// <summary>
/// Anchor metadata for the runtime evidence (DSSE envelope, Rekor, etc.).
/// </summary>
[JsonPropertyName("anchor")]
public EvidenceAnchor? Anchor { get; init; }
/// <summary>
/// Whether the runtime evidence is anchored (has DSSE/Rekor attestation).
/// </summary>
[JsonIgnore]
public bool IsAnchored => Anchor?.Anchored == true;
}

View File

@@ -0,0 +1,210 @@
// <copyright file="IDeterminizationConfigStore.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_20260112_012_POLICY_determinization_reanalysis_config (POLICY-CONFIG-002)
// </copyright>
namespace StellaOps.Policy.Determinization;
/// <summary>
/// Store for per-tenant determinization configuration with audit trail.
/// Sprint: SPRINT_20260112_012_POLICY_determinization_reanalysis_config (POLICY-CONFIG-002)
/// </summary>
public interface IDeterminizationConfigStore
{
/// <summary>
/// Gets the effective configuration for a tenant.
/// Returns default config if no tenant-specific config exists.
/// </summary>
Task<EffectiveDeterminizationConfig> GetEffectiveConfigAsync(
string tenantId,
CancellationToken ct = default);
/// <summary>
/// Saves configuration for a tenant with audit information.
/// </summary>
Task SaveConfigAsync(
string tenantId,
DeterminizationOptions config,
ConfigAuditInfo auditInfo,
CancellationToken ct = default);
/// <summary>
/// Gets the audit history for a tenant's configuration changes.
/// </summary>
Task<IReadOnlyList<ConfigAuditEntry>> GetAuditHistoryAsync(
string tenantId,
int limit = 50,
CancellationToken ct = default);
}
/// <summary>
/// Effective configuration with metadata.
/// </summary>
public sealed record EffectiveDeterminizationConfig
{
/// <summary>The active configuration values.</summary>
public required DeterminizationOptions Config { get; init; }
/// <summary>Whether this is the default config or tenant-specific.</summary>
public required bool IsDefault { get; init; }
/// <summary>Tenant ID (null for default).</summary>
public string? TenantId { get; init; }
/// <summary>When the config was last updated.</summary>
public DateTimeOffset? LastUpdatedAt { get; init; }
/// <summary>Who last updated the config.</summary>
public string? LastUpdatedBy { get; init; }
/// <summary>Configuration version for optimistic concurrency.</summary>
public int Version { get; init; }
}
/// <summary>
/// Audit information for config changes.
/// </summary>
public sealed record ConfigAuditInfo
{
/// <summary>User or system making the change.</summary>
public required string Actor { get; init; }
/// <summary>Reason for the change.</summary>
public required string Reason { get; init; }
/// <summary>Source of the change (UI, API, CLI, etc.).</summary>
public string? Source { get; init; }
/// <summary>Correlation ID for tracing.</summary>
public string? CorrelationId { get; init; }
}
/// <summary>
/// Audit trail entry for config changes.
/// </summary>
public sealed record ConfigAuditEntry
{
/// <summary>Unique entry ID.</summary>
public required Guid Id { get; init; }
/// <summary>Tenant ID.</summary>
public required string TenantId { get; init; }
/// <summary>When the change occurred.</summary>
public required DateTimeOffset ChangedAt { get; init; }
/// <summary>User or system making the change.</summary>
public required string Actor { get; init; }
/// <summary>Reason for the change.</summary>
public required string Reason { get; init; }
/// <summary>Source of the change.</summary>
public string? Source { get; init; }
/// <summary>The previous configuration (JSON).</summary>
public string? PreviousConfig { get; init; }
/// <summary>The new configuration (JSON).</summary>
public required string NewConfig { get; init; }
/// <summary>Change summary.</summary>
public string? Summary { get; init; }
}
/// <summary>
/// In-memory implementation of <see cref="IDeterminizationConfigStore"/> for testing.
/// Sprint: SPRINT_20260112_012_POLICY_determinization_reanalysis_config (POLICY-CONFIG-002)
/// </summary>
public sealed class InMemoryDeterminizationConfigStore : IDeterminizationConfigStore
{
private readonly Dictionary<string, (DeterminizationOptions Config, int Version, DateTimeOffset UpdatedAt, string UpdatedBy)> _configs = new();
private readonly List<ConfigAuditEntry> _auditLog = [];
private readonly DeterminizationOptions _defaultConfig = new();
private readonly object _lock = new();
public Task<EffectiveDeterminizationConfig> GetEffectiveConfigAsync(
string tenantId,
CancellationToken ct = default)
{
lock (_lock)
{
if (_configs.TryGetValue(tenantId, out var entry))
{
return Task.FromResult(new EffectiveDeterminizationConfig
{
Config = entry.Config,
IsDefault = false,
TenantId = tenantId,
LastUpdatedAt = entry.UpdatedAt,
LastUpdatedBy = entry.UpdatedBy,
Version = entry.Version
});
}
return Task.FromResult(new EffectiveDeterminizationConfig
{
Config = _defaultConfig,
IsDefault = true,
TenantId = null,
LastUpdatedAt = null,
LastUpdatedBy = null,
Version = 0
});
}
}
public Task SaveConfigAsync(
string tenantId,
DeterminizationOptions config,
ConfigAuditInfo auditInfo,
CancellationToken ct = default)
{
lock (_lock)
{
string? previousConfigJson = null;
var version = 1;
if (_configs.TryGetValue(tenantId, out var existing))
{
previousConfigJson = System.Text.Json.JsonSerializer.Serialize(existing.Config);
version = existing.Version + 1;
}
var now = DateTimeOffset.UtcNow;
_configs[tenantId] = (config, version, now, auditInfo.Actor);
_auditLog.Add(new ConfigAuditEntry
{
Id = Guid.NewGuid(),
TenantId = tenantId,
ChangedAt = now,
Actor = auditInfo.Actor,
Reason = auditInfo.Reason,
Source = auditInfo.Source,
PreviousConfig = previousConfigJson,
NewConfig = System.Text.Json.JsonSerializer.Serialize(config),
Summary = $"Config updated by {auditInfo.Actor}: {auditInfo.Reason}"
});
}
return Task.CompletedTask;
}
public Task<IReadOnlyList<ConfigAuditEntry>> GetAuditHistoryAsync(
string tenantId,
int limit = 50,
CancellationToken ct = default)
{
lock (_lock)
{
var entries = _auditLog
.Where(e => e.TenantId == tenantId)
.OrderByDescending(e => e.ChangedAt)
.Take(limit)
.ToList();
return Task.FromResult<IReadOnlyList<ConfigAuditEntry>>(entries);
}
}
}

View File

@@ -5,6 +5,7 @@ namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Result of determinization evaluation.
/// Combines observation state, uncertainty score, and guardrails.
/// Sprint: SPRINT_20260112_004_POLICY_unknowns_determinization_greyqueue (POLICY-UNK-001)
/// </summary>
public sealed record DeterminizationResult
{
@@ -50,6 +51,13 @@ public sealed record DeterminizationResult
[JsonPropertyName("rationale")]
public string? Rationale { get; init; }
/// <summary>
/// Reanalysis fingerprint for deterministic replay.
/// Sprint: SPRINT_20260112_004_POLICY_unknowns_determinization_greyqueue (POLICY-UNK-001)
/// </summary>
[JsonPropertyName("fingerprint")]
public ReanalysisFingerprint? Fingerprint { get; init; }
/// <summary>
/// Creates result for determined observation (low uncertainty).
/// </summary>

View File

@@ -0,0 +1,297 @@
// <copyright file="ReanalysisFingerprint.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_20260112_004_POLICY_unknowns_determinization_greyqueue (POLICY-UNK-001)
// </copyright>
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Deterministic fingerprint for reanalysis triggering and replay verification.
/// Content-addressed to enable reproducible policy evaluations.
/// </summary>
public sealed record ReanalysisFingerprint
{
private static readonly JsonSerializerOptions CanonicalOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
/// <summary>
/// Content-addressed fingerprint ID (sha256:...).
/// </summary>
[JsonPropertyName("fingerprint_id")]
public required string FingerprintId { get; init; }
/// <summary>
/// DSSE bundle digest for evidence provenance.
/// </summary>
[JsonPropertyName("dsse_bundle_digest")]
public string? DsseBundleDigest { get; init; }
/// <summary>
/// Sorted list of evidence digests contributing to this fingerprint.
/// </summary>
[JsonPropertyName("evidence_digests")]
public IReadOnlyList<string> EvidenceDigests { get; init; } = [];
/// <summary>
/// Tool versions used for evaluation (deterministic ordering).
/// </summary>
[JsonPropertyName("tool_versions")]
public IReadOnlyDictionary<string, string> ToolVersions { get; init; } = new Dictionary<string, string>();
/// <summary>
/// Product version under evaluation.
/// </summary>
[JsonPropertyName("product_version")]
public string? ProductVersion { get; init; }
/// <summary>
/// Policy configuration hash at evaluation time.
/// </summary>
[JsonPropertyName("policy_config_hash")]
public string? PolicyConfigHash { get; init; }
/// <summary>
/// Signal weights hash for determinism verification.
/// </summary>
[JsonPropertyName("signal_weights_hash")]
public string? SignalWeightsHash { get; init; }
/// <summary>
/// When this fingerprint was computed (UTC ISO-8601).
/// </summary>
[JsonPropertyName("computed_at")]
public required DateTimeOffset ComputedAt { get; init; }
/// <summary>
/// Triggers that caused this reanalysis.
/// </summary>
[JsonPropertyName("triggers")]
public IReadOnlyList<ReanalysisTrigger> Triggers { get; init; } = [];
/// <summary>
/// Suggested next actions based on current state.
/// </summary>
[JsonPropertyName("next_actions")]
public IReadOnlyList<string> NextActions { get; init; } = [];
}
/// <summary>
/// Trigger that caused a reanalysis.
/// </summary>
public sealed record ReanalysisTrigger
{
/// <summary>
/// Event type that triggered reanalysis (e.g., epss.updated, vex.changed).
/// </summary>
[JsonPropertyName("event_type")]
public required string EventType { get; init; }
/// <summary>
/// Event version for schema compatibility.
/// </summary>
[JsonPropertyName("event_version")]
public int EventVersion { get; init; } = 1;
/// <summary>
/// Source of the event (e.g., scanner, excititor, signals).
/// </summary>
[JsonPropertyName("source")]
public string? Source { get; init; }
/// <summary>
/// When the event was received (UTC).
/// </summary>
[JsonPropertyName("received_at")]
public DateTimeOffset ReceivedAt { get; init; }
/// <summary>
/// Event correlation ID for traceability.
/// </summary>
[JsonPropertyName("correlation_id")]
public string? CorrelationId { get; init; }
}
/// <summary>
/// Builder for creating deterministic reanalysis fingerprints.
/// </summary>
public sealed class ReanalysisFingerprintBuilder
{
private readonly TimeProvider _timeProvider;
private string? _dsseBundleDigest;
private readonly List<string> _evidenceDigests = [];
private readonly SortedDictionary<string, string> _toolVersions = new(StringComparer.Ordinal);
private string? _productVersion;
private string? _policyConfigHash;
private string? _signalWeightsHash;
private readonly List<ReanalysisTrigger> _triggers = [];
private readonly List<string> _nextActions = [];
public ReanalysisFingerprintBuilder(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public ReanalysisFingerprintBuilder WithDsseBundleDigest(string? digest)
{
_dsseBundleDigest = digest;
return this;
}
public ReanalysisFingerprintBuilder AddEvidenceDigest(string digest)
{
if (!string.IsNullOrWhiteSpace(digest))
{
_evidenceDigests.Add(digest);
}
return this;
}
public ReanalysisFingerprintBuilder AddEvidenceDigests(IEnumerable<string> digests)
{
foreach (var digest in digests)
{
AddEvidenceDigest(digest);
}
return this;
}
public ReanalysisFingerprintBuilder WithToolVersion(string tool, string version)
{
_toolVersions[tool] = version;
return this;
}
public ReanalysisFingerprintBuilder WithProductVersion(string? version)
{
_productVersion = version;
return this;
}
public ReanalysisFingerprintBuilder WithPolicyConfigHash(string? hash)
{
_policyConfigHash = hash;
return this;
}
public ReanalysisFingerprintBuilder WithSignalWeightsHash(string? hash)
{
_signalWeightsHash = hash;
return this;
}
public ReanalysisFingerprintBuilder AddTrigger(ReanalysisTrigger trigger)
{
_triggers.Add(trigger);
return this;
}
public ReanalysisFingerprintBuilder AddTrigger(string eventType, int eventVersion = 1, string? source = null, string? correlationId = null)
{
_triggers.Add(new ReanalysisTrigger
{
EventType = eventType,
EventVersion = eventVersion,
Source = source,
ReceivedAt = _timeProvider.GetUtcNow(),
CorrelationId = correlationId
});
return this;
}
public ReanalysisFingerprintBuilder AddNextAction(string action)
{
if (!string.IsNullOrWhiteSpace(action))
{
_nextActions.Add(action);
}
return this;
}
/// <summary>
/// Builds the fingerprint with a deterministic content-addressed ID.
/// </summary>
public ReanalysisFingerprint Build()
{
var now = _timeProvider.GetUtcNow();
// Sort evidence digests for determinism
var sortedDigests = _evidenceDigests
.Distinct(StringComparer.Ordinal)
.OrderBy(d => d, StringComparer.Ordinal)
.ToList();
// Sort triggers by event type then received_at for determinism
var sortedTriggers = _triggers
.OrderBy(t => t.EventType, StringComparer.Ordinal)
.ThenBy(t => t.ReceivedAt)
.ToList();
// Sort next actions for determinism
var sortedActions = _nextActions
.Distinct(StringComparer.Ordinal)
.OrderBy(a => a, StringComparer.Ordinal)
.ToList();
// Compute content-addressed fingerprint ID
var fingerprintId = ComputeFingerprintId(
_dsseBundleDigest,
sortedDigests,
_toolVersions,
_productVersion,
_policyConfigHash,
_signalWeightsHash);
return new ReanalysisFingerprint
{
FingerprintId = fingerprintId,
DsseBundleDigest = _dsseBundleDigest,
EvidenceDigests = sortedDigests,
ToolVersions = new Dictionary<string, string>(_toolVersions),
ProductVersion = _productVersion,
PolicyConfigHash = _policyConfigHash,
SignalWeightsHash = _signalWeightsHash,
ComputedAt = now,
Triggers = sortedTriggers,
NextActions = sortedActions
};
}
private static string ComputeFingerprintId(
string? dsseBundleDigest,
IReadOnlyList<string> evidenceDigests,
IReadOnlyDictionary<string, string> toolVersions,
string? productVersion,
string? policyConfigHash,
string? signalWeightsHash)
{
// Create canonical representation for hashing
var canonical = new
{
dsse = dsseBundleDigest,
evidence = evidenceDigests,
tools = toolVersions,
product = productVersion,
policy = policyConfigHash,
weights = signalWeightsHash
};
var json = JsonSerializer.SerializeToUtf8Bytes(canonical, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
});
var hash = SHA256.HashData(json);
return "sha256:" + Convert.ToHexStringLower(hash);
}
}

View File

@@ -0,0 +1,80 @@
// <copyright file="SignalConflictExtensions.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_20260112_004_POLICY_unknowns_determinization_greyqueue (POLICY-UNK-002)
// </copyright>
using StellaOps.Policy.Determinization.Evidence;
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Extension methods for signal conflict detection.
/// </summary>
public static class SignalConflictExtensions
{
/// <summary>
/// Returns true if VEX status is "not_affected".
/// </summary>
public static bool IsNotAffected(this SignalState<VexClaimSummary> vex)
{
return vex.HasValue && vex.Value!.IsNotAffected;
}
/// <summary>
/// Returns true if VEX status is "affected".
/// </summary>
public static bool IsAffected(this SignalState<VexClaimSummary> vex)
{
return vex.HasValue && string.Equals(vex.Value!.Status, "affected", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Returns true if reachability shows exploitable path.
/// </summary>
public static bool IsExploitable(this SignalState<ReachabilityEvidence> reachability)
{
return reachability.HasValue && reachability.Value!.IsReachable;
}
/// <summary>
/// Returns true if static analysis shows unreachable.
/// </summary>
public static bool IsStaticUnreachable(this SignalState<ReachabilityEvidence> reachability)
{
return reachability.HasValue && reachability.Value!.Status == ReachabilityStatus.Unreachable;
}
/// <summary>
/// Returns true if runtime telemetry detected execution.
/// </summary>
public static bool HasExecution(this SignalState<RuntimeEvidence> runtime)
{
return runtime.HasValue && runtime.Value!.Detected;
}
/// <summary>
/// Returns true if multiple VEX sources exist.
/// </summary>
public static bool HasMultipleSources(this SignalState<VexClaimSummary> vex)
{
return vex.HasValue && vex.Value!.StatementCount > 1;
}
/// <summary>
/// Returns true if VEX sources have conflicting status.
/// This is determined by low confidence when multiple sources exist.
/// </summary>
public static bool HasConflictingStatus(this SignalState<VexClaimSummary> vex)
{
// If there are multiple sources and confidence is below 0.7, they likely conflict
return vex.HasValue && vex.Value!.StatementCount > 1 && vex.Value!.Confidence < 0.7;
}
/// <summary>
/// Returns true if backport evidence indicates fix is applied.
/// </summary>
public static bool IsBackported(this SignalState<BackportEvidence> backport)
{
return backport.HasValue && backport.Value!.Detected;
}
}

View File

@@ -0,0 +1,306 @@
// <copyright file="ConflictDetector.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_20260112_004_POLICY_unknowns_determinization_greyqueue (POLICY-UNK-002)
// </copyright>
using StellaOps.Policy.Determinization.Evidence;
using StellaOps.Policy.Determinization.Models;
namespace StellaOps.Policy.Determinization.Scoring;
/// <summary>
/// Detects conflicting evidence signals that require manual adjudication.
/// </summary>
public interface IConflictDetector
{
/// <summary>
/// Detects conflicts in the signal snapshot.
/// </summary>
ConflictDetectionResult Detect(SignalSnapshot snapshot);
}
/// <summary>
/// Result of conflict detection.
/// </summary>
public sealed record ConflictDetectionResult
{
/// <summary>
/// Whether any conflicts were detected.
/// </summary>
public bool HasConflict { get; init; }
/// <summary>
/// List of detected conflicts.
/// </summary>
public IReadOnlyList<SignalConflict> Conflicts { get; init; } = [];
/// <summary>
/// Overall conflict severity (0.0 = none, 1.0 = critical).
/// </summary>
public double Severity { get; init; }
/// <summary>
/// Suggested adjudication path.
/// </summary>
public AdjudicationPath SuggestedPath { get; init; } = AdjudicationPath.None;
public static ConflictDetectionResult NoConflict() => new()
{
HasConflict = false,
Conflicts = [],
Severity = 0.0,
SuggestedPath = AdjudicationPath.None
};
public static ConflictDetectionResult WithConflicts(
IReadOnlyList<SignalConflict> conflicts,
double severity,
AdjudicationPath suggestedPath) => new()
{
HasConflict = conflicts.Count > 0,
Conflicts = conflicts,
Severity = Math.Clamp(severity, 0.0, 1.0),
SuggestedPath = suggestedPath
};
}
/// <summary>
/// A detected conflict between signals.
/// </summary>
public sealed record SignalConflict
{
/// <summary>
/// First signal in the conflict.
/// </summary>
public required string Signal1 { get; init; }
/// <summary>
/// Second signal in the conflict.
/// </summary>
public required string Signal2 { get; init; }
/// <summary>
/// Type of conflict.
/// </summary>
public required ConflictType Type { get; init; }
/// <summary>
/// Human-readable description.
/// </summary>
public required string Description { get; init; }
/// <summary>
/// Conflict severity (0.0 = minor, 1.0 = critical).
/// </summary>
public double Severity { get; init; }
}
/// <summary>
/// Type of signal conflict.
/// </summary>
public enum ConflictType
{
/// <summary>
/// VEX says not_affected but reachability shows exploitable path.
/// </summary>
VexReachabilityContradiction,
/// <summary>
/// Static analysis says unreachable but runtime telemetry shows execution.
/// </summary>
StaticRuntimeContradiction,
/// <summary>
/// Multiple VEX statements with conflicting status.
/// </summary>
VexStatusConflict,
/// <summary>
/// Backport evidence conflicts with vulnerability status.
/// </summary>
BackportStatusConflict,
/// <summary>
/// EPSS score conflicts with other risk indicators.
/// </summary>
EpssRiskContradiction,
/// <summary>
/// Other conflict type.
/// </summary>
Other
}
/// <summary>
/// Suggested adjudication path for conflicts.
/// </summary>
public enum AdjudicationPath
{
/// <summary>
/// No adjudication needed.
/// </summary>
None,
/// <summary>
/// Automatic resolution possible with additional evidence.
/// </summary>
AutoResolvable,
/// <summary>
/// Requires human review by security team.
/// </summary>
SecurityTeamReview,
/// <summary>
/// Requires vendor clarification.
/// </summary>
VendorClarification,
/// <summary>
/// Escalate to security steering committee.
/// </summary>
SteeringCommittee
}
/// <summary>
/// Default implementation of conflict detection.
/// </summary>
public sealed class ConflictDetector : IConflictDetector
{
private readonly ILogger<ConflictDetector> _logger;
public ConflictDetector(ILogger<ConflictDetector> logger)
{
_logger = logger;
}
public ConflictDetectionResult Detect(SignalSnapshot snapshot)
{
ArgumentNullException.ThrowIfNull(snapshot);
var conflicts = new List<SignalConflict>();
// Check VEX vs Reachability contradiction
CheckVexReachabilityConflict(snapshot, conflicts);
// Check Static vs Runtime contradiction
CheckStaticRuntimeConflict(snapshot, conflicts);
// Check multiple VEX statements
CheckVexStatusConflict(snapshot, conflicts);
// Check Backport vs Status conflict
CheckBackportStatusConflict(snapshot, conflicts);
if (conflicts.Count == 0)
{
return ConflictDetectionResult.NoConflict();
}
// Calculate overall severity (max of all conflicts)
var severity = conflicts.Max(c => c.Severity);
// Determine adjudication path based on conflict types and severity
var suggestedPath = DetermineAdjudicationPath(conflicts, severity);
_logger.LogWarning(
"Detected {ConflictCount} signal conflicts for CVE {Cve} / PURL {Purl} with severity {Severity:F2}",
conflicts.Count,
snapshot.Cve,
snapshot.Purl,
severity);
return ConflictDetectionResult.WithConflicts(
conflicts.OrderBy(c => c.Type).ThenByDescending(c => c.Severity).ToList(),
severity,
suggestedPath);
}
private static void CheckVexReachabilityConflict(SignalSnapshot snapshot, List<SignalConflict> conflicts)
{
// VEX says not_affected but reachability shows exploitable
if (snapshot.Vex.IsNotAffected && snapshot.Reachability.IsExploitable)
{
conflicts.Add(new SignalConflict
{
Signal1 = "VEX",
Signal2 = "Reachability",
Type = ConflictType.VexReachabilityContradiction,
Description = "VEX status is not_affected but reachability analysis shows exploitable path",
Severity = 0.9 // High severity - needs resolution
});
}
}
private static void CheckStaticRuntimeConflict(SignalSnapshot snapshot, List<SignalConflict> conflicts)
{
// Static says unreachable but runtime shows execution
if (snapshot.Reachability.IsStaticUnreachable && snapshot.Runtime.HasExecution)
{
conflicts.Add(new SignalConflict
{
Signal1 = "StaticReachability",
Signal2 = "RuntimeTelemetry",
Type = ConflictType.StaticRuntimeContradiction,
Description = "Static analysis shows unreachable but runtime telemetry detected execution",
Severity = 0.85 // High severity - static analysis may be incomplete
});
}
}
private static void CheckVexStatusConflict(SignalSnapshot snapshot, List<SignalConflict> conflicts)
{
// Multiple VEX sources with conflicting status
if (snapshot.Vex.HasMultipleSources && snapshot.Vex.HasConflictingStatus)
{
conflicts.Add(new SignalConflict
{
Signal1 = "VEX:Source1",
Signal2 = "VEX:Source2",
Type = ConflictType.VexStatusConflict,
Description = "Multiple VEX statements with conflicting status",
Severity = 0.7 // Medium-high - needs vendor clarification
});
}
}
private static void CheckBackportStatusConflict(SignalSnapshot snapshot, List<SignalConflict> conflicts)
{
// Backport says fixed but vulnerability still active
if (snapshot.Backport.IsBackported && snapshot.Vex.IsAffected)
{
conflicts.Add(new SignalConflict
{
Signal1 = "Backport",
Signal2 = "VEX",
Type = ConflictType.BackportStatusConflict,
Description = "Backport evidence indicates fix applied but VEX status shows affected",
Severity = 0.6 // Medium - may be version mismatch
});
}
}
private static AdjudicationPath DetermineAdjudicationPath(IReadOnlyList<SignalConflict> conflicts, double severity)
{
// Critical conflicts go to steering committee
if (severity >= 0.95)
{
return AdjudicationPath.SteeringCommittee;
}
// VEX conflicts need vendor clarification
if (conflicts.Any(c => c.Type == ConflictType.VexStatusConflict))
{
return AdjudicationPath.VendorClarification;
}
// High severity needs security team review
if (severity >= 0.7)
{
return AdjudicationPath.SecurityTeamReview;
}
// Lower severity may be auto-resolvable with more evidence
return AdjudicationPath.AutoResolvable;
}
}

View File

@@ -91,8 +91,56 @@ public sealed record Unknown
/// <summary>Last update timestamp.</summary>
public required DateTimeOffset UpdatedAt { get; init; }
// Sprint: SPRINT_20260112_004_POLICY_unknowns_determinization_greyqueue (POLICY-UNK-003)
/// <summary>Reanalysis fingerprint ID for deterministic replay.</summary>
public string? FingerprintId { get; init; }
/// <summary>Triggers that caused the last reanalysis.</summary>
public IReadOnlyList<UnknownTrigger> Triggers { get; init; } = [];
/// <summary>Suggested next actions based on current state.</summary>
public IReadOnlyList<string> NextActions { get; init; } = [];
/// <summary>Conflict detection result if conflicts exist.</summary>
public UnknownConflictInfo? ConflictInfo { get; init; }
/// <summary>Observation state from determinization.</summary>
public string? ObservationState { get; init; }
}
/// <summary>
/// Trigger that caused a reanalysis of an unknown.
/// Sprint: SPRINT_20260112_004_POLICY_unknowns_determinization_greyqueue (POLICY-UNK-003)
/// </summary>
public sealed record UnknownTrigger(
string EventType,
int EventVersion,
string? Source,
DateTimeOffset ReceivedAt,
string? CorrelationId);
/// <summary>
/// Conflict information for an unknown.
/// Sprint: SPRINT_20260112_004_POLICY_unknowns_determinization_greyqueue (POLICY-UNK-003)
/// </summary>
public sealed record UnknownConflictInfo(
bool HasConflict,
double Severity,
string SuggestedPath,
IReadOnlyList<UnknownConflictDetail> Conflicts);
/// <summary>
/// Detail of a specific conflict.
/// </summary>
public sealed record UnknownConflictDetail(
string Signal1,
string Signal2,
string Type,
string Description,
double Severity);
/// <summary>
/// Reference to evidence supporting unknown classification.
/// </summary>

View File

@@ -0,0 +1,349 @@
// -----------------------------------------------------------------------------
// CvssThresholdGate.cs
// Sprint: SPRINT_20260112_017_POLICY_cvss_threshold_gate
// Tasks: CVSS-GATE-001 to CVSS-GATE-007
// Description: Policy gate for CVSS score threshold enforcement.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Globalization;
using StellaOps.Policy.TrustLattice;
namespace StellaOps.Policy.Gates;
/// <summary>
/// Configuration options for CVSS threshold gate.
/// </summary>
public sealed class CvssThresholdGateOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Policy:Gates:CvssThreshold";
/// <summary>
/// Whether the gate is enabled.
/// </summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Gate priority (lower = earlier evaluation).
/// </summary>
public int Priority { get; init; } = 15;
/// <summary>
/// Default CVSS threshold (used when environment-specific not configured).
/// </summary>
public double DefaultThreshold { get; init; } = 7.0;
/// <summary>
/// Per-environment CVSS thresholds.
/// </summary>
public IReadOnlyDictionary<string, double> Thresholds { get; init; } = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
{
["production"] = 7.0,
["staging"] = 8.0,
["development"] = 9.0
};
/// <summary>
/// Preferred CVSS version for evaluation: "v3.1", "v4.0", or "highest".
/// </summary>
public string CvssVersionPreference { get; init; } = "highest";
/// <summary>
/// CVEs to always allow regardless of score.
/// </summary>
public IReadOnlySet<string> Allowlist { get; init; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// CVEs to always block regardless of score.
/// </summary>
public IReadOnlySet<string> Denylist { get; init; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Whether to fail findings without CVSS scores.
/// </summary>
public bool FailOnMissingCvss { get; init; } = false;
/// <summary>
/// Whether to require all CVSS versions to pass (AND) vs any (OR).
/// </summary>
public bool RequireAllVersionsPass { get; init; } = false;
}
/// <summary>
/// CVSS score information for a finding.
/// </summary>
public sealed record CvssScoreInfo
{
/// <summary>
/// CVSS v3.1 base score (0.0-10.0), null if not available.
/// </summary>
public double? CvssV31BaseScore { get; init; }
/// <summary>
/// CVSS v4.0 base score (0.0-10.0), null if not available.
/// </summary>
public double? CvssV40BaseScore { get; init; }
/// <summary>
/// CVSS v3.1 vector string.
/// </summary>
public string? CvssV31Vector { get; init; }
/// <summary>
/// CVSS v4.0 vector string.
/// </summary>
public string? CvssV40Vector { get; init; }
}
/// <summary>
/// Policy gate that enforces CVSS score thresholds.
/// Blocks findings with CVSS scores exceeding configured thresholds.
/// </summary>
public sealed class CvssThresholdGate : IPolicyGate
{
private readonly CvssThresholdGateOptions _options;
private readonly Func<string?, CvssScoreInfo?> _cvssLookup;
/// <summary>
/// Initializes the gate with options and optional CVSS lookup.
/// </summary>
/// <param name="options">Gate options.</param>
/// <param name="cvssLookup">Function to look up CVSS scores by CVE ID. If null, uses context metadata.</param>
public CvssThresholdGate(CvssThresholdGateOptions? options = null, Func<string?, CvssScoreInfo?>? cvssLookup = null)
{
_options = options ?? new CvssThresholdGateOptions();
_cvssLookup = cvssLookup ?? (_ => null);
}
/// <inheritdoc/>
public Task<GateResult> EvaluateAsync(MergeResult mergeResult, PolicyGateContext context, CancellationToken ct = default)
{
if (!_options.Enabled)
{
return Task.FromResult(Pass("disabled"));
}
var cveId = context.CveId;
// Check denylist first (always block)
if (!string.IsNullOrEmpty(cveId) && _options.Denylist.Contains(cveId))
{
return Task.FromResult(Fail(
"denylist",
new Dictionary<string, object>
{
["cve_id"] = cveId,
["reason"] = "CVE is on denylist"
}));
}
// Check allowlist (always pass)
if (!string.IsNullOrEmpty(cveId) && _options.Allowlist.Contains(cveId))
{
return Task.FromResult(Pass(
"allowlist",
new Dictionary<string, object>
{
["cve_id"] = cveId,
["reason"] = "CVE is on allowlist"
}));
}
// Get CVSS scores
var cvssInfo = GetCvssScores(cveId, context);
if (cvssInfo is null || (!cvssInfo.CvssV31BaseScore.HasValue && !cvssInfo.CvssV40BaseScore.HasValue))
{
if (_options.FailOnMissingCvss)
{
return Task.FromResult(Fail(
"missing_cvss",
new Dictionary<string, object>
{
["cve_id"] = cveId ?? "(unknown)",
["reason"] = "No CVSS score available"
}));
}
return Task.FromResult(Pass(
"no_cvss_available",
new Dictionary<string, object>
{
["cve_id"] = cveId ?? "(unknown)"
}));
}
// Get threshold for environment
var threshold = GetThreshold(context.Environment);
// Evaluate based on version preference
var (passed, selectedScore, selectedVersion) = EvaluateCvss(cvssInfo, threshold);
var details = new Dictionary<string, object>
{
["threshold"] = threshold,
["environment"] = context.Environment,
["cvss_version"] = selectedVersion,
["cvss_score"] = selectedScore,
["preference"] = _options.CvssVersionPreference
};
if (cvssInfo.CvssV31BaseScore.HasValue)
{
details["cvss_v31_score"] = cvssInfo.CvssV31BaseScore.Value;
}
if (cvssInfo.CvssV40BaseScore.HasValue)
{
details["cvss_v40_score"] = cvssInfo.CvssV40BaseScore.Value;
}
if (!string.IsNullOrEmpty(cveId))
{
details["cve_id"] = cveId;
}
if (!passed)
{
return Task.FromResult(Fail(
"cvss_exceeds_threshold",
details));
}
return Task.FromResult(Pass("cvss_within_threshold", details));
}
private CvssScoreInfo? GetCvssScores(string? cveId, PolicyGateContext context)
{
// Try lookup function first
var fromLookup = _cvssLookup(cveId);
if (fromLookup is not null)
{
return fromLookup;
}
// Try to extract from context metadata
if (context.Metadata is null)
{
return null;
}
double? v31Score = null;
double? v40Score = null;
string? v31Vector = null;
string? v40Vector = null;
if (context.Metadata.TryGetValue("cvss_v31_score", out var v31Str) &&
double.TryParse(v31Str, NumberStyles.Float, CultureInfo.InvariantCulture, out var v31))
{
v31Score = v31;
}
if (context.Metadata.TryGetValue("cvss_v40_score", out var v40Str) &&
double.TryParse(v40Str, NumberStyles.Float, CultureInfo.InvariantCulture, out var v40))
{
v40Score = v40;
}
if (context.Metadata.TryGetValue("cvss_v31_vector", out var v31Vec))
{
v31Vector = v31Vec;
}
if (context.Metadata.TryGetValue("cvss_v40_vector", out var v40Vec))
{
v40Vector = v40Vec;
}
if (!v31Score.HasValue && !v40Score.HasValue)
{
return null;
}
return new CvssScoreInfo
{
CvssV31BaseScore = v31Score,
CvssV40BaseScore = v40Score,
CvssV31Vector = v31Vector,
CvssV40Vector = v40Vector
};
}
private double GetThreshold(string environment)
{
if (_options.Thresholds.TryGetValue(environment, out var threshold))
{
return threshold;
}
return _options.DefaultThreshold;
}
private (bool Passed, double Score, string Version) EvaluateCvss(CvssScoreInfo cvssInfo, double threshold)
{
var v31Score = cvssInfo.CvssV31BaseScore;
var v40Score = cvssInfo.CvssV40BaseScore;
return _options.CvssVersionPreference.ToLowerInvariant() switch
{
"v3.1" when v31Score.HasValue => (v31Score.Value < threshold, v31Score.Value, "v3.1"),
"v4.0" when v40Score.HasValue => (v40Score.Value < threshold, v40Score.Value, "v4.0"),
"highest" => EvaluateHighest(v31Score, v40Score, threshold),
_ => EvaluateHighest(v31Score, v40Score, threshold)
};
}
private (bool Passed, double Score, string Version) EvaluateHighest(double? v31Score, double? v40Score, double threshold)
{
// Use whichever score is available, preferring the higher one for conservative evaluation
if (v31Score.HasValue && v40Score.HasValue)
{
if (_options.RequireAllVersionsPass)
{
// Both must pass
var passed = v31Score.Value < threshold && v40Score.Value < threshold;
var higherScore = Math.Max(v31Score.Value, v40Score.Value);
var version = v31Score.Value >= v40Score.Value ? "v3.1" : "v4.0";
return (passed, higherScore, $"both ({version} highest)");
}
else
{
// Use the higher score (more conservative)
if (v31Score.Value >= v40Score.Value)
{
return (v31Score.Value < threshold, v31Score.Value, "v3.1");
}
return (v40Score.Value < threshold, v40Score.Value, "v4.0");
}
}
if (v31Score.HasValue)
{
return (v31Score.Value < threshold, v31Score.Value, "v3.1");
}
if (v40Score.HasValue)
{
return (v40Score.Value < threshold, v40Score.Value, "v4.0");
}
// No score available - should not reach here if caller checks first
return (true, 0.0, "none");
}
private static GateResult Pass(string reason, IDictionary<string, object>? details = null) => new()
{
GateName = nameof(CvssThresholdGate),
Passed = true,
Reason = reason,
Details = details?.ToImmutableDictionary() ?? ImmutableDictionary<string, object>.Empty
};
private static GateResult Fail(string reason, IDictionary<string, object>? details = null) => new()
{
GateName = nameof(CvssThresholdGate),
Passed = false,
Reason = reason,
Details = details?.ToImmutableDictionary() ?? ImmutableDictionary<string, object>.Empty
};
}

View File

@@ -0,0 +1,80 @@
// -----------------------------------------------------------------------------
// CvssThresholdGateExtensions.cs
// Sprint: SPRINT_20260112_017_POLICY_cvss_threshold_gate
// Tasks: CVSS-GATE-007
// Description: Extension methods for CVSS threshold gate registration.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Policy.Gates;
/// <summary>
/// Extension methods for CVSS threshold gate registration.
/// </summary>
public static class CvssThresholdGateExtensions
{
/// <summary>
/// Adds CVSS threshold gate services to the service collection.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration to bind options from.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddCvssThresholdGate(
this IServiceCollection services,
IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
services.Configure<CvssThresholdGateOptions>(
configuration.GetSection(CvssThresholdGateOptions.SectionName));
services.TryAddSingleton<CvssThresholdGate>(sp =>
{
var options = sp.GetService<Microsoft.Extensions.Options.IOptions<CvssThresholdGateOptions>>()?.Value;
return new CvssThresholdGate(options);
});
return services;
}
/// <summary>
/// Adds CVSS threshold gate services with explicit options.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configureOptions">Options configuration action.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddCvssThresholdGate(
this IServiceCollection services,
Action<CvssThresholdGateOptions> configureOptions)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configureOptions);
services.Configure(configureOptions);
services.TryAddSingleton<CvssThresholdGate>(sp =>
{
var options = sp.GetService<Microsoft.Extensions.Options.IOptions<CvssThresholdGateOptions>>()?.Value;
return new CvssThresholdGate(options);
});
return services;
}
/// <summary>
/// Registers the CVSS threshold gate with a policy gate registry.
/// </summary>
/// <param name="registry">Policy gate registry.</param>
/// <returns>Registry for chaining.</returns>
public static IPolicyGateRegistry RegisterCvssThresholdGate(this IPolicyGateRegistry registry)
{
ArgumentNullException.ThrowIfNull(registry);
registry.Register<CvssThresholdGate>(nameof(CvssThresholdGate));
return registry;
}
}

View File

@@ -0,0 +1,470 @@
// -----------------------------------------------------------------------------
// SbomPresenceGate.cs
// Sprint: SPRINT_20260112_017_POLICY_sbom_presence_gate
// Tasks: SBOM-GATE-001 to SBOM-GATE-008
// Description: Policy gate for SBOM presence and format validation.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Globalization;
using System.Text.Json;
using StellaOps.Policy.TrustLattice;
namespace StellaOps.Policy.Gates;
/// <summary>
/// Configuration options for SBOM presence gate.
/// </summary>
public sealed class SbomPresenceGateOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Policy:Gates:SbomPresence";
/// <summary>
/// Whether the gate is enabled.
/// </summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Gate priority (lower = earlier evaluation).
/// </summary>
public int Priority { get; init; } = 5;
/// <summary>
/// Per-environment enforcement levels.
/// </summary>
public IReadOnlyDictionary<string, SbomEnforcementLevel> Enforcement { get; init; } =
new Dictionary<string, SbomEnforcementLevel>(StringComparer.OrdinalIgnoreCase)
{
["production"] = SbomEnforcementLevel.Required,
["staging"] = SbomEnforcementLevel.Required,
["development"] = SbomEnforcementLevel.Optional
};
/// <summary>
/// Default enforcement level for unknown environments.
/// </summary>
public SbomEnforcementLevel DefaultEnforcement { get; init; } = SbomEnforcementLevel.Required;
/// <summary>
/// Accepted SBOM formats.
/// </summary>
public IReadOnlySet<string> AcceptedFormats { get; init; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"spdx-2.2",
"spdx-2.3",
"spdx-3.0.1",
"cyclonedx-1.4",
"cyclonedx-1.5",
"cyclonedx-1.6",
"cyclonedx-1.7"
};
/// <summary>
/// Minimum number of components required in SBOM.
/// </summary>
public int MinimumComponents { get; init; } = 1;
/// <summary>
/// Whether to require SBOM signature.
/// </summary>
public bool RequireSignature { get; init; } = false;
/// <summary>
/// Whether to validate SBOM against schema.
/// </summary>
public bool SchemaValidation { get; init; } = true;
/// <summary>
/// Whether to require primary component/describes field.
/// </summary>
public bool RequirePrimaryComponent { get; init; } = true;
}
/// <summary>
/// SBOM enforcement levels.
/// </summary>
public enum SbomEnforcementLevel
{
/// <summary>
/// SBOM is not required (gate passes regardless).
/// </summary>
Optional,
/// <summary>
/// SBOM is recommended but not required (warning on missing).
/// </summary>
Recommended,
/// <summary>
/// SBOM is required (gate fails if missing).
/// </summary>
Required
}
/// <summary>
/// Information about an SBOM for gate evaluation.
/// </summary>
public sealed record SbomInfo
{
/// <summary>
/// Whether an SBOM is present.
/// </summary>
public bool Present { get; init; }
/// <summary>
/// SBOM format (e.g., "spdx-2.3", "cyclonedx-1.6").
/// </summary>
public string? Format { get; init; }
/// <summary>
/// SBOM format version.
/// </summary>
public string? FormatVersion { get; init; }
/// <summary>
/// Number of components in the SBOM.
/// </summary>
public int ComponentCount { get; init; }
/// <summary>
/// Whether the SBOM has a signature.
/// </summary>
public bool HasSignature { get; init; }
/// <summary>
/// Whether the signature is valid.
/// </summary>
public bool? SignatureValid { get; init; }
/// <summary>
/// Whether the SBOM passed schema validation.
/// </summary>
public bool? SchemaValid { get; init; }
/// <summary>
/// Schema validation errors if any.
/// </summary>
public IReadOnlyList<string>? SchemaErrors { get; init; }
/// <summary>
/// Whether a primary component/describes field is present.
/// </summary>
public bool HasPrimaryComponent { get; init; }
/// <summary>
/// SBOM document URI or path.
/// </summary>
public string? DocumentUri { get; init; }
/// <summary>
/// SBOM creation timestamp.
/// </summary>
public DateTimeOffset? CreatedAt { get; init; }
}
/// <summary>
/// Policy gate that validates SBOM presence and format.
/// </summary>
public sealed class SbomPresenceGate : IPolicyGate
{
private readonly SbomPresenceGateOptions _options;
private readonly Func<PolicyGateContext, SbomInfo?> _sbomLookup;
/// <summary>
/// Initializes the gate with options and optional SBOM lookup.
/// </summary>
/// <param name="options">Gate options.</param>
/// <param name="sbomLookup">Function to look up SBOM info from context.</param>
public SbomPresenceGate(SbomPresenceGateOptions? options = null, Func<PolicyGateContext, SbomInfo?>? sbomLookup = null)
{
_options = options ?? new SbomPresenceGateOptions();
_sbomLookup = sbomLookup ?? GetSbomFromMetadata;
}
/// <inheritdoc/>
public Task<GateResult> EvaluateAsync(MergeResult mergeResult, PolicyGateContext context, CancellationToken ct = default)
{
if (!_options.Enabled)
{
return Task.FromResult(Pass("disabled"));
}
var enforcement = GetEnforcementLevel(context.Environment);
// If optional, always pass
if (enforcement == SbomEnforcementLevel.Optional)
{
return Task.FromResult(Pass("optional_enforcement", new Dictionary<string, object>
{
["environment"] = context.Environment,
["enforcement"] = enforcement.ToString()
}));
}
// Get SBOM info
var sbomInfo = _sbomLookup(context);
// Check presence
if (sbomInfo is null || !sbomInfo.Present)
{
if (enforcement == SbomEnforcementLevel.Recommended)
{
return Task.FromResult(Pass("sbom_missing_recommended", new Dictionary<string, object>
{
["environment"] = context.Environment,
["enforcement"] = enforcement.ToString(),
["warning"] = "SBOM recommended but not present"
}));
}
return Task.FromResult(Fail("sbom_missing", new Dictionary<string, object>
{
["environment"] = context.Environment,
["enforcement"] = enforcement.ToString(),
["reason"] = "SBOM is required but not present"
}));
}
var details = new Dictionary<string, object>
{
["environment"] = context.Environment,
["enforcement"] = enforcement.ToString(),
["sbom_present"] = true
};
// Validate format
if (!string.IsNullOrEmpty(sbomInfo.Format))
{
details["format"] = sbomInfo.Format;
var normalizedFormat = NormalizeFormat(sbomInfo.Format, sbomInfo.FormatVersion);
if (!_options.AcceptedFormats.Contains(normalizedFormat))
{
details["normalized_format"] = normalizedFormat;
details["accepted_formats"] = string.Join(", ", _options.AcceptedFormats);
return Task.FromResult(Fail("invalid_format", details));
}
}
// Validate component count
details["component_count"] = sbomInfo.ComponentCount;
if (sbomInfo.ComponentCount < _options.MinimumComponents)
{
details["minimum_components"] = _options.MinimumComponents;
return Task.FromResult(Fail("insufficient_components", details));
}
// Validate schema
if (_options.SchemaValidation && sbomInfo.SchemaValid.HasValue)
{
details["schema_valid"] = sbomInfo.SchemaValid.Value;
if (!sbomInfo.SchemaValid.Value)
{
if (sbomInfo.SchemaErrors is { Count: > 0 })
{
details["schema_errors"] = string.Join("; ", sbomInfo.SchemaErrors.Take(5));
}
return Task.FromResult(Fail("schema_validation_failed", details));
}
}
// Validate signature requirement
if (_options.RequireSignature)
{
details["has_signature"] = sbomInfo.HasSignature;
if (!sbomInfo.HasSignature)
{
return Task.FromResult(Fail("signature_missing", details));
}
if (sbomInfo.SignatureValid.HasValue)
{
details["signature_valid"] = sbomInfo.SignatureValid.Value;
if (!sbomInfo.SignatureValid.Value)
{
return Task.FromResult(Fail("signature_invalid", details));
}
}
}
// Validate primary component
if (_options.RequirePrimaryComponent)
{
details["has_primary_component"] = sbomInfo.HasPrimaryComponent;
if (!sbomInfo.HasPrimaryComponent)
{
return Task.FromResult(Fail("primary_component_missing", details));
}
}
// Add optional metadata
if (!string.IsNullOrEmpty(sbomInfo.DocumentUri))
{
details["document_uri"] = sbomInfo.DocumentUri;
}
if (sbomInfo.CreatedAt.HasValue)
{
details["created_at"] = sbomInfo.CreatedAt.Value.ToString("o", CultureInfo.InvariantCulture);
}
return Task.FromResult(Pass("sbom_valid", details));
}
private SbomEnforcementLevel GetEnforcementLevel(string environment)
{
if (_options.Enforcement.TryGetValue(environment, out var level))
{
return level;
}
return _options.DefaultEnforcement;
}
private static string NormalizeFormat(string format, string? version)
{
// Normalize format string to match accepted formats
var normalizedFormat = format.ToLowerInvariant().Trim();
// Handle various format representations
if (normalizedFormat.StartsWith("spdx", StringComparison.Ordinal))
{
// Extract version from format or use provided version
var spdxVersion = ExtractVersion(normalizedFormat, "spdx") ?? version;
if (!string.IsNullOrEmpty(spdxVersion))
{
return $"spdx-{spdxVersion}";
}
return normalizedFormat;
}
if (normalizedFormat.StartsWith("cyclonedx", StringComparison.Ordinal) ||
normalizedFormat.StartsWith("cdx", StringComparison.Ordinal))
{
var cdxVersion = ExtractVersion(normalizedFormat, "cyclonedx") ??
ExtractVersion(normalizedFormat, "cdx") ??
version;
if (!string.IsNullOrEmpty(cdxVersion))
{
return $"cyclonedx-{cdxVersion}";
}
return normalizedFormat.Replace("cdx", "cyclonedx");
}
return normalizedFormat;
}
private static string? ExtractVersion(string format, string prefix)
{
// Try to extract version from format like "spdx-2.3" or "spdx2.3" or "spdx 2.3"
var withoutPrefix = format
.Replace(prefix, string.Empty, StringComparison.OrdinalIgnoreCase)
.TrimStart('-', ' ', '_');
if (string.IsNullOrEmpty(withoutPrefix))
{
return null;
}
// Check if remaining string looks like a version
if (char.IsDigit(withoutPrefix[0]))
{
// Take until non-version character
var versionEnd = 0;
while (versionEnd < withoutPrefix.Length &&
(char.IsDigit(withoutPrefix[versionEnd]) || withoutPrefix[versionEnd] == '.'))
{
versionEnd++;
}
return withoutPrefix[..versionEnd];
}
return null;
}
private static SbomInfo? GetSbomFromMetadata(PolicyGateContext context)
{
if (context.Metadata is null)
{
return null;
}
var present = context.Metadata.TryGetValue("sbom_present", out var presentStr) &&
bool.TryParse(presentStr, out var p) && p;
if (!present)
{
return new SbomInfo { Present = false };
}
context.Metadata.TryGetValue("sbom_format", out var format);
context.Metadata.TryGetValue("sbom_format_version", out var formatVersion);
var componentCount = 0;
if (context.Metadata.TryGetValue("sbom_component_count", out var countStr) &&
int.TryParse(countStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var count))
{
componentCount = count;
}
var hasSignature = context.Metadata.TryGetValue("sbom_has_signature", out var sigStr) &&
bool.TryParse(sigStr, out var sig) && sig;
bool? signatureValid = null;
if (context.Metadata.TryGetValue("sbom_signature_valid", out var sigValidStr) &&
bool.TryParse(sigValidStr, out var sv))
{
signatureValid = sv;
}
bool? schemaValid = null;
if (context.Metadata.TryGetValue("sbom_schema_valid", out var schemaValidStr) &&
bool.TryParse(schemaValidStr, out var schv))
{
schemaValid = schv;
}
var hasPrimaryComponent = context.Metadata.TryGetValue("sbom_has_primary_component", out var pcStr) &&
bool.TryParse(pcStr, out var pc) && pc;
context.Metadata.TryGetValue("sbom_document_uri", out var documentUri);
DateTimeOffset? createdAt = null;
if (context.Metadata.TryGetValue("sbom_created_at", out var createdStr) &&
DateTimeOffset.TryParse(createdStr, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var created))
{
createdAt = created;
}
return new SbomInfo
{
Present = true,
Format = format,
FormatVersion = formatVersion,
ComponentCount = componentCount,
HasSignature = hasSignature,
SignatureValid = signatureValid,
SchemaValid = schemaValid,
HasPrimaryComponent = hasPrimaryComponent,
DocumentUri = documentUri,
CreatedAt = createdAt
};
}
private static GateResult Pass(string reason, IDictionary<string, object>? details = null) => new()
{
GateName = nameof(SbomPresenceGate),
Passed = true,
Reason = reason,
Details = details?.ToImmutableDictionary() ?? ImmutableDictionary<string, object>.Empty
};
private static GateResult Fail(string reason, IDictionary<string, object>? details = null) => new()
{
GateName = nameof(SbomPresenceGate),
Passed = false,
Reason = reason,
Details = details?.ToImmutableDictionary() ?? ImmutableDictionary<string, object>.Empty
};
}

View File

@@ -0,0 +1,80 @@
// -----------------------------------------------------------------------------
// SbomPresenceGateExtensions.cs
// Sprint: SPRINT_20260112_017_POLICY_sbom_presence_gate
// Tasks: SBOM-GATE-008
// Description: Extension methods for SBOM presence gate registration.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Policy.Gates;
/// <summary>
/// Extension methods for SBOM presence gate registration.
/// </summary>
public static class SbomPresenceGateExtensions
{
/// <summary>
/// Adds SBOM presence gate services to the service collection.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration to bind options from.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddSbomPresenceGate(
this IServiceCollection services,
IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
services.Configure<SbomPresenceGateOptions>(
configuration.GetSection(SbomPresenceGateOptions.SectionName));
services.TryAddSingleton<SbomPresenceGate>(sp =>
{
var options = sp.GetService<Microsoft.Extensions.Options.IOptions<SbomPresenceGateOptions>>()?.Value;
return new SbomPresenceGate(options);
});
return services;
}
/// <summary>
/// Adds SBOM presence gate services with explicit options.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configureOptions">Options configuration action.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddSbomPresenceGate(
this IServiceCollection services,
Action<SbomPresenceGateOptions> configureOptions)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configureOptions);
services.Configure(configureOptions);
services.TryAddSingleton<SbomPresenceGate>(sp =>
{
var options = sp.GetService<Microsoft.Extensions.Options.IOptions<SbomPresenceGateOptions>>()?.Value;
return new SbomPresenceGate(options);
});
return services;
}
/// <summary>
/// Registers the SBOM presence gate with a policy gate registry.
/// </summary>
/// <param name="registry">Policy gate registry.</param>
/// <returns>Registry for chaining.</returns>
public static IPolicyGateRegistry RegisterSbomPresenceGate(this IPolicyGateRegistry registry)
{
ArgumentNullException.ThrowIfNull(registry);
registry.Register<SbomPresenceGate>(nameof(SbomPresenceGate));
return registry;
}
}

View File

@@ -0,0 +1,501 @@
// -----------------------------------------------------------------------------
// SignatureRequiredGate.cs
// Sprint: SPRINT_20260112_017_POLICY_signature_required_gate
// Tasks: SIG-GATE-001 to SIG-GATE-008
// Description: Policy gate for signature verification on evidence artifacts.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Globalization;
using System.Text.RegularExpressions;
using StellaOps.Policy.TrustLattice;
namespace StellaOps.Policy.Gates;
/// <summary>
/// Configuration options for signature required gate.
/// </summary>
public sealed class SignatureRequiredGateOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Policy:Gates:SignatureRequired";
/// <summary>
/// Whether the gate is enabled.
/// </summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Gate priority (lower = earlier evaluation).
/// </summary>
public int Priority { get; init; } = 3;
/// <summary>
/// Per-evidence-type signature requirements.
/// </summary>
public IReadOnlyDictionary<string, EvidenceSignatureConfig> EvidenceTypes { get; init; } =
new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
{
["sbom"] = new EvidenceSignatureConfig { Required = true },
["vex"] = new EvidenceSignatureConfig { Required = true },
["attestation"] = new EvidenceSignatureConfig { Required = true }
};
/// <summary>
/// Per-environment override for signature requirements.
/// </summary>
public IReadOnlyDictionary<string, EnvironmentSignatureConfig> Environments { get; init; } =
new Dictionary<string, EnvironmentSignatureConfig>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Default behavior for unknown evidence types.
/// </summary>
public bool RequireUnknownTypes { get; init; } = false;
/// <summary>
/// Whether to support keyless (Fulcio) verification.
/// </summary>
public bool EnableKeylessVerification { get; init; } = true;
/// <summary>
/// Fulcio root certificate paths (bundled).
/// </summary>
public IReadOnlyList<string> FulcioRoots { get; init; } = Array.Empty<string>();
/// <summary>
/// Rekor transparency log URL for keyless verification.
/// </summary>
public string? RekorUrl { get; init; }
/// <summary>
/// Whether to require transparency log inclusion for keyless signatures.
/// </summary>
public bool RequireTransparencyLogInclusion { get; init; } = true;
}
/// <summary>
/// Configuration for a specific evidence type.
/// </summary>
public sealed class EvidenceSignatureConfig
{
/// <summary>
/// Whether signature is required for this evidence type.
/// </summary>
public bool Required { get; init; } = true;
/// <summary>
/// Trusted issuers (email identities). Supports wildcards (*@domain.com).
/// </summary>
public IReadOnlySet<string> TrustedIssuers { get; init; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Trusted key IDs (for non-keyless verification).
/// </summary>
public IReadOnlySet<string> TrustedKeyIds { get; init; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Accepted signature algorithms.
/// </summary>
public IReadOnlySet<string> AcceptedAlgorithms { get; init; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"ES256", "ES384", "ES512", // ECDSA
"RS256", "RS384", "RS512", // RSA
"EdDSA", "Ed25519" // Edwards curves
};
/// <summary>
/// Whether to allow self-signed certificates.
/// </summary>
public bool AllowSelfSigned { get; init; } = false;
}
/// <summary>
/// Per-environment signature configuration override.
/// </summary>
public sealed class EnvironmentSignatureConfig
{
/// <summary>
/// Override required flag for this environment.
/// </summary>
public bool? RequiredOverride { get; init; }
/// <summary>
/// Additional trusted issuers for this environment.
/// </summary>
public IReadOnlySet<string>? AdditionalIssuers { get; init; }
/// <summary>
/// Evidence types to skip in this environment.
/// </summary>
public IReadOnlySet<string>? SkipEvidenceTypes { get; init; }
}
/// <summary>
/// Information about a signature for gate evaluation.
/// </summary>
public sealed record SignatureInfo
{
/// <summary>
/// Evidence type (sbom, vex, attestation, etc.).
/// </summary>
public required string EvidenceType { get; init; }
/// <summary>
/// Whether the evidence has a signature.
/// </summary>
public bool HasSignature { get; init; }
/// <summary>
/// Whether the signature is valid.
/// </summary>
public bool? SignatureValid { get; init; }
/// <summary>
/// Signature algorithm used.
/// </summary>
public string? Algorithm { get; init; }
/// <summary>
/// Signer identity (email for keyless).
/// </summary>
public string? SignerIdentity { get; init; }
/// <summary>
/// Key ID for non-keyless signatures.
/// </summary>
public string? KeyId { get; init; }
/// <summary>
/// Whether the signature is keyless (Fulcio).
/// </summary>
public bool IsKeyless { get; init; }
/// <summary>
/// Whether the signature has transparency log inclusion.
/// </summary>
public bool? HasTransparencyLogInclusion { get; init; }
/// <summary>
/// Transparency log entry ID.
/// </summary>
public string? TransparencyLogEntryId { get; init; }
/// <summary>
/// DSSE payload type.
/// </summary>
public string? DssePayloadType { get; init; }
/// <summary>
/// Certificate chain validity.
/// </summary>
public bool? CertificateChainValid { get; init; }
/// <summary>
/// Certificate expiration (for keyless).
/// </summary>
public DateTimeOffset? CertificateExpiry { get; init; }
/// <summary>
/// Verification errors if any.
/// </summary>
public IReadOnlyList<string>? VerificationErrors { get; init; }
}
/// <summary>
/// Policy gate that enforces signature requirements on evidence artifacts.
/// </summary>
public sealed class SignatureRequiredGate : IPolicyGate
{
private readonly SignatureRequiredGateOptions _options;
private readonly Func<PolicyGateContext, IReadOnlyList<SignatureInfo>> _signatureLookup;
/// <summary>
/// Initializes the gate with options and optional signature lookup.
/// </summary>
/// <param name="options">Gate options.</param>
/// <param name="signatureLookup">Function to look up signature info from context.</param>
public SignatureRequiredGate(
SignatureRequiredGateOptions? options = null,
Func<PolicyGateContext, IReadOnlyList<SignatureInfo>>? signatureLookup = null)
{
_options = options ?? new SignatureRequiredGateOptions();
_signatureLookup = signatureLookup ?? GetSignaturesFromMetadata;
}
/// <inheritdoc/>
public Task<GateResult> EvaluateAsync(MergeResult mergeResult, PolicyGateContext context, CancellationToken ct = default)
{
if (!_options.Enabled)
{
return Task.FromResult(Pass("disabled"));
}
var signatures = _signatureLookup(context);
var envConfig = GetEnvironmentConfig(context.Environment);
var failures = new List<string>();
var details = new Dictionary<string, object>
{
["environment"] = context.Environment,
["signatures_evaluated"] = signatures.Count
};
// Check each configured evidence type
foreach (var (evidenceType, config) in _options.EvidenceTypes)
{
// Check if skipped for this environment
if (envConfig?.SkipEvidenceTypes?.Contains(evidenceType) == true)
{
continue;
}
var isRequired = envConfig?.RequiredOverride ?? config.Required;
if (!isRequired)
{
continue;
}
var matchingSignatures = signatures.Where(s =>
string.Equals(s.EvidenceType, evidenceType, StringComparison.OrdinalIgnoreCase)).ToList();
if (matchingSignatures.Count == 0)
{
failures.Add($"{evidenceType}: signature missing");
continue;
}
foreach (var sig in matchingSignatures)
{
var validationResult = ValidateSignature(sig, config, envConfig);
if (!validationResult.Valid)
{
failures.Add($"{evidenceType}: {validationResult.Error}");
}
}
}
// Check for any signatures on unknown types if configured
if (_options.RequireUnknownTypes)
{
var knownTypes = _options.EvidenceTypes.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase);
var unknownSigs = signatures.Where(s => !knownTypes.Contains(s.EvidenceType));
foreach (var sig in unknownSigs)
{
if (!sig.HasSignature || sig.SignatureValid != true)
{
failures.Add($"{sig.EvidenceType}: unknown type requires valid signature");
}
}
}
if (failures.Count > 0)
{
details["failures"] = failures.ToArray();
return Task.FromResult(Fail("signature_validation_failed", details));
}
details["all_signatures_valid"] = true;
return Task.FromResult(Pass("signatures_verified", details));
}
private (bool Valid, string? Error) ValidateSignature(
SignatureInfo sig,
EvidenceSignatureConfig config,
EnvironmentSignatureConfig? envConfig)
{
// Check if signature is present
if (!sig.HasSignature)
{
return (false, "signature not present");
}
// Check if signature is valid
if (sig.SignatureValid != true)
{
var errors = sig.VerificationErrors is { Count: > 0 }
? string.Join("; ", sig.VerificationErrors.Take(3))
: "signature verification failed";
return (false, errors);
}
// Check algorithm
if (!string.IsNullOrEmpty(sig.Algorithm) && !config.AcceptedAlgorithms.Contains(sig.Algorithm))
{
return (false, $"algorithm '{sig.Algorithm}' not accepted");
}
// Validate issuer/identity
if (!string.IsNullOrEmpty(sig.SignerIdentity))
{
var trustedIssuers = new HashSet<string>(config.TrustedIssuers, StringComparer.OrdinalIgnoreCase);
if (envConfig?.AdditionalIssuers is not null)
{
trustedIssuers.UnionWith(envConfig.AdditionalIssuers);
}
if (trustedIssuers.Count > 0 && !IsIssuerTrusted(sig.SignerIdentity, trustedIssuers))
{
return (false, $"issuer '{sig.SignerIdentity}' not trusted");
}
}
// Validate key ID for non-keyless
if (!sig.IsKeyless && !string.IsNullOrEmpty(sig.KeyId))
{
if (config.TrustedKeyIds.Count > 0 && !config.TrustedKeyIds.Contains(sig.KeyId))
{
return (false, $"key '{sig.KeyId}' not trusted");
}
}
// Keyless-specific validation
if (sig.IsKeyless)
{
if (!_options.EnableKeylessVerification)
{
return (false, "keyless verification disabled");
}
if (_options.RequireTransparencyLogInclusion && sig.HasTransparencyLogInclusion != true)
{
return (false, "transparency log inclusion required");
}
if (sig.CertificateChainValid == false)
{
return (false, "certificate chain invalid");
}
}
return (true, null);
}
private static bool IsIssuerTrusted(string issuer, ISet<string> trustedIssuers)
{
// Direct match
if (trustedIssuers.Contains(issuer))
{
return true;
}
// Wildcard match (*@domain.com)
foreach (var trusted in trustedIssuers)
{
if (trusted.StartsWith("*@", StringComparison.Ordinal))
{
var domain = trusted[2..];
if (issuer.EndsWith($"@{domain}", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
else if (trusted.Contains('*'))
{
// General wildcard pattern
var pattern = "^" + Regex.Escape(trusted).Replace("\\*", ".*") + "$";
if (Regex.IsMatch(issuer, pattern, RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(100)))
{
return true;
}
}
}
return false;
}
private EnvironmentSignatureConfig? GetEnvironmentConfig(string environment)
{
if (_options.Environments.TryGetValue(environment, out var config))
{
return config;
}
return null;
}
private static IReadOnlyList<SignatureInfo> GetSignaturesFromMetadata(PolicyGateContext context)
{
if (context.Metadata is null)
{
return Array.Empty<SignatureInfo>();
}
var signatures = new List<SignatureInfo>();
// Parse signature info from metadata
// Expected keys: sig_<type>_present, sig_<type>_valid, sig_<type>_identity, etc.
var types = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var key in context.Metadata.Keys)
{
if (key.StartsWith("sig_", StringComparison.OrdinalIgnoreCase) && key.Contains('_'))
{
var parts = key.Split('_');
if (parts.Length >= 3)
{
types.Add(parts[1]);
}
}
}
foreach (var type in types)
{
var prefix = $"sig_{type}_";
var hasSignature = context.Metadata.TryGetValue($"{prefix}present", out var presentStr) &&
bool.TryParse(presentStr, out var present) && present;
bool? signatureValid = null;
if (context.Metadata.TryGetValue($"{prefix}valid", out var validStr) &&
bool.TryParse(validStr, out var valid))
{
signatureValid = valid;
}
context.Metadata.TryGetValue($"{prefix}algorithm", out var algorithm);
context.Metadata.TryGetValue($"{prefix}identity", out var identity);
context.Metadata.TryGetValue($"{prefix}keyid", out var keyId);
var isKeyless = context.Metadata.TryGetValue($"{prefix}keyless", out var keylessStr) &&
bool.TryParse(keylessStr, out var keyless) && keyless;
bool? hasLogInclusion = null;
if (context.Metadata.TryGetValue($"{prefix}log_inclusion", out var logStr) &&
bool.TryParse(logStr, out var log))
{
hasLogInclusion = log;
}
signatures.Add(new SignatureInfo
{
EvidenceType = type,
HasSignature = hasSignature,
SignatureValid = signatureValid,
Algorithm = algorithm,
SignerIdentity = identity,
KeyId = keyId,
IsKeyless = isKeyless,
HasTransparencyLogInclusion = hasLogInclusion
});
}
return signatures;
}
private static GateResult Pass(string reason, IDictionary<string, object>? details = null) => new()
{
GateName = nameof(SignatureRequiredGate),
Passed = true,
Reason = reason,
Details = details?.ToImmutableDictionary() ?? ImmutableDictionary<string, object>.Empty
};
private static GateResult Fail(string reason, IDictionary<string, object>? details = null) => new()
{
GateName = nameof(SignatureRequiredGate),
Passed = false,
Reason = reason,
Details = details?.ToImmutableDictionary() ?? ImmutableDictionary<string, object>.Empty
};
}

View File

@@ -0,0 +1,80 @@
// -----------------------------------------------------------------------------
// SignatureRequiredGateExtensions.cs
// Sprint: SPRINT_20260112_017_POLICY_signature_required_gate
// Tasks: SIG-GATE-008
// Description: Extension methods for signature required gate registration.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Policy.Gates;
/// <summary>
/// Extension methods for signature required gate registration.
/// </summary>
public static class SignatureRequiredGateExtensions
{
/// <summary>
/// Adds signature required gate services to the service collection.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration to bind options from.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddSignatureRequiredGate(
this IServiceCollection services,
IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
services.Configure<SignatureRequiredGateOptions>(
configuration.GetSection(SignatureRequiredGateOptions.SectionName));
services.TryAddSingleton<SignatureRequiredGate>(sp =>
{
var options = sp.GetService<Microsoft.Extensions.Options.IOptions<SignatureRequiredGateOptions>>()?.Value;
return new SignatureRequiredGate(options);
});
return services;
}
/// <summary>
/// Adds signature required gate services with explicit options.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configureOptions">Options configuration action.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddSignatureRequiredGate(
this IServiceCollection services,
Action<SignatureRequiredGateOptions> configureOptions)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configureOptions);
services.Configure(configureOptions);
services.TryAddSingleton<SignatureRequiredGate>(sp =>
{
var options = sp.GetService<Microsoft.Extensions.Options.IOptions<SignatureRequiredGateOptions>>()?.Value;
return new SignatureRequiredGate(options);
});
return services;
}
/// <summary>
/// Registers the signature required gate with a policy gate registry.
/// </summary>
/// <param name="registry">Policy gate registry.</param>
/// <returns>Registry for chaining.</returns>
public static IPolicyGateRegistry RegisterSignatureRequiredGate(this IPolicyGateRegistry registry)
{
ArgumentNullException.ThrowIfNull(registry);
registry.Register<SignatureRequiredGate>(nameof(SignatureRequiredGate));
return registry;
}
}

View File

@@ -62,6 +62,41 @@ public sealed record VexProofGateOptions
["staging"] = "medium",
["development"] = "low",
};
// Sprint: SPRINT_20260112_004_BE_policy_determinization_attested_rules (DET-ATT-004)
/// <summary>
/// Whether anchor-aware mode is enabled.
/// When enabled, additional validation requirements are enforced.
/// </summary>
public bool AnchorAwareMode { get; init; } = false;
/// <summary>
/// When anchor-aware mode is enabled, require VEX statements to have DSSE anchoring.
/// </summary>
public bool RequireVexAnchoring { get; init; } = false;
/// <summary>
/// When anchor-aware mode is enabled, require Rekor transparency verification.
/// </summary>
public bool RequireRekorVerification { get; init; } = false;
/// <summary>
/// Creates strict anchor-aware options for production use.
/// </summary>
public static VexProofGateOptions StrictAnchorAware => new()
{
Enabled = true,
MinimumConfidenceTier = "high",
RequireProofForNotAffected = true,
RequireProofForFixed = true,
RequireSignedStatements = true,
AnchorAwareMode = true,
RequireVexAnchoring = true,
RequireRekorVerification = true,
MaxAllowedConflicts = 0,
MaxProofAgeHours = 72 // 3 days for strict mode
};
}
/// <summary>
@@ -96,6 +131,20 @@ public sealed record VexProofGateContext
/// <summary>Consensus outcome from the proof.</summary>
public string? ConsensusOutcome { get; init; }
// Sprint: SPRINT_20260112_004_BE_policy_determinization_attested_rules (DET-ATT-004)
/// <summary>Whether the VEX proof is anchored with DSSE attestation.</summary>
public bool? IsAnchored { get; init; }
/// <summary>DSSE envelope digest if anchored.</summary>
public string? EnvelopeDigest { get; init; }
/// <summary>Whether the proof has Rekor transparency.</summary>
public bool? HasRekorVerification { get; init; }
/// <summary>Rekor log index if verified.</summary>
public long? RekorLogIndex { get; init; }
}
/// <summary>
@@ -225,6 +274,51 @@ public sealed class VexProofGate : IPolicyGate
details["consensusOutcome"] = proofContext.ConsensusOutcome;
}
// Sprint: SPRINT_20260112_004_BE_policy_determinization_attested_rules (DET-ATT-004)
// Anchor-aware mode validations
if (_options.AnchorAwareMode)
{
details["anchorAwareMode"] = true;
// Validate VEX anchoring if required
if (_options.RequireVexAnchoring)
{
details["requireVexAnchoring"] = true;
details["isAnchored"] = proofContext.IsAnchored ?? false;
if (proofContext.IsAnchored != true)
{
return Task.FromResult(Fail("vex_not_anchored",
details.ToImmutableDictionary(),
"VEX proof requires DSSE anchoring in anchor-aware mode"));
}
if (!string.IsNullOrEmpty(proofContext.EnvelopeDigest))
{
details["envelopeDigest"] = proofContext.EnvelopeDigest;
}
}
// Validate Rekor verification if required
if (_options.RequireRekorVerification)
{
details["requireRekorVerification"] = true;
details["hasRekorVerification"] = proofContext.HasRekorVerification ?? false;
if (proofContext.HasRekorVerification != true)
{
return Task.FromResult(Fail("rekor_verification_missing",
details.ToImmutableDictionary(),
"VEX proof requires Rekor transparency verification in anchor-aware mode"));
}
if (proofContext.RekorLogIndex.HasValue)
{
details["rekorLogIndex"] = proofContext.RekorLogIndex.Value;
}
}
}
return Task.FromResult(new GateResult
{
GateName = nameof(VexProofGate),
@@ -291,6 +385,14 @@ public sealed class VexProofGate : IPolicyGate
ProofComputedAt = context.Metadata.TryGetValue("vex_proof_computed_at", out var timeStr) &&
DateTimeOffset.TryParse(timeStr, out var time) ? time : null,
ConsensusOutcome = context.Metadata.GetValueOrDefault("vex_proof_consensus_outcome"),
// Sprint: SPRINT_20260112_004_BE_policy_determinization_attested_rules (DET-ATT-004)
IsAnchored = context.Metadata.TryGetValue("vex_proof_anchored", out var anchoredStr) &&
bool.TryParse(anchoredStr, out var anchored) ? anchored : null,
EnvelopeDigest = context.Metadata.GetValueOrDefault("vex_proof_envelope_digest"),
HasRekorVerification = context.Metadata.TryGetValue("vex_proof_rekor_verified", out var rekorStr) &&
bool.TryParse(rekorStr, out var rekorVerified) ? rekorVerified : null,
RekorLogIndex = context.Metadata.TryGetValue("vex_proof_rekor_log_index", out var rekorIdxStr) &&
long.TryParse(rekorIdxStr, out var rekorIdx) ? rekorIdx : null,
};
}
@@ -309,4 +411,13 @@ public sealed class VexProofGate : IPolicyGate
Reason = reason,
Details = details ?? ImmutableDictionary<string, object>.Empty,
};
// Sprint: SPRINT_20260112_004_BE_policy_determinization_attested_rules (DET-ATT-004)
private static GateResult Fail(string reason, ImmutableDictionary<string, object>? details, string message) => new()
{
GateName = nameof(VexProofGate),
Passed = false,
Reason = reason,
Details = (details ?? ImmutableDictionary<string, object>.Empty).Add("message", message),
};
}

View File

@@ -0,0 +1,216 @@
// <copyright file="DeterminizationOptionsTests.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_20260112_012_POLICY_determinization_reanalysis_config (POLICY-CONFIG-005)
// </copyright>
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Policy.Determinization.Tests;
[Trait("Category", TestCategories.Unit)]
public class DeterminizationOptionsTests
{
[Fact]
public void Defaults_HaveExpectedValues()
{
// Arrange & Act
var options = new DeterminizationOptions();
// Assert - base options
Assert.Equal(14.0, options.ConfidenceHalfLifeDays);
Assert.Equal(0.1, options.ConfidenceFloor);
Assert.Equal(0.60, options.ManualReviewEntropyThreshold);
Assert.Equal(0.40, options.RefreshEntropyThreshold);
Assert.Equal(30.0, options.StaleObservationDays);
Assert.False(options.EnableDetailedLogging);
Assert.True(options.EnableAutoRefresh);
Assert.Equal(3, options.MaxSignalQueryRetries);
// Assert - reanalysis triggers (POLICY-CONFIG-001)
Assert.Equal(0.2, options.Triggers.EpssDeltaThreshold);
Assert.True(options.Triggers.TriggerOnThresholdCrossing);
Assert.True(options.Triggers.TriggerOnRekorEntry);
Assert.True(options.Triggers.TriggerOnVexStatusChange);
Assert.True(options.Triggers.TriggerOnRuntimeTelemetryChange);
Assert.True(options.Triggers.TriggerOnPatchProofAdded);
Assert.True(options.Triggers.TriggerOnDsseValidationChange);
Assert.False(options.Triggers.TriggerOnToolVersionChange); // Disabled by default
Assert.Equal(15, options.Triggers.MinReanalysisIntervalMinutes);
Assert.Equal(10, options.Triggers.MaxReanalysesPerDayPerCve);
// Assert - conflict policy
Assert.Equal(ConflictAction.RequireManualReview, options.ConflictPolicy.VexReachabilityConflictAction);
Assert.Equal(ConflictAction.RequireManualReview, options.ConflictPolicy.StaticRuntimeConflictAction);
Assert.Equal(ConflictAction.RequestVendorClarification, options.ConflictPolicy.VexStatusConflictAction);
Assert.Equal(ConflictAction.RequireManualReview, options.ConflictPolicy.BackportStatusConflictAction);
Assert.Equal(0.85, options.ConflictPolicy.EscalationSeverityThreshold);
Assert.Equal(48, options.ConflictPolicy.ConflictTtlHours);
Assert.False(options.ConflictPolicy.EnableAutoResolution);
}
[Fact]
public void EnvironmentThresholds_Development_IsRelaxed()
{
// Arrange
var options = new DeterminizationOptions();
// Act
var dev = options.EnvironmentThresholds.Development;
// Assert
Assert.Equal(0.60, dev.MaxPassEntropy);
Assert.Equal(1, dev.MinEvidenceCount);
Assert.False(dev.RequireDsseSigning);
Assert.False(dev.RequireRekorTransparency);
}
[Fact]
public void EnvironmentThresholds_Staging_IsStandard()
{
// Arrange
var options = new DeterminizationOptions();
// Act
var staging = options.EnvironmentThresholds.Staging;
// Assert
Assert.Equal(0.40, staging.MaxPassEntropy);
Assert.Equal(2, staging.MinEvidenceCount);
Assert.False(staging.RequireDsseSigning);
Assert.False(staging.RequireRekorTransparency);
}
[Fact]
public void EnvironmentThresholds_Production_IsStrict()
{
// Arrange
var options = new DeterminizationOptions();
// Act
var prod = options.EnvironmentThresholds.Production;
// Assert
Assert.Equal(0.25, prod.MaxPassEntropy);
Assert.Equal(3, prod.MinEvidenceCount);
Assert.True(prod.RequireDsseSigning);
Assert.True(prod.RequireRekorTransparency);
}
[Theory]
[InlineData("dev", 0.60)]
[InlineData("DEV", 0.60)]
[InlineData("development", 0.60)]
[InlineData("DEVELOPMENT", 0.60)]
[InlineData("stage", 0.40)]
[InlineData("STAGE", 0.40)]
[InlineData("staging", 0.40)]
[InlineData("qa", 0.40)]
[InlineData("QA", 0.40)]
[InlineData("prod", 0.25)]
[InlineData("PROD", 0.25)]
[InlineData("production", 0.25)]
[InlineData("PRODUCTION", 0.25)]
[InlineData("unknown", 0.40)] // Falls back to staging
[InlineData("", 0.40)]
public void GetForEnvironment_ReturnsCorrectThresholds(string envName, double expectedMaxEntropy)
{
// Arrange
var options = new DeterminizationOptions();
// Act
var thresholds = options.EnvironmentThresholds.GetForEnvironment(envName);
// Assert
Assert.Equal(expectedMaxEntropy, thresholds.MaxPassEntropy);
}
[Fact]
public void BindFromConfiguration_LoadsAllSections()
{
// Arrange
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Determinization:ConfidenceHalfLifeDays"] = "21",
["Determinization:ConfidenceFloor"] = "0.15",
["Determinization:ManualReviewEntropyThreshold"] = "0.65",
["Determinization:Triggers:EpssDeltaThreshold"] = "0.3",
["Determinization:Triggers:TriggerOnToolVersionChange"] = "true",
["Determinization:Triggers:MinReanalysisIntervalMinutes"] = "30",
["Determinization:ConflictPolicy:EscalationSeverityThreshold"] = "0.9",
["Determinization:ConflictPolicy:ConflictTtlHours"] = "72",
["Determinization:EnvironmentThresholds:Production:MaxPassEntropy"] = "0.20",
["Determinization:EnvironmentThresholds:Production:MinEvidenceCount"] = "4"
})
.Build();
var services = new ServiceCollection();
services.AddOptions<DeterminizationOptions>()
.Bind(config.GetSection(DeterminizationOptions.SectionName));
var provider = services.BuildServiceProvider();
// Act
var options = provider.GetRequiredService<IOptions<DeterminizationOptions>>().Value;
// Assert - base options
Assert.Equal(21.0, options.ConfidenceHalfLifeDays);
Assert.Equal(0.15, options.ConfidenceFloor);
Assert.Equal(0.65, options.ManualReviewEntropyThreshold);
// Assert - triggers
Assert.Equal(0.3, options.Triggers.EpssDeltaThreshold);
Assert.True(options.Triggers.TriggerOnToolVersionChange);
Assert.Equal(30, options.Triggers.MinReanalysisIntervalMinutes);
// Assert - conflict policy
Assert.Equal(0.9, options.ConflictPolicy.EscalationSeverityThreshold);
Assert.Equal(72, options.ConflictPolicy.ConflictTtlHours);
// Assert - environment thresholds
Assert.Equal(0.20, options.EnvironmentThresholds.Production.MaxPassEntropy);
Assert.Equal(4, options.EnvironmentThresholds.Production.MinEvidenceCount);
}
[Fact]
public void ConflictAction_AllValuesAreDefined()
{
// Arrange & Act
var values = Enum.GetValues<ConflictAction>();
// Assert - ensure all expected values exist
Assert.Contains(ConflictAction.LogAndContinue, values);
Assert.Contains(ConflictAction.RequireManualReview, values);
Assert.Contains(ConflictAction.RequestVendorClarification, values);
Assert.Contains(ConflictAction.EscalateToCommittee, values);
Assert.Contains(ConflictAction.BlockUntilResolved, values);
}
[Fact]
public void EnvironmentThresholdValues_Presets_AreDeterministic()
{
// Verify presets don't change between calls (important for determinism)
var relaxed1 = EnvironmentThresholdValues.Relaxed;
var relaxed2 = EnvironmentThresholdValues.Relaxed;
var standard1 = EnvironmentThresholdValues.Standard;
var standard2 = EnvironmentThresholdValues.Standard;
var strict1 = EnvironmentThresholdValues.Strict;
var strict2 = EnvironmentThresholdValues.Strict;
// Records should be equal by value
Assert.Equal(relaxed1, relaxed2);
Assert.Equal(standard1, standard2);
Assert.Equal(strict1, strict2);
// Different presets should not be equal
Assert.NotEqual(relaxed1, standard1);
Assert.NotEqual(standard1, strict1);
Assert.NotEqual(relaxed1, strict1);
}
}

View File

@@ -0,0 +1,181 @@
// <copyright file="ReanalysisFingerprintTests.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_20260112_004_POLICY_unknowns_determinization_greyqueue (POLICY-UNK-006)
// </copyright>
using Microsoft.Extensions.Time.Testing;
using StellaOps.Policy.Determinization.Models;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Policy.Determinization.Tests.Models;
[Trait("Category", TestCategories.Unit)]
public class ReanalysisFingerprintTests
{
private readonly FakeTimeProvider _timeProvider;
public ReanalysisFingerprintTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 15, 12, 0, 0, TimeSpan.Zero));
}
[Fact]
public void Build_WithAllInputs_GeneratesDeterministicFingerprint()
{
// Arrange
var builder1 = new ReanalysisFingerprintBuilder(_timeProvider)
.WithDsseBundleDigest("sha256:bundle123")
.AddEvidenceDigest("sha256:evidence1")
.AddEvidenceDigest("sha256:evidence2")
.WithToolVersion("scanner", "1.0.0")
.WithToolVersion("policy-engine", "2.0.0")
.WithProductVersion("myapp@1.2.3")
.WithPolicyConfigHash("sha256:config456")
.WithSignalWeightsHash("sha256:weights789");
var builder2 = new ReanalysisFingerprintBuilder(_timeProvider)
.WithDsseBundleDigest("sha256:bundle123")
.AddEvidenceDigest("sha256:evidence1")
.AddEvidenceDigest("sha256:evidence2")
.WithToolVersion("scanner", "1.0.0")
.WithToolVersion("policy-engine", "2.0.0")
.WithProductVersion("myapp@1.2.3")
.WithPolicyConfigHash("sha256:config456")
.WithSignalWeightsHash("sha256:weights789");
// Act
var fingerprint1 = builder1.Build();
var fingerprint2 = builder2.Build();
// Assert - same inputs produce same fingerprint ID
Assert.Equal(fingerprint1.FingerprintId, fingerprint2.FingerprintId);
Assert.StartsWith("sha256:", fingerprint1.FingerprintId);
}
[Fact]
public void Build_WithDifferentInputs_GeneratesDifferentFingerprint()
{
// Arrange
var builder1 = new ReanalysisFingerprintBuilder(_timeProvider)
.WithDsseBundleDigest("sha256:bundle123")
.WithProductVersion("myapp@1.2.3");
var builder2 = new ReanalysisFingerprintBuilder(_timeProvider)
.WithDsseBundleDigest("sha256:bundle456") // Different
.WithProductVersion("myapp@1.2.3");
// Act
var fingerprint1 = builder1.Build();
var fingerprint2 = builder2.Build();
// Assert - different inputs produce different fingerprint IDs
Assert.NotEqual(fingerprint1.FingerprintId, fingerprint2.FingerprintId);
}
[Fact]
public void Build_EvidenceDigests_AreSortedDeterministically()
{
// Arrange - add in random order
var builder = new ReanalysisFingerprintBuilder(_timeProvider)
.AddEvidenceDigest("sha256:zzz")
.AddEvidenceDigest("sha256:aaa")
.AddEvidenceDigest("sha256:mmm");
// Act
var fingerprint = builder.Build();
// Assert - sorted alphabetically
Assert.Equal(3, fingerprint.EvidenceDigests.Count);
Assert.Equal("sha256:aaa", fingerprint.EvidenceDigests[0]);
Assert.Equal("sha256:mmm", fingerprint.EvidenceDigests[1]);
Assert.Equal("sha256:zzz", fingerprint.EvidenceDigests[2]);
}
[Fact]
public void Build_ToolVersions_AreSortedDeterministically()
{
// Arrange - add in random order
var builder = new ReanalysisFingerprintBuilder(_timeProvider)
.WithToolVersion("zebra-tool", "1.0.0")
.WithToolVersion("alpha-tool", "2.0.0")
.WithToolVersion("mike-tool", "3.0.0");
// Act
var fingerprint = builder.Build();
// Assert - sorted by key
var keys = fingerprint.ToolVersions.Keys.ToList();
Assert.Equal("alpha-tool", keys[0]);
Assert.Equal("mike-tool", keys[1]);
Assert.Equal("zebra-tool", keys[2]);
}
[Fact]
public void Build_Triggers_AreSortedByEventTypeThenTime()
{
// Arrange
var builder = new ReanalysisFingerprintBuilder(_timeProvider)
.AddTrigger("vex.changed", 1, "excititor")
.AddTrigger("epss.updated", 1, "signals")
.AddTrigger("runtime.detected", 1, "zastava");
// Act
var fingerprint = builder.Build();
// Assert - sorted by event type
Assert.Equal(3, fingerprint.Triggers.Count);
Assert.Equal("epss.updated", fingerprint.Triggers[0].EventType);
Assert.Equal("runtime.detected", fingerprint.Triggers[1].EventType);
Assert.Equal("vex.changed", fingerprint.Triggers[2].EventType);
}
[Fact]
public void Build_DuplicateEvidenceDigests_AreDeduped()
{
// Arrange
var builder = new ReanalysisFingerprintBuilder(_timeProvider)
.AddEvidenceDigest("sha256:abc")
.AddEvidenceDigest("sha256:abc") // duplicate
.AddEvidenceDigest("sha256:def");
// Act
var fingerprint = builder.Build();
// Assert
Assert.Equal(2, fingerprint.EvidenceDigests.Count);
}
[Fact]
public void Build_NextActions_AreSortedAndDeduped()
{
// Arrange
var builder = new ReanalysisFingerprintBuilder(_timeProvider)
.AddNextAction("rescan")
.AddNextAction("notify")
.AddNextAction("rescan") // duplicate
.AddNextAction("adjudicate");
// Act
var fingerprint = builder.Build();
// Assert
Assert.Equal(3, fingerprint.NextActions.Count);
Assert.Equal("adjudicate", fingerprint.NextActions[0]);
Assert.Equal("notify", fingerprint.NextActions[1]);
Assert.Equal("rescan", fingerprint.NextActions[2]);
}
[Fact]
public void Build_SetsComputedAtFromTimeProvider()
{
// Arrange
var builder = new ReanalysisFingerprintBuilder(_timeProvider);
// Act
var fingerprint = builder.Build();
// Assert
Assert.Equal(_timeProvider.GetUtcNow(), fingerprint.ComputedAt);
}
}

View File

@@ -0,0 +1,239 @@
// <copyright file="ConflictDetectorTests.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_20260112_004_POLICY_unknowns_determinization_greyqueue (POLICY-UNK-006)
// </copyright>
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Policy.Determinization.Evidence;
using StellaOps.Policy.Determinization.Models;
using StellaOps.Policy.Determinization.Scoring;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Policy.Determinization.Tests.Scoring;
[Trait("Category", TestCategories.Unit)]
public class ConflictDetectorTests
{
private readonly ConflictDetector _detector;
private readonly DateTimeOffset _now = new(2026, 1, 15, 12, 0, 0, TimeSpan.Zero);
public ConflictDetectorTests()
{
_detector = new ConflictDetector(NullLogger<ConflictDetector>.Instance);
}
[Fact]
public void Detect_NoConflicts_ReturnsNoConflictResult()
{
// Arrange - consistent signals
var snapshot = CreateSnapshot(
vexStatus: "affected",
vexConfidence: 0.9,
reachable: true,
runtimeDetected: true,
backportDetected: false);
// Act
var result = _detector.Detect(snapshot);
// Assert
Assert.False(result.HasConflict);
Assert.Empty(result.Conflicts);
Assert.Equal(AdjudicationPath.None, result.SuggestedPath);
}
[Fact]
public void Detect_VexNotAffectedButReachable_DetectsConflict()
{
// Arrange - VEX says not_affected but reachability shows exploitable
var snapshot = CreateSnapshot(
vexStatus: "not_affected",
vexConfidence: 0.9,
reachable: true,
runtimeDetected: false,
backportDetected: false);
// Act
var result = _detector.Detect(snapshot);
// Assert
Assert.True(result.HasConflict);
Assert.Single(result.Conflicts);
Assert.Equal(ConflictType.VexReachabilityContradiction, result.Conflicts[0].Type);
Assert.Equal(0.9, result.Conflicts[0].Severity);
}
[Fact]
public void Detect_StaticUnreachableButRuntimeDetected_DetectsConflict()
{
// Arrange - static analysis says unreachable but runtime shows execution
var snapshot = CreateSnapshot(
vexStatus: "affected",
vexConfidence: 0.9,
reachable: false,
reachabilityStatus: ReachabilityStatus.Unreachable,
runtimeDetected: true,
backportDetected: false);
// Act
var result = _detector.Detect(snapshot);
// Assert
Assert.True(result.HasConflict);
Assert.Contains(result.Conflicts, c => c.Type == ConflictType.StaticRuntimeContradiction);
}
[Fact]
public void Detect_MultipleVexWithLowConfidence_DetectsConflict()
{
// Arrange - multiple VEX sources with conflicting status (low confidence)
var snapshot = CreateSnapshot(
vexStatus: "affected",
vexConfidence: 0.5, // Low confidence indicates conflict
vexStatementCount: 3,
reachable: true,
runtimeDetected: false,
backportDetected: false);
// Act
var result = _detector.Detect(snapshot);
// Assert
Assert.True(result.HasConflict);
Assert.Contains(result.Conflicts, c => c.Type == ConflictType.VexStatusConflict);
Assert.Equal(AdjudicationPath.VendorClarification, result.SuggestedPath);
}
[Fact]
public void Detect_BackportedButVexAffected_DetectsConflict()
{
// Arrange - backport evidence says fixed but VEX still says affected
var snapshot = CreateSnapshot(
vexStatus: "affected",
vexConfidence: 0.9,
reachable: false,
runtimeDetected: false,
backportDetected: true);
// Act
var result = _detector.Detect(snapshot);
// Assert
Assert.True(result.HasConflict);
Assert.Contains(result.Conflicts, c => c.Type == ConflictType.BackportStatusConflict);
}
[Fact]
public void Detect_MultipleConflicts_ReturnsSeverityBasedPath()
{
// Arrange - multiple conflicts
var snapshot = CreateSnapshot(
vexStatus: "not_affected",
vexConfidence: 0.5,
vexStatementCount: 2,
reachable: true,
runtimeDetected: false,
backportDetected: false);
// Act
var result = _detector.Detect(snapshot);
// Assert
Assert.True(result.HasConflict);
Assert.True(result.Conflicts.Count >= 2);
Assert.True(result.Severity >= 0.7);
Assert.Equal(AdjudicationPath.SecurityTeamReview, result.SuggestedPath);
}
[Fact]
public void Detect_ConflictsAreSortedByTypeThenSeverity()
{
// Arrange - multiple conflicts of different types
var snapshot = CreateSnapshot(
vexStatus: "not_affected",
vexConfidence: 0.5,
vexStatementCount: 2,
reachable: true,
runtimeDetected: false,
backportDetected: true);
// Act
var result = _detector.Detect(snapshot);
// Assert - conflicts are sorted by type then severity descending
for (int i = 1; i < result.Conflicts.Count; i++)
{
var prev = result.Conflicts[i - 1];
var curr = result.Conflicts[i];
Assert.True(
prev.Type < curr.Type ||
(prev.Type == curr.Type && prev.Severity >= curr.Severity),
"Conflicts should be sorted by type then severity descending");
}
}
private SignalSnapshot CreateSnapshot(
string vexStatus,
double vexConfidence,
bool reachable,
bool runtimeDetected,
bool backportDetected,
ReachabilityStatus? reachabilityStatus = null,
int vexStatementCount = 1)
{
return new SignalSnapshot
{
Cve = "CVE-2024-12345",
Purl = "pkg:nuget/Test@1.0.0",
SnapshotAt = _now,
Epss = SignalState<EpssEvidence>.Queried(
new EpssEvidence
{
Probability = 0.5,
Percentile = 0.7,
Model = "epss-v3",
FetchedAt = _now
},
_now),
Vex = SignalState<VexClaimSummary>.Queried(
new VexClaimSummary
{
Status = vexStatus,
Confidence = vexConfidence,
StatementCount = vexStatementCount,
ComputedAt = _now
},
_now),
Reachability = SignalState<ReachabilityEvidence>.Queried(
new ReachabilityEvidence
{
Status = reachabilityStatus ?? (reachable ? ReachabilityStatus.Reachable : ReachabilityStatus.NotAnalyzed),
AnalyzedAt = _now,
Confidence = 0.95
},
_now),
Runtime = SignalState<RuntimeEvidence>.Queried(
new RuntimeEvidence
{
Detected = runtimeDetected,
Source = "tracer",
ObservationStart = _now.AddDays(-7),
ObservationEnd = _now,
Confidence = 0.9
},
_now),
Backport = SignalState<BackportEvidence>.Queried(
new BackportEvidence
{
Detected = backportDetected,
Source = "vendor-advisory",
DetectedAt = _now,
Confidence = 0.85
},
_now),
Sbom = SignalState<SbomLineageEvidence>.NotQueried(),
Cvss = SignalState<CvssEvidence>.NotQueried()
};
}
}

View File

@@ -0,0 +1,347 @@
// -----------------------------------------------------------------------------
// CvssThresholdGateTests.cs
// Sprint: SPRINT_20260112_017_POLICY_cvss_threshold_gate
// Tasks: CVSS-GATE-008, CVSS-GATE-009
// Description: Unit tests for CVSS threshold gate.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using StellaOps.Policy.Confidence.Models;
using StellaOps.Policy.Gates;
using StellaOps.Policy.TrustLattice;
using Xunit;
namespace StellaOps.Policy.Tests.Gates;
[Trait("Category", "Unit")]
public sealed class CvssThresholdGateTests
{
private static MergeResult CreateMergeResult() => new()
{
Status = VexStatus.Affected,
Confidence = 0.8,
HasConflicts = false,
AllClaims = ImmutableArray<ScoredClaim>.Empty,
WinningClaim = new ScoredClaim
{
SourceId = "test",
Status = VexStatus.Affected,
OriginalScore = 0.8,
AdjustedScore = 0.8,
ScopeSpecificity = 1,
Accepted = true,
Reason = "test"
},
Conflicts = ImmutableArray<ConflictRecord>.Empty
};
private static PolicyGateContext CreateContext(
string environment = "production",
string? cveId = null,
Dictionary<string, string>? metadata = null) => new()
{
Environment = environment,
CveId = cveId,
Metadata = metadata
};
[Fact]
public async Task EvaluateAsync_Disabled_ReturnsPass()
{
var options = new CvssThresholdGateOptions { Enabled = false };
var gate = new CvssThresholdGate(options);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.True(result.Passed);
Assert.Equal("disabled", result.Reason);
}
[Fact]
public async Task EvaluateAsync_CveOnDenylist_ReturnsFail()
{
var options = new CvssThresholdGateOptions
{
Denylist = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "CVE-2024-12345" }
};
var gate = new CvssThresholdGate(options);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-12345"));
Assert.False(result.Passed);
Assert.Equal("denylist", result.Reason);
}
[Fact]
public async Task EvaluateAsync_CveOnAllowlist_ReturnsPass()
{
var options = new CvssThresholdGateOptions
{
Allowlist = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "CVE-2024-99999" }
};
var gate = new CvssThresholdGate(options);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-99999"));
Assert.True(result.Passed);
Assert.Equal("allowlist", result.Reason);
}
[Fact]
public async Task EvaluateAsync_DenylistTakesPrecedenceOverAllowlist()
{
var options = new CvssThresholdGateOptions
{
Allowlist = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "CVE-2024-12345" },
Denylist = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "CVE-2024-12345" }
};
var gate = new CvssThresholdGate(options);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-12345"));
Assert.False(result.Passed);
Assert.Equal("denylist", result.Reason);
}
[Fact]
public async Task EvaluateAsync_NoCvssScore_FailOnMissingFalse_ReturnsPass()
{
var options = new CvssThresholdGateOptions { FailOnMissingCvss = false };
var gate = new CvssThresholdGate(options);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-00001"));
Assert.True(result.Passed);
Assert.Equal("no_cvss_available", result.Reason);
}
[Fact]
public async Task EvaluateAsync_NoCvssScore_FailOnMissingTrue_ReturnsFail()
{
var options = new CvssThresholdGateOptions { FailOnMissingCvss = true };
var gate = new CvssThresholdGate(options);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-00001"));
Assert.False(result.Passed);
Assert.Equal("missing_cvss", result.Reason);
}
[Theory]
[InlineData(6.9, true)] // Below threshold
[InlineData(7.0, false)] // At threshold (fails - must be strictly below)
[InlineData(7.1, false)] // Above threshold
[InlineData(9.9, false)] // Well above threshold
public async Task EvaluateAsync_V31Score_DefaultThreshold_ReturnsExpected(double score, bool expectedPass)
{
var options = new CvssThresholdGateOptions
{
DefaultThreshold = 7.0,
CvssVersionPreference = "v3.1"
};
var lookup = (string? _) => new CvssScoreInfo { CvssV31BaseScore = score };
var gate = new CvssThresholdGate(options, lookup);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-00001"));
Assert.Equal(expectedPass, result.Passed);
}
[Theory]
[InlineData(7.9, true)] // Below staging threshold
[InlineData(8.0, false)] // At staging threshold
[InlineData(8.5, false)] // Above staging threshold
public async Task EvaluateAsync_StagingEnvironment_UsesStagingThreshold(double score, bool expectedPass)
{
var options = new CvssThresholdGateOptions
{
Thresholds = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
{
["production"] = 7.0,
["staging"] = 8.0,
["development"] = 9.0
},
CvssVersionPreference = "v3.1"
};
var lookup = (string? _) => new CvssScoreInfo { CvssV31BaseScore = score };
var gate = new CvssThresholdGate(options, lookup);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(environment: "staging", cveId: "CVE-2024-00001"));
Assert.Equal(expectedPass, result.Passed);
}
[Theory]
[InlineData(8.9, true)] // Below development threshold
[InlineData(9.0, false)] // At development threshold
[InlineData(9.5, false)] // Above development threshold
public async Task EvaluateAsync_DevelopmentEnvironment_UsesDevelopmentThreshold(double score, bool expectedPass)
{
var options = new CvssThresholdGateOptions
{
Thresholds = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
{
["production"] = 7.0,
["staging"] = 8.0,
["development"] = 9.0
},
CvssVersionPreference = "v3.1"
};
var lookup = (string? _) => new CvssScoreInfo { CvssV31BaseScore = score };
var gate = new CvssThresholdGate(options, lookup);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(environment: "development", cveId: "CVE-2024-00001"));
Assert.Equal(expectedPass, result.Passed);
}
[Fact]
public async Task EvaluateAsync_UnknownEnvironment_UsesDefaultThreshold()
{
var options = new CvssThresholdGateOptions
{
DefaultThreshold = 5.0,
Thresholds = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
{
["production"] = 7.0
},
CvssVersionPreference = "v3.1"
};
var lookup = (string? _) => new CvssScoreInfo { CvssV31BaseScore = 5.5 };
var gate = new CvssThresholdGate(options, lookup);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(environment: "qa", cveId: "CVE-2024-00001"));
Assert.False(result.Passed);
Assert.Equal("cvss_exceeds_threshold", result.Reason);
}
[Fact]
public async Task EvaluateAsync_V40Score_UsesV40WhenPreferred()
{
var options = new CvssThresholdGateOptions
{
DefaultThreshold = 7.0,
CvssVersionPreference = "v4.0"
};
var lookup = (string? _) => new CvssScoreInfo
{
CvssV31BaseScore = 8.0, // Would fail
CvssV40BaseScore = 6.0 // Would pass
};
var gate = new CvssThresholdGate(options, lookup);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-00001"));
Assert.True(result.Passed);
Assert.Equal("v4.0", result.Details["cvss_version"]);
}
[Fact]
public async Task EvaluateAsync_HighestPreference_UsesHigherScore()
{
var options = new CvssThresholdGateOptions
{
DefaultThreshold = 7.5,
CvssVersionPreference = "highest"
};
var lookup = (string? _) => new CvssScoreInfo
{
CvssV31BaseScore = 7.0, // Would pass alone
CvssV40BaseScore = 8.0 // Would fail, and is higher
};
var gate = new CvssThresholdGate(options, lookup);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-00001"));
Assert.False(result.Passed);
Assert.Equal(8.0, (double)result.Details["cvss_score"]);
}
[Fact]
public async Task EvaluateAsync_RequireAllVersionsPass_BothMustPass()
{
var options = new CvssThresholdGateOptions
{
DefaultThreshold = 7.5,
CvssVersionPreference = "highest",
RequireAllVersionsPass = true
};
var lookup = (string? _) => new CvssScoreInfo
{
CvssV31BaseScore = 7.0, // Would pass
CvssV40BaseScore = 8.0 // Would fail
};
var gate = new CvssThresholdGate(options, lookup);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-00001"));
Assert.False(result.Passed);
}
[Fact]
public async Task EvaluateAsync_RequireAllVersionsPass_BothPass()
{
var options = new CvssThresholdGateOptions
{
DefaultThreshold = 8.5,
CvssVersionPreference = "highest",
RequireAllVersionsPass = true
};
var lookup = (string? _) => new CvssScoreInfo
{
CvssV31BaseScore = 7.0,
CvssV40BaseScore = 8.0
};
var gate = new CvssThresholdGate(options, lookup);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-00001"));
Assert.True(result.Passed);
}
[Fact]
public async Task EvaluateAsync_MetadataFallback_ExtractsFromContext()
{
var options = new CvssThresholdGateOptions
{
DefaultThreshold = 7.0,
CvssVersionPreference = "v3.1"
};
var metadata = new Dictionary<string, string>
{
["cvss_v31_score"] = "6.5"
};
var gate = new CvssThresholdGate(options);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-00001", metadata: metadata));
Assert.True(result.Passed);
Assert.Equal(6.5, (double)result.Details["cvss_score"]);
}
[Fact]
public async Task EvaluateAsync_CaseInsensitiveCveMatch()
{
var options = new CvssThresholdGateOptions
{
Allowlist = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "cve-2024-12345" }
};
var gate = new CvssThresholdGate(options);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-12345"));
Assert.True(result.Passed);
Assert.Equal("allowlist", result.Reason);
}
[Fact]
public async Task EvaluateAsync_IncludesAllDetailsInResult()
{
var options = new CvssThresholdGateOptions
{
DefaultThreshold = 7.0,
CvssVersionPreference = "v3.1"
};
var lookup = (string? _) => new CvssScoreInfo
{
CvssV31BaseScore = 8.5,
CvssV40BaseScore = 7.2
};
var gate = new CvssThresholdGate(options, lookup);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(environment: "production", cveId: "CVE-2024-00001"));
Assert.False(result.Passed);
Assert.Equal(7.0, (double)result.Details["threshold"]);
Assert.Equal("production", result.Details["environment"]);
Assert.Equal("v3.1", result.Details["cvss_version"]);
Assert.Equal(8.5, (double)result.Details["cvss_score"]);
Assert.Equal(8.5, (double)result.Details["cvss_v31_score"]);
Assert.Equal(7.2, (double)result.Details["cvss_v40_score"]);
Assert.Equal("CVE-2024-00001", result.Details["cve_id"]);
}
}

View File

@@ -0,0 +1,384 @@
// -----------------------------------------------------------------------------
// SbomPresenceGateTests.cs
// Sprint: SPRINT_20260112_017_POLICY_sbom_presence_gate
// Tasks: SBOM-GATE-009
// Description: Unit tests for SBOM presence gate.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using StellaOps.Policy.Confidence.Models;
using StellaOps.Policy.Gates;
using StellaOps.Policy.TrustLattice;
using Xunit;
namespace StellaOps.Policy.Tests.Gates;
[Trait("Category", "Unit")]
public sealed class SbomPresenceGateTests
{
private static MergeResult CreateMergeResult() => new()
{
Status = VexStatus.Affected,
Confidence = 0.8,
HasConflicts = false,
AllClaims = ImmutableArray<ScoredClaim>.Empty,
WinningClaim = new ScoredClaim
{
SourceId = "test",
Status = VexStatus.Affected,
OriginalScore = 0.8,
AdjustedScore = 0.8,
ScopeSpecificity = 1,
Accepted = true,
Reason = "test"
},
Conflicts = ImmutableArray<ConflictRecord>.Empty
};
private static PolicyGateContext CreateContext(
string environment = "production",
Dictionary<string, string>? metadata = null) => new()
{
Environment = environment,
Metadata = metadata
};
[Fact]
public async Task EvaluateAsync_Disabled_ReturnsPass()
{
var options = new SbomPresenceGateOptions { Enabled = false };
var gate = new SbomPresenceGate(options);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.True(result.Passed);
Assert.Equal("disabled", result.Reason);
}
[Fact]
public async Task EvaluateAsync_OptionalEnforcement_ReturnsPass()
{
var options = new SbomPresenceGateOptions
{
Enforcement = new Dictionary<string, SbomEnforcementLevel>(StringComparer.OrdinalIgnoreCase)
{
["development"] = SbomEnforcementLevel.Optional
}
};
var gate = new SbomPresenceGate(options);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(environment: "development"));
Assert.True(result.Passed);
Assert.Equal("optional_enforcement", result.Reason);
}
[Fact]
public async Task EvaluateAsync_MissingSbom_RequiredEnforcement_ReturnsFail()
{
var options = new SbomPresenceGateOptions();
var gate = new SbomPresenceGate(options);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.False(result.Passed);
Assert.Equal("sbom_missing", result.Reason);
}
[Fact]
public async Task EvaluateAsync_MissingSbom_RecommendedEnforcement_ReturnsPassWithWarning()
{
var options = new SbomPresenceGateOptions
{
Enforcement = new Dictionary<string, SbomEnforcementLevel>(StringComparer.OrdinalIgnoreCase)
{
["staging"] = SbomEnforcementLevel.Recommended
}
};
var gate = new SbomPresenceGate(options);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(environment: "staging"));
Assert.True(result.Passed);
Assert.Equal("sbom_missing_recommended", result.Reason);
Assert.Contains("warning", result.Details.Keys);
}
[Fact]
public async Task EvaluateAsync_ValidSbom_ReturnsPass()
{
var options = new SbomPresenceGateOptions();
var sbomInfo = new SbomInfo
{
Present = true,
Format = "spdx-2.3",
ComponentCount = 10,
HasPrimaryComponent = true,
SchemaValid = true
};
var gate = new SbomPresenceGate(options, _ => sbomInfo);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.True(result.Passed);
Assert.Equal("sbom_valid", result.Reason);
}
[Theory]
[InlineData("spdx-2.2")]
[InlineData("spdx-2.3")]
[InlineData("spdx-3.0.1")]
[InlineData("cyclonedx-1.4")]
[InlineData("cyclonedx-1.5")]
[InlineData("cyclonedx-1.6")]
[InlineData("cyclonedx-1.7")]
public async Task EvaluateAsync_AcceptedFormats_ReturnsPass(string format)
{
var options = new SbomPresenceGateOptions();
var sbomInfo = new SbomInfo
{
Present = true,
Format = format,
ComponentCount = 5,
HasPrimaryComponent = true
};
var gate = new SbomPresenceGate(options, _ => sbomInfo);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.True(result.Passed);
}
[Theory]
[InlineData("unknown-1.0")]
[InlineData("custom-format")]
[InlineData("spdx-1.0")]
public async Task EvaluateAsync_InvalidFormat_ReturnsFail(string format)
{
var options = new SbomPresenceGateOptions();
var sbomInfo = new SbomInfo
{
Present = true,
Format = format,
ComponentCount = 5,
HasPrimaryComponent = true
};
var gate = new SbomPresenceGate(options, _ => sbomInfo);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.False(result.Passed);
Assert.Equal("invalid_format", result.Reason);
}
[Fact]
public async Task EvaluateAsync_InsufficientComponents_ReturnsFail()
{
var options = new SbomPresenceGateOptions { MinimumComponents = 5 };
var sbomInfo = new SbomInfo
{
Present = true,
Format = "spdx-2.3",
ComponentCount = 3,
HasPrimaryComponent = true
};
var gate = new SbomPresenceGate(options, _ => sbomInfo);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.False(result.Passed);
Assert.Equal("insufficient_components", result.Reason);
Assert.Equal(5, (int)result.Details["minimum_components"]);
Assert.Equal(3, (int)result.Details["component_count"]);
}
[Fact]
public async Task EvaluateAsync_SchemaValidationFailed_ReturnsFail()
{
var options = new SbomPresenceGateOptions { SchemaValidation = true };
var sbomInfo = new SbomInfo
{
Present = true,
Format = "spdx-2.3",
ComponentCount = 5,
HasPrimaryComponent = true,
SchemaValid = false,
SchemaErrors = new[] { "Missing required field 'name'", "Invalid date format" }
};
var gate = new SbomPresenceGate(options, _ => sbomInfo);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.False(result.Passed);
Assert.Equal("schema_validation_failed", result.Reason);
Assert.Contains("schema_errors", result.Details.Keys);
}
[Fact]
public async Task EvaluateAsync_SignatureRequired_MissingSignature_ReturnsFail()
{
var options = new SbomPresenceGateOptions { RequireSignature = true };
var sbomInfo = new SbomInfo
{
Present = true,
Format = "spdx-2.3",
ComponentCount = 5,
HasPrimaryComponent = true,
HasSignature = false
};
var gate = new SbomPresenceGate(options, _ => sbomInfo);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.False(result.Passed);
Assert.Equal("signature_missing", result.Reason);
}
[Fact]
public async Task EvaluateAsync_SignatureRequired_InvalidSignature_ReturnsFail()
{
var options = new SbomPresenceGateOptions { RequireSignature = true };
var sbomInfo = new SbomInfo
{
Present = true,
Format = "spdx-2.3",
ComponentCount = 5,
HasPrimaryComponent = true,
HasSignature = true,
SignatureValid = false
};
var gate = new SbomPresenceGate(options, _ => sbomInfo);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.False(result.Passed);
Assert.Equal("signature_invalid", result.Reason);
}
[Fact]
public async Task EvaluateAsync_SignatureRequired_ValidSignature_ReturnsPass()
{
var options = new SbomPresenceGateOptions { RequireSignature = true };
var sbomInfo = new SbomInfo
{
Present = true,
Format = "spdx-2.3",
ComponentCount = 5,
HasPrimaryComponent = true,
HasSignature = true,
SignatureValid = true
};
var gate = new SbomPresenceGate(options, _ => sbomInfo);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.True(result.Passed);
}
[Fact]
public async Task EvaluateAsync_PrimaryComponentRequired_Missing_ReturnsFail()
{
var options = new SbomPresenceGateOptions { RequirePrimaryComponent = true };
var sbomInfo = new SbomInfo
{
Present = true,
Format = "spdx-2.3",
ComponentCount = 5,
HasPrimaryComponent = false
};
var gate = new SbomPresenceGate(options, _ => sbomInfo);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.False(result.Passed);
Assert.Equal("primary_component_missing", result.Reason);
}
[Theory]
[InlineData("production", SbomEnforcementLevel.Required)]
[InlineData("staging", SbomEnforcementLevel.Required)]
[InlineData("development", SbomEnforcementLevel.Optional)]
public async Task EvaluateAsync_EnvironmentEnforcement_UsesCorrectLevel(string environment, SbomEnforcementLevel expectedLevel)
{
var options = new SbomPresenceGateOptions
{
Enforcement = new Dictionary<string, SbomEnforcementLevel>(StringComparer.OrdinalIgnoreCase)
{
["production"] = SbomEnforcementLevel.Required,
["staging"] = SbomEnforcementLevel.Required,
["development"] = SbomEnforcementLevel.Optional
}
};
var gate = new SbomPresenceGate(options);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(environment: environment));
Assert.Equal(expectedLevel.ToString(), result.Details["enforcement"]);
}
[Fact]
public async Task EvaluateAsync_UnknownEnvironment_UsesDefaultEnforcement()
{
var options = new SbomPresenceGateOptions
{
DefaultEnforcement = SbomEnforcementLevel.Recommended,
Enforcement = new Dictionary<string, SbomEnforcementLevel>(StringComparer.OrdinalIgnoreCase)
{
["production"] = SbomEnforcementLevel.Required
}
};
var gate = new SbomPresenceGate(options);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(environment: "qa"));
Assert.Equal(SbomEnforcementLevel.Recommended.ToString(), result.Details["enforcement"]);
}
[Fact]
public async Task EvaluateAsync_MetadataFallback_ParsesSbomInfo()
{
var options = new SbomPresenceGateOptions();
var metadata = new Dictionary<string, string>
{
["sbom_present"] = "true",
["sbom_format"] = "cyclonedx-1.6",
["sbom_component_count"] = "25",
["sbom_has_primary_component"] = "true"
};
var gate = new SbomPresenceGate(options);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(metadata: metadata));
Assert.True(result.Passed);
Assert.Equal("cyclonedx-1.6", result.Details["format"]);
Assert.Equal(25, (int)result.Details["component_count"]);
}
[Theory]
[InlineData("SPDX-2.3", "spdx-2.3")]
[InlineData("CycloneDX-1.6", "cyclonedx-1.6")]
[InlineData("spdx 2.3", "spdx-2.3")]
[InlineData("cdx-1.5", "cyclonedx-1.5")]
public async Task EvaluateAsync_FormatNormalization_HandlesVariations(string inputFormat, string normalizedExpected)
{
var options = new SbomPresenceGateOptions();
var sbomInfo = new SbomInfo
{
Present = true,
Format = inputFormat,
ComponentCount = 5,
HasPrimaryComponent = true
};
var gate = new SbomPresenceGate(options, _ => sbomInfo);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
// If format was accepted, it was normalized correctly
Assert.True(result.Passed, $"Format '{inputFormat}' should normalize to '{normalizedExpected}' and be accepted");
}
[Fact]
public async Task EvaluateAsync_IncludesOptionalMetadata()
{
var options = new SbomPresenceGateOptions();
var createdAt = new DateTimeOffset(2026, 1, 15, 10, 30, 0, TimeSpan.Zero);
var sbomInfo = new SbomInfo
{
Present = true,
Format = "spdx-2.3",
ComponentCount = 10,
HasPrimaryComponent = true,
DocumentUri = "urn:sbom:example:12345",
CreatedAt = createdAt
};
var gate = new SbomPresenceGate(options, _ => sbomInfo);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.True(result.Passed);
Assert.Equal("urn:sbom:example:12345", result.Details["document_uri"]);
Assert.Contains("2026-01-15", (string)result.Details["created_at"]);
}
}

View File

@@ -0,0 +1,450 @@
// -----------------------------------------------------------------------------
// SignatureRequiredGateTests.cs
// Sprint: SPRINT_20260112_017_POLICY_signature_required_gate
// Tasks: SIG-GATE-009
// Description: Unit tests for signature required gate.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using StellaOps.Policy.Confidence.Models;
using StellaOps.Policy.Gates;
using StellaOps.Policy.TrustLattice;
using Xunit;
namespace StellaOps.Policy.Tests.Gates;
[Trait("Category", "Unit")]
public sealed class SignatureRequiredGateTests
{
private static MergeResult CreateMergeResult() => new()
{
Status = VexStatus.Affected,
Confidence = 0.8,
HasConflicts = false,
AllClaims = ImmutableArray<ScoredClaim>.Empty,
WinningClaim = new ScoredClaim
{
SourceId = "test",
Status = VexStatus.Affected,
OriginalScore = 0.8,
AdjustedScore = 0.8,
ScopeSpecificity = 1,
Accepted = true,
Reason = "test"
},
Conflicts = ImmutableArray<ConflictRecord>.Empty
};
private static PolicyGateContext CreateContext(string environment = "production") => new()
{
Environment = environment
};
[Fact]
public async Task EvaluateAsync_Disabled_ReturnsPass()
{
var options = new SignatureRequiredGateOptions { Enabled = false };
var gate = new SignatureRequiredGate(options);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.True(result.Passed);
Assert.Equal("disabled", result.Reason);
}
[Fact]
public async Task EvaluateAsync_MissingSignature_ReturnsFail()
{
var options = new SignatureRequiredGateOptions();
var signatures = new List<SignatureInfo>(); // No signatures
var gate = new SignatureRequiredGate(options, _ => signatures);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.False(result.Passed);
Assert.Equal("signature_validation_failed", result.Reason);
}
[Fact]
public async Task EvaluateAsync_AllValidSignatures_ReturnsPass()
{
var options = new SignatureRequiredGateOptions();
var signatures = new List<SignatureInfo>
{
new() { EvidenceType = "sbom", HasSignature = true, SignatureValid = true },
new() { EvidenceType = "vex", HasSignature = true, SignatureValid = true },
new() { EvidenceType = "attestation", HasSignature = true, SignatureValid = true }
};
var gate = new SignatureRequiredGate(options, _ => signatures);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.True(result.Passed);
Assert.Equal("signatures_verified", result.Reason);
}
[Fact]
public async Task EvaluateAsync_InvalidSignature_ReturnsFail()
{
var options = new SignatureRequiredGateOptions();
var signatures = new List<SignatureInfo>
{
new() { EvidenceType = "sbom", HasSignature = true, SignatureValid = false, VerificationErrors = new[] { "Invalid hash" } },
new() { EvidenceType = "vex", HasSignature = true, SignatureValid = true },
new() { EvidenceType = "attestation", HasSignature = true, SignatureValid = true }
};
var gate = new SignatureRequiredGate(options, _ => signatures);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.False(result.Passed);
Assert.Contains("failures", result.Details.Keys);
}
[Fact]
public async Task EvaluateAsync_NotRequiredType_PassesWithoutSignature()
{
var options = new SignatureRequiredGateOptions
{
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
{
["sbom"] = new EvidenceSignatureConfig { Required = false },
["vex"] = new EvidenceSignatureConfig { Required = true },
["attestation"] = new EvidenceSignatureConfig { Required = true }
}
};
var signatures = new List<SignatureInfo>
{
// No SBOM signature - but it's not required
new() { EvidenceType = "vex", HasSignature = true, SignatureValid = true },
new() { EvidenceType = "attestation", HasSignature = true, SignatureValid = true }
};
var gate = new SignatureRequiredGate(options, _ => signatures);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.True(result.Passed);
}
[Theory]
[InlineData("build@company.com", new[] { "build@company.com" }, true)]
[InlineData("release@company.com", new[] { "*@company.com" }, true)]
[InlineData("external@other.com", new[] { "*@company.com" }, false)]
[InlineData("build@company.com", new[] { "other@company.com" }, false)]
public async Task EvaluateAsync_IssuerValidation_EnforcesConstraints(
string signerIdentity,
string[] trustedIssuers,
bool expectedPass)
{
var options = new SignatureRequiredGateOptions
{
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
{
["sbom"] = new EvidenceSignatureConfig
{
Required = true,
TrustedIssuers = new HashSet<string>(trustedIssuers, StringComparer.OrdinalIgnoreCase)
},
["vex"] = new EvidenceSignatureConfig { Required = false },
["attestation"] = new EvidenceSignatureConfig { Required = false }
}
};
var signatures = new List<SignatureInfo>
{
new()
{
EvidenceType = "sbom",
HasSignature = true,
SignatureValid = true,
SignerIdentity = signerIdentity
}
};
var gate = new SignatureRequiredGate(options, _ => signatures);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.Equal(expectedPass, result.Passed);
}
[Theory]
[InlineData("ES256", true)]
[InlineData("RS256", true)]
[InlineData("EdDSA", true)]
[InlineData("UNKNOWN", false)]
public async Task EvaluateAsync_AlgorithmValidation_EnforcesAccepted(string algorithm, bool expectedPass)
{
var options = new SignatureRequiredGateOptions
{
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
{
["sbom"] = new EvidenceSignatureConfig { Required = true },
["vex"] = new EvidenceSignatureConfig { Required = false },
["attestation"] = new EvidenceSignatureConfig { Required = false }
}
};
var signatures = new List<SignatureInfo>
{
new()
{
EvidenceType = "sbom",
HasSignature = true,
SignatureValid = true,
Algorithm = algorithm
}
};
var gate = new SignatureRequiredGate(options, _ => signatures);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.Equal(expectedPass, result.Passed);
}
[Fact]
public async Task EvaluateAsync_KeyIdValidation_EnforcesConstraints()
{
var options = new SignatureRequiredGateOptions
{
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
{
["sbom"] = new EvidenceSignatureConfig
{
Required = true,
TrustedKeyIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "key-001", "key-002" }
},
["vex"] = new EvidenceSignatureConfig { Required = false },
["attestation"] = new EvidenceSignatureConfig { Required = false }
}
};
var signatures = new List<SignatureInfo>
{
new()
{
EvidenceType = "sbom",
HasSignature = true,
SignatureValid = true,
KeyId = "key-999",
IsKeyless = false
}
};
var gate = new SignatureRequiredGate(options, _ => signatures);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.False(result.Passed);
}
[Fact]
public async Task EvaluateAsync_KeylessSignature_ValidWithTransparencyLog()
{
var options = new SignatureRequiredGateOptions
{
EnableKeylessVerification = true,
RequireTransparencyLogInclusion = true,
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
{
["sbom"] = new EvidenceSignatureConfig { Required = true },
["vex"] = new EvidenceSignatureConfig { Required = false },
["attestation"] = new EvidenceSignatureConfig { Required = false }
}
};
var signatures = new List<SignatureInfo>
{
new()
{
EvidenceType = "sbom",
HasSignature = true,
SignatureValid = true,
IsKeyless = true,
HasTransparencyLogInclusion = true,
CertificateChainValid = true
}
};
var gate = new SignatureRequiredGate(options, _ => signatures);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.True(result.Passed);
}
[Fact]
public async Task EvaluateAsync_KeylessSignature_FailsWithoutTransparencyLog()
{
var options = new SignatureRequiredGateOptions
{
EnableKeylessVerification = true,
RequireTransparencyLogInclusion = true,
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
{
["sbom"] = new EvidenceSignatureConfig { Required = true },
["vex"] = new EvidenceSignatureConfig { Required = false },
["attestation"] = new EvidenceSignatureConfig { Required = false }
}
};
var signatures = new List<SignatureInfo>
{
new()
{
EvidenceType = "sbom",
HasSignature = true,
SignatureValid = true,
IsKeyless = true,
HasTransparencyLogInclusion = false
}
};
var gate = new SignatureRequiredGate(options, _ => signatures);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.False(result.Passed);
}
[Fact]
public async Task EvaluateAsync_KeylessDisabled_FailsKeylessSignature()
{
var options = new SignatureRequiredGateOptions
{
EnableKeylessVerification = false,
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
{
["sbom"] = new EvidenceSignatureConfig { Required = true },
["vex"] = new EvidenceSignatureConfig { Required = false },
["attestation"] = new EvidenceSignatureConfig { Required = false }
}
};
var signatures = new List<SignatureInfo>
{
new()
{
EvidenceType = "sbom",
HasSignature = true,
SignatureValid = true,
IsKeyless = true
}
};
var gate = new SignatureRequiredGate(options, _ => signatures);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.False(result.Passed);
}
[Fact]
public async Task EvaluateAsync_EnvironmentOverride_SkipsTypes()
{
var options = new SignatureRequiredGateOptions
{
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
{
["sbom"] = new EvidenceSignatureConfig { Required = true },
["vex"] = new EvidenceSignatureConfig { Required = true },
["attestation"] = new EvidenceSignatureConfig { Required = true }
},
Environments = new Dictionary<string, EnvironmentSignatureConfig>(StringComparer.OrdinalIgnoreCase)
{
["development"] = new EnvironmentSignatureConfig
{
SkipEvidenceTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "sbom", "vex" }
}
}
};
var signatures = new List<SignatureInfo>
{
// Only attestation signature in development
new() { EvidenceType = "attestation", HasSignature = true, SignatureValid = true }
};
var gate = new SignatureRequiredGate(options, _ => signatures);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(environment: "development"));
Assert.True(result.Passed);
}
[Fact]
public async Task EvaluateAsync_EnvironmentOverride_AddsIssuers()
{
var options = new SignatureRequiredGateOptions
{
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
{
["sbom"] = new EvidenceSignatureConfig
{
Required = true,
TrustedIssuers = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "prod@company.com" }
},
["vex"] = new EvidenceSignatureConfig { Required = false },
["attestation"] = new EvidenceSignatureConfig { Required = false }
},
Environments = new Dictionary<string, EnvironmentSignatureConfig>(StringComparer.OrdinalIgnoreCase)
{
["staging"] = new EnvironmentSignatureConfig
{
AdditionalIssuers = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "staging@company.com" }
}
}
};
var signatures = new List<SignatureInfo>
{
new()
{
EvidenceType = "sbom",
HasSignature = true,
SignatureValid = true,
SignerIdentity = "staging@company.com"
}
};
var gate = new SignatureRequiredGate(options, _ => signatures);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(environment: "staging"));
Assert.True(result.Passed);
}
[Fact]
public async Task EvaluateAsync_InvalidCertificateChain_Fails()
{
var options = new SignatureRequiredGateOptions
{
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
{
["sbom"] = new EvidenceSignatureConfig { Required = true },
["vex"] = new EvidenceSignatureConfig { Required = false },
["attestation"] = new EvidenceSignatureConfig { Required = false }
}
};
var signatures = new List<SignatureInfo>
{
new()
{
EvidenceType = "sbom",
HasSignature = true,
SignatureValid = true,
IsKeyless = true,
HasTransparencyLogInclusion = true,
CertificateChainValid = false
}
};
var gate = new SignatureRequiredGate(options, _ => signatures);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.False(result.Passed);
}
[Fact]
public async Task EvaluateAsync_WildcardIssuerMatch_MatchesSubdomains()
{
var options = new SignatureRequiredGateOptions
{
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
{
["sbom"] = new EvidenceSignatureConfig
{
Required = true,
TrustedIssuers = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "*@*.company.com" }
},
["vex"] = new EvidenceSignatureConfig { Required = false },
["attestation"] = new EvidenceSignatureConfig { Required = false }
}
};
var signatures = new List<SignatureInfo>
{
new()
{
EvidenceType = "sbom",
HasSignature = true,
SignatureValid = true,
SignerIdentity = "build@ci.company.com"
}
};
var gate = new SignatureRequiredGate(options, _ => signatures);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.True(result.Passed);
}
}

View File

@@ -0,0 +1,268 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) 2025 StellaOps
// Sprint: SPRINT_20260112_004_BE_policy_determinization_attested_rules (DET-ATT-004)
// Task: Unit tests for VexProofGate anchor-aware mode
using System.Collections.Immutable;
using StellaOps.Policy.Gates;
using StellaOps.Policy.TrustLattice;
using VexStatus = StellaOps.Policy.Confidence.Models.VexStatus;
using Xunit;
namespace StellaOps.Policy.Tests.Gates;
public class VexProofGateTests
{
private static readonly DateTimeOffset FixedTime = new(2026, 1, 14, 12, 0, 0, TimeSpan.Zero);
private static MergeResult CreateMergeResult(VexStatus status) =>
new()
{
Status = status,
Confidence = 0.9,
HasConflicts = false,
AllClaims = ImmutableArray<ScoredClaim>.Empty,
WinningClaim = new ScoredClaim
{
SourceId = "test",
Status = status,
OriginalScore = 0.9,
AdjustedScore = 0.9,
ScopeSpecificity = 1,
Accepted = true,
Reason = "Test claim"
},
Conflicts = ImmutableArray<ConflictRecord>.Empty
};
[Fact]
public async Task EvaluateAsync_WhenDisabled_ReturnsPass()
{
// Arrange
var options = new VexProofGateOptions { Enabled = false };
var gate = new VexProofGate(options);
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
var context = new PolicyGateContext { Environment = "production" };
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
Assert.True(result.Passed);
Assert.Equal("disabled", result.Reason);
}
[Fact]
public async Task EvaluateAsync_WhenAnchorAwareModeEnabled_RequiresAnchoring()
{
// Arrange
var options = new VexProofGateOptions
{
Enabled = true,
RequireProofForNotAffected = true,
AnchorAwareMode = true,
RequireVexAnchoring = true
};
var gate = new VexProofGate(options);
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
var context = new PolicyGateContext
{
Environment = "production",
Metadata = new Dictionary<string, string>
{
["vex_proof_id"] = "proof-123",
["vex_proof_confidence_tier"] = "high",
["vex_proof_anchored"] = "false" // Not anchored
}
};
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
Assert.False(result.Passed);
Assert.Equal("vex_not_anchored", result.Reason);
}
[Fact]
public async Task EvaluateAsync_WhenAnchorAwareModeEnabled_PassesWithAnchoring()
{
// Arrange
var options = new VexProofGateOptions
{
Enabled = true,
RequireProofForNotAffected = true,
AnchorAwareMode = true,
RequireVexAnchoring = true,
RequireRekorVerification = false
};
var gate = new VexProofGate(options);
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
var context = new PolicyGateContext
{
Environment = "production",
Metadata = new Dictionary<string, string>
{
["vex_proof_id"] = "proof-123",
["vex_proof_confidence_tier"] = "high",
["vex_proof_anchored"] = "true",
["vex_proof_envelope_digest"] = "sha256:abc123"
}
};
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
Assert.True(result.Passed);
Assert.Equal("proof_valid", result.Reason);
}
[Fact]
public async Task EvaluateAsync_WhenRekorRequired_FailsWithoutRekor()
{
// Arrange
var options = new VexProofGateOptions
{
Enabled = true,
RequireProofForNotAffected = true,
AnchorAwareMode = true,
RequireVexAnchoring = true,
RequireRekorVerification = true
};
var gate = new VexProofGate(options);
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
var context = new PolicyGateContext
{
Environment = "production",
Metadata = new Dictionary<string, string>
{
["vex_proof_id"] = "proof-123",
["vex_proof_confidence_tier"] = "high",
["vex_proof_anchored"] = "true",
["vex_proof_rekor_verified"] = "false"
}
};
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
Assert.False(result.Passed);
Assert.Equal("rekor_verification_missing", result.Reason);
}
[Fact]
public async Task EvaluateAsync_WhenRekorRequired_PassesWithRekor()
{
// Arrange
var options = new VexProofGateOptions
{
Enabled = true,
RequireProofForNotAffected = true,
AnchorAwareMode = true,
RequireVexAnchoring = true,
RequireRekorVerification = true
};
var gate = new VexProofGate(options);
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
var context = new PolicyGateContext
{
Environment = "production",
Metadata = new Dictionary<string, string>
{
["vex_proof_id"] = "proof-123",
["vex_proof_confidence_tier"] = "high",
["vex_proof_anchored"] = "true",
["vex_proof_envelope_digest"] = "sha256:abc123",
["vex_proof_rekor_verified"] = "true",
["vex_proof_rekor_log_index"] = "12345678"
}
};
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
Assert.True(result.Passed);
Assert.Equal("proof_valid", result.Reason);
Assert.True(result.Details.ContainsKey("rekorLogIndex"));
}
[Fact]
public async Task EvaluateAsync_StrictAnchorAware_EnforcesAllRequirements()
{
// Arrange
var options = VexProofGateOptions.StrictAnchorAware;
var gate = new VexProofGate(options);
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
var context = new PolicyGateContext
{
Environment = "production",
Metadata = new Dictionary<string, string>
{
["vex_proof_id"] = "proof-123",
["vex_proof_confidence_tier"] = "high",
["vex_proof_all_signed"] = "true",
["vex_proof_anchored"] = "true",
["vex_proof_envelope_digest"] = "sha256:abc123",
["vex_proof_rekor_verified"] = "true",
["vex_proof_rekor_log_index"] = "12345678"
}
};
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
Assert.True(result.Passed);
Assert.Equal("proof_valid", result.Reason);
}
[Fact]
public async Task EvaluateAsync_StrictAnchorAware_FailsWithoutSignedStatements()
{
// Arrange
var options = VexProofGateOptions.StrictAnchorAware;
var gate = new VexProofGate(options);
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
var context = new PolicyGateContext
{
Environment = "production",
Metadata = new Dictionary<string, string>
{
["vex_proof_id"] = "proof-123",
["vex_proof_confidence_tier"] = "high",
["vex_proof_all_signed"] = "false", // Not signed
["vex_proof_anchored"] = "true",
["vex_proof_rekor_verified"] = "true"
}
};
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
Assert.False(result.Passed);
Assert.Equal("unsigned_statements", result.Reason);
}
[Fact]
public void StrictAnchorAware_HasExpectedDefaults()
{
// Act
var options = VexProofGateOptions.StrictAnchorAware;
// Assert
Assert.True(options.Enabled);
Assert.Equal("high", options.MinimumConfidenceTier);
Assert.True(options.RequireProofForNotAffected);
Assert.True(options.RequireProofForFixed);
Assert.True(options.RequireSignedStatements);
Assert.True(options.AnchorAwareMode);
Assert.True(options.RequireVexAnchoring);
Assert.True(options.RequireRekorVerification);
Assert.Equal(0, options.MaxAllowedConflicts);
Assert.Equal(72, options.MaxProofAgeHours);
}
}