This commit is contained in:
master
2026-01-07 10:25:34 +02:00
726 changed files with 147397 additions and 1364 deletions

View File

@@ -0,0 +1,38 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Policy.Determinization;
using StellaOps.Policy.Engine.Gates;
using StellaOps.Policy.Engine.Gates.Determinization;
using StellaOps.Policy.Engine.Policies;
using StellaOps.Policy.Engine.Subscriptions;
namespace StellaOps.Policy.Engine.DependencyInjection;
/// <summary>
/// Dependency injection extensions for determinization engine.
/// </summary>
public static class DeterminizationEngineExtensions
{
/// <summary>
/// Add determinization gate and related services to the service collection.
/// </summary>
public static IServiceCollection AddDeterminizationEngine(this IServiceCollection services)
{
// Add determinization library services
services.AddDeterminization();
// Add gate
services.TryAddSingleton<IDeterminizationGate, DeterminizationGate>();
// Add policy
services.TryAddSingleton<IDeterminizationPolicy, DeterminizationPolicy>();
// Add signal snapshot builder
services.TryAddSingleton<ISignalSnapshotBuilder, SignalSnapshotBuilder>();
// Add signal update subscription
services.TryAddSingleton<ISignalUpdateSubscription, SignalUpdateHandler>();
return services;
}
}

View File

@@ -0,0 +1,204 @@
using System.Collections.Immutable;
using System.Diagnostics.Metrics;
using Microsoft.Extensions.Logging;
using StellaOps.Policy;
using StellaOps.Policy.Determinization;
using StellaOps.Policy.Determinization.Models;
using StellaOps.Policy.Engine.Gates.Determinization;
using StellaOps.Policy.Engine.Policies;
using StellaOps.Policy.Gates;
using StellaOps.Policy.TrustLattice;
namespace StellaOps.Policy.Engine.Gates;
/// <summary>
/// Gate that evaluates CVE observations against determinization thresholds.
/// </summary>
public sealed class DeterminizationGate : IDeterminizationGate
{
private static readonly Meter Meter = new("StellaOps.Policy.Engine.Gates");
private static readonly Counter<long> EvaluationsCounter = Meter.CreateCounter<long>(
"stellaops_policy_determinization_evaluations_total",
"evaluations",
"Total determinization gate evaluations");
private static readonly Counter<long> RuleMatchesCounter = Meter.CreateCounter<long>(
"stellaops_policy_determinization_rule_matches_total",
"matches",
"Total determinization rule matches by rule name");
private readonly IDeterminizationPolicy _policy;
private readonly IUncertaintyScoreCalculator _uncertaintyCalculator;
private readonly IDecayedConfidenceCalculator _decayCalculator;
private readonly TrustScoreAggregator _trustAggregator;
private readonly ISignalSnapshotBuilder _snapshotBuilder;
private readonly ILogger<DeterminizationGate> _logger;
public DeterminizationGate(
IDeterminizationPolicy policy,
IUncertaintyScoreCalculator uncertaintyCalculator,
IDecayedConfidenceCalculator decayCalculator,
TrustScoreAggregator trustAggregator,
ISignalSnapshotBuilder snapshotBuilder,
ILogger<DeterminizationGate> logger)
{
_policy = policy;
_uncertaintyCalculator = uncertaintyCalculator;
_decayCalculator = decayCalculator;
_trustAggregator = trustAggregator;
_snapshotBuilder = snapshotBuilder;
_logger = logger;
}
public async Task<GateResult> EvaluateAsync(
MergeResult mergeResult,
PolicyGateContext context,
CancellationToken ct = default)
{
var result = await EvaluateDeterminizationAsync(mergeResult, context, ct);
return new GateResult
{
GateName = "DeterminizationGate",
Passed = result.Passed,
Reason = result.Reason,
Details = BuildDetails(result)
};
}
public async Task<DeterminizationGateResult> EvaluateDeterminizationAsync(
MergeResult mergeResult,
PolicyGateContext context,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(mergeResult);
ArgumentNullException.ThrowIfNull(context);
// Extract CVE ID and PURL from context
var cveId = context.CveId ?? throw new ArgumentException("CveId is required", nameof(context));
var purl = context.SubjectKey ?? throw new ArgumentException("SubjectKey is required", nameof(context));
// 1. Build signal snapshot for the CVE/component
var snapshot = await _snapshotBuilder.BuildAsync(cveId, purl, ct);
// 2. Calculate uncertainty
var uncertainty = _uncertaintyCalculator.Calculate(snapshot);
// 3. Calculate decay
var lastUpdate = DetermineLastSignalUpdate(snapshot);
var decay = _decayCalculator.Calculate(
baseConfidence: 1.0,
ageDays: (snapshot.SnapshotAt - lastUpdate).TotalDays,
halfLifeDays: 30,
floor: 0.1);
// 4. Calculate trust score
var trustScore = _trustAggregator.Aggregate(snapshot, uncertainty);
// 5. Parse environment from context
var environment = ParseEnvironment(context.Environment);
// 6. Build determinization context
var determCtx = new DeterminizationContext
{
SignalSnapshot = snapshot,
UncertaintyScore = uncertainty,
Decay = new ObservationDecay
{
LastSignalUpdate = lastUpdate,
AgeDays = (snapshot.SnapshotAt - lastUpdate).TotalDays,
DecayedMultiplier = decay,
IsStale = decay < 0.5
},
TrustScore = trustScore,
Environment = environment
};
// 7. Evaluate policy
var policyResult = _policy.Evaluate(determCtx);
// 8. Record metrics
EvaluationsCounter.Add(1,
new KeyValuePair<string, object?>("status", policyResult.Status.ToString()),
new KeyValuePair<string, object?>("environment", environment.ToString()),
new KeyValuePair<string, object?>("passed", policyResult.Status is PolicyVerdictStatus.Pass or PolicyVerdictStatus.GuardedPass));
if (policyResult.MatchedRule is not null)
{
RuleMatchesCounter.Add(1,
new KeyValuePair<string, object?>("rule", policyResult.MatchedRule),
new KeyValuePair<string, object?>("status", policyResult.Status.ToString()));
}
_logger.LogInformation(
"DeterminizationGate evaluated CVE {CveId} on {Purl}: status={Status}, entropy={Entropy:F3}, trust={Trust:F3}, rule={Rule}",
cveId,
purl,
policyResult.Status,
uncertainty.Entropy,
trustScore,
policyResult.MatchedRule);
return new DeterminizationGateResult
{
Passed = policyResult.Status is PolicyVerdictStatus.Pass or PolicyVerdictStatus.GuardedPass,
Status = policyResult.Status,
Reason = policyResult.Reason,
GuardRails = policyResult.GuardRails,
UncertaintyScore = uncertainty,
Decay = determCtx.Decay,
TrustScore = trustScore,
MatchedRule = policyResult.MatchedRule,
Metadata = null
};
}
private static DateTimeOffset DetermineLastSignalUpdate(SignalSnapshot snapshot)
{
var timestamps = new List<DateTimeOffset>();
if (snapshot.Epss.QueriedAt.HasValue) timestamps.Add(snapshot.Epss.QueriedAt.Value);
if (snapshot.Vex.QueriedAt.HasValue) timestamps.Add(snapshot.Vex.QueriedAt.Value);
if (snapshot.Reachability.QueriedAt.HasValue) timestamps.Add(snapshot.Reachability.QueriedAt.Value);
if (snapshot.Runtime.QueriedAt.HasValue) timestamps.Add(snapshot.Runtime.QueriedAt.Value);
if (snapshot.Backport.QueriedAt.HasValue) timestamps.Add(snapshot.Backport.QueriedAt.Value);
if (snapshot.Sbom.QueriedAt.HasValue) timestamps.Add(snapshot.Sbom.QueriedAt.Value);
if (snapshot.Cvss.QueriedAt.HasValue) timestamps.Add(snapshot.Cvss.QueriedAt.Value);
return timestamps.Count > 0 ? timestamps.Max() : snapshot.SnapshotAt;
}
private static DeploymentEnvironment ParseEnvironment(string environment) =>
environment.ToLowerInvariant() switch
{
"production" or "prod" => DeploymentEnvironment.Production,
"staging" or "stage" => DeploymentEnvironment.Staging,
"testing" or "test" => DeploymentEnvironment.Testing,
"development" or "dev" => DeploymentEnvironment.Development,
_ => DeploymentEnvironment.Development
};
private static ImmutableDictionary<string, object> BuildDetails(DeterminizationGateResult result)
{
var builder = ImmutableDictionary.CreateBuilder<string, object>();
builder["uncertainty_entropy"] = result.UncertaintyScore.Entropy;
builder["uncertainty_tier"] = result.UncertaintyScore.Tier.ToString();
builder["uncertainty_completeness"] = result.UncertaintyScore.Completeness;
builder["decay_multiplier"] = result.Decay.DecayedMultiplier;
builder["decay_is_stale"] = result.Decay.IsStale;
builder["decay_age_days"] = result.Decay.AgeDays;
builder["trust_score"] = result.TrustScore;
if (result.MatchedRule is not null)
builder["matched_rule"] = result.MatchedRule;
if (result.GuardRails is not null)
{
builder["guardrails_monitoring"] = result.GuardRails.EnableMonitoring;
if (result.GuardRails.ReevalAfter.HasValue)
builder["guardrails_reeval_after"] = result.GuardRails.ReevalAfter.Value.ToString();
}
return builder.ToImmutable();
}
}

View File

@@ -0,0 +1,21 @@
using StellaOps.Policy.Determinization.Models;
namespace StellaOps.Policy.Engine.Gates.Determinization;
/// <summary>
/// Builds signal snapshots for determinization evaluation.
/// </summary>
public interface ISignalSnapshotBuilder
{
/// <summary>
/// Build a signal snapshot for the given CVE/component pair.
/// </summary>
/// <param name="cveId">CVE identifier.</param>
/// <param name="componentPurl">Component PURL.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Signal snapshot containing all available signals.</returns>
Task<SignalSnapshot> BuildAsync(
string cveId,
string componentPurl,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,95 @@
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Determinization.Models;
namespace StellaOps.Policy.Engine.Gates.Determinization;
/// <summary>
/// Builds signal snapshots for determinization evaluation by querying signal repositories.
/// </summary>
public sealed class SignalSnapshotBuilder : ISignalSnapshotBuilder
{
private readonly ISignalRepository _signalRepository;
private readonly TimeProvider _timeProvider;
private readonly ILogger<SignalSnapshotBuilder> _logger;
public SignalSnapshotBuilder(
ISignalRepository signalRepository,
TimeProvider timeProvider,
ILogger<SignalSnapshotBuilder> logger)
{
_signalRepository = signalRepository;
_timeProvider = timeProvider;
_logger = logger;
}
public async Task<SignalSnapshot> BuildAsync(
string cveId,
string componentPurl,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
ArgumentException.ThrowIfNullOrWhiteSpace(componentPurl);
_logger.LogDebug(
"Building signal snapshot for CVE {CveId} on {Purl}",
cveId,
componentPurl);
var snapshotAt = _timeProvider.GetUtcNow();
var subjectKey = BuildSubjectKey(cveId, componentPurl);
// Query all signals in parallel
var signalsTask = _signalRepository.GetSignalsAsync(subjectKey, ct);
var signals = await signalsTask;
// Build snapshot from retrieved signals
var snapshot = SignalSnapshot.Empty(cveId, componentPurl, snapshotAt);
foreach (var signal in signals)
{
snapshot = ApplySignal(snapshot, signal);
}
_logger.LogDebug(
"Built signal snapshot for CVE {CveId} on {Purl}: {SignalCount} signals present",
cveId,
componentPurl,
signals.Count);
return snapshot;
}
private static string BuildSubjectKey(string cveId, string componentPurl)
=> $"{cveId}::{componentPurl}";
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
return snapshot;
}
}
/// <summary>
/// Repository for retrieving signals.
/// </summary>
public interface ISignalRepository
{
/// <summary>
/// Get all signals for the given subject key.
/// </summary>
Task<IReadOnlyList<Signal>> GetSignalsAsync(string subjectKey, CancellationToken ct = default);
}
/// <summary>
/// Represents a signal retrieved from storage.
/// </summary>
public sealed record Signal
{
public required string Type { get; init; }
public required string SubjectKey { get; init; }
public required DateTimeOffset ObservedAt { get; init; }
public required object? Evidence { get; init; }
}

View File

@@ -0,0 +1,57 @@
using System.Collections.Immutable;
using StellaOps.Policy;
using StellaOps.Policy.Determinization.Models;
using StellaOps.Policy.Gates;
namespace StellaOps.Policy.Engine.Gates;
/// <summary>
/// Gate that evaluates determinization state and uncertainty for findings.
/// </summary>
public interface IDeterminizationGate : IPolicyGate
{
/// <summary>
/// Evaluate a finding against determinization thresholds.
/// </summary>
/// <param name="mergeResult">The merge result from trust lattice.</param>
/// <param name="context">Policy gate context.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Determinization-specific gate evaluation result.</returns>
Task<DeterminizationGateResult> EvaluateDeterminizationAsync(
TrustLattice.MergeResult mergeResult,
PolicyGateContext context,
CancellationToken ct = default);
}
/// <summary>
/// Result of determinization gate evaluation.
/// </summary>
public sealed record DeterminizationGateResult
{
/// <summary>Whether the gate passed.</summary>
public required bool Passed { get; init; }
/// <summary>Policy verdict status.</summary>
public required PolicyVerdictStatus Status { get; init; }
/// <summary>Reason for the decision.</summary>
public required string Reason { get; init; }
/// <summary>Guardrails if GuardedPass.</summary>
public GuardRails? GuardRails { get; init; }
/// <summary>Uncertainty score.</summary>
public required UncertaintyScore UncertaintyScore { get; init; }
/// <summary>Decay information.</summary>
public required ObservationDecay Decay { get; init; }
/// <summary>Trust score.</summary>
public required double TrustScore { get; init; }
/// <summary>Rule that matched.</summary>
public string? MatchedRule { get; init; }
/// <summary>Additional metadata for audit.</summary>
public ImmutableDictionary<string, object>? Metadata { get; init; }
}

View File

@@ -1,3 +1,6 @@
using StellaOps.Facet;
using StellaOps.Policy.Gates;
namespace StellaOps.Policy.Engine.Gates;
/// <summary>
@@ -35,6 +38,11 @@ public sealed class PolicyGateOptions
/// </summary>
public OverrideOptions Override { get; set; } = new();
/// <summary>
/// Facet quota gate options.
/// </summary>
public FacetQuotaGateOptions FacetQuota { get; set; } = new();
/// <summary>
/// Whether gates are enabled.
/// </summary>
@@ -139,3 +147,72 @@ public sealed class OverrideOptions
/// </summary>
public int MinJustificationLength { get; set; } = 20;
}
/// <summary>
/// Configuration options for the facet drift quota gate.
/// Sprint: SPRINT_20260105_002_003_FACET (QTA-011)
/// </summary>
public sealed class FacetQuotaGateOptions
{
/// <summary>
/// Whether facet quota enforcement is enabled.
/// When disabled, the facet quota gate will skip evaluation.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Default action when quota is exceeded and no facet-specific action is defined.
/// </summary>
public QuotaExceededAction DefaultAction { get; set; } = QuotaExceededAction.Warn;
/// <summary>
/// Default maximum churn percentage allowed before quota enforcement triggers.
/// </summary>
public decimal DefaultMaxChurnPercent { get; set; } = 10.0m;
/// <summary>
/// Default maximum number of changed files allowed before quota enforcement triggers.
/// </summary>
public int DefaultMaxChangedFiles { get; set; } = 50;
/// <summary>
/// Whether to skip quota check when no baseline seal is found.
/// </summary>
public bool SkipIfNoBaseline { get; set; } = true;
/// <summary>
/// SLA in days for VEX draft review when action is RequireVex.
/// </summary>
public int VexReviewSlaDays { get; set; } = 7;
/// <summary>
/// Per-facet quota overrides by facet ID.
/// </summary>
public Dictionary<string, FacetQuotaOverride> FacetOverrides { get; set; } = new();
}
/// <summary>
/// Per-facet quota configuration override.
/// </summary>
public sealed class FacetQuotaOverride
{
/// <summary>
/// Maximum churn percentage for this facet.
/// </summary>
public decimal? MaxChurnPercent { get; set; }
/// <summary>
/// Maximum changed files for this facet.
/// </summary>
public int? MaxChangedFiles { get; set; }
/// <summary>
/// Action when this facet's quota is exceeded.
/// </summary>
public QuotaExceededAction? Action { get; set; }
/// <summary>
/// Allowlist globs for files that don't count against quota.
/// </summary>
public List<string> AllowlistGlobs { get; set; } = new();
}

View File

@@ -0,0 +1,112 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Determinization;
using StellaOps.Policy.Determinization.Models;
namespace StellaOps.Policy.Engine.Policies;
/// <summary>
/// Implements allow/quarantine/escalate logic per advisory specification.
/// </summary>
public sealed class DeterminizationPolicy : IDeterminizationPolicy
{
private readonly DeterminizationOptions _options;
private readonly DeterminizationRuleSet _ruleSet;
private readonly ILogger<DeterminizationPolicy> _logger;
public DeterminizationPolicy(
IOptions<DeterminizationOptions> options,
ILogger<DeterminizationPolicy> logger)
{
_options = options.Value;
_ruleSet = DeterminizationRuleSet.Default(_options);
_logger = logger;
}
public DeterminizationResult Evaluate(DeterminizationContext ctx)
{
ArgumentNullException.ThrowIfNull(ctx);
// Get environment-specific thresholds
var thresholds = GetEnvironmentThresholds(ctx.Environment);
// Evaluate rules in priority order
foreach (var rule in _ruleSet.Rules.OrderBy(r => r.Priority))
{
if (rule.Condition(ctx, thresholds))
{
var result = rule.Action(ctx, thresholds);
result = result with { MatchedRule = rule.Name };
_logger.LogDebug(
"Rule {RuleName} matched for CVE {CveId}: {Status}",
rule.Name,
ctx.SignalSnapshot.Cve,
result.Status);
return result;
}
}
// Default: Deferred (no rule matched, needs more evidence)
return DeterminizationResult.Deferred(
"No determinization rule matched; additional evidence required");
}
private EnvironmentThresholds GetEnvironmentThresholds(DeploymentEnvironment env)
{
return env switch
{
DeploymentEnvironment.Production => DefaultEnvironmentThresholds.Production,
DeploymentEnvironment.Staging => DefaultEnvironmentThresholds.Staging,
DeploymentEnvironment.Testing => DefaultEnvironmentThresholds.Development,
DeploymentEnvironment.Development => DefaultEnvironmentThresholds.Development,
_ => DefaultEnvironmentThresholds.Development
};
}
}
/// <summary>
/// Environment-specific thresholds for determinization decisions.
/// </summary>
public sealed record EnvironmentThresholds
{
public required DeploymentEnvironment Environment { get; init; }
public required double MinConfidenceForNotAffected { get; init; }
public required double MaxEntropyForAllow { get; init; }
public required double EpssBlockThreshold { get; init; }
public required bool RequireReachabilityForAllow { get; init; }
}
/// <summary>
/// Default environment thresholds per advisory.
/// </summary>
public static class DefaultEnvironmentThresholds
{
public static EnvironmentThresholds Production => new()
{
Environment = DeploymentEnvironment.Production,
MinConfidenceForNotAffected = 0.75,
MaxEntropyForAllow = 0.3,
EpssBlockThreshold = 0.3,
RequireReachabilityForAllow = true
};
public static EnvironmentThresholds Staging => new()
{
Environment = DeploymentEnvironment.Staging,
MinConfidenceForNotAffected = 0.60,
MaxEntropyForAllow = 0.5,
EpssBlockThreshold = 0.4,
RequireReachabilityForAllow = true
};
public static EnvironmentThresholds Development => new()
{
Environment = DeploymentEnvironment.Development,
MinConfidenceForNotAffected = 0.40,
MaxEntropyForAllow = 0.7,
EpssBlockThreshold = 0.6,
RequireReachabilityForAllow = false
};
}

View File

@@ -0,0 +1,220 @@
using StellaOps.Policy;
using StellaOps.Policy.Determinization;
using StellaOps.Policy.Determinization.Models;
namespace StellaOps.Policy.Engine.Policies;
/// <summary>
/// Rule set for determinization policy evaluation.
/// Rules are evaluated in priority order (lower = higher priority).
/// </summary>
public sealed class DeterminizationRuleSet
{
public IReadOnlyList<DeterminizationRule> Rules { get; }
private DeterminizationRuleSet(IReadOnlyList<DeterminizationRule> rules)
{
Rules = rules;
}
/// <summary>
/// Creates the default rule set per advisory specification.
/// </summary>
public static DeterminizationRuleSet Default(DeterminizationOptions options) =>
new(new List<DeterminizationRule>
{
// Rule 1: Escalate if runtime evidence shows vulnerable code loaded
new DeterminizationRule
{
Name = "RuntimeEscalation",
Priority = 10,
Condition = (ctx, _) =>
ctx.SignalSnapshot.Runtime.HasValue &&
ctx.SignalSnapshot.Runtime.Value!.ObservedLoaded,
Action = (ctx, _) =>
DeterminizationResult.Escalated(
"Runtime evidence shows vulnerable code loaded in memory")
},
// Rule 2: Quarantine if EPSS exceeds threshold
new DeterminizationRule
{
Name = "EpssQuarantine",
Priority = 20,
Condition = (ctx, thresholds) =>
ctx.SignalSnapshot.Epss.HasValue &&
ctx.SignalSnapshot.Epss.Value!.Score >= thresholds.EpssBlockThreshold,
Action = (ctx, thresholds) =>
DeterminizationResult.Quarantined(
$"EPSS score {ctx.SignalSnapshot.Epss.Value!.Score:P1} exceeds threshold {thresholds.EpssBlockThreshold:P1}")
},
// Rule 3: Quarantine if proven reachable
new DeterminizationRule
{
Name = "ReachabilityQuarantine",
Priority = 25,
Condition = (ctx, _) =>
ctx.SignalSnapshot.Reachability.HasValue &&
ctx.SignalSnapshot.Reachability.Value!.IsReachable,
Action = (ctx, _) =>
DeterminizationResult.Quarantined(
$"Vulnerable code is reachable via call graph analysis")
},
// Rule 4: Block high entropy in production
new DeterminizationRule
{
Name = "ProductionEntropyBlock",
Priority = 30,
Condition = (ctx, thresholds) =>
ctx.Environment == DeploymentEnvironment.Production &&
ctx.UncertaintyScore.Entropy > thresholds.MaxEntropyForAllow,
Action = (ctx, thresholds) =>
DeterminizationResult.Quarantined(
$"High uncertainty (entropy={ctx.UncertaintyScore.Entropy:F2}) exceeds production threshold ({thresholds.MaxEntropyForAllow:F2})")
},
// Rule 5: Defer if evidence is stale
new DeterminizationRule
{
Name = "StaleEvidenceDefer",
Priority = 40,
Condition = (ctx, _) => ctx.Decay.IsStale,
Action = (ctx, _) =>
DeterminizationResult.Deferred(
$"Evidence is stale (last update: {ctx.Decay.LastSignalUpdate:u}, age: {ctx.Decay.AgeDays:F1} days)")
},
// Rule 6: Guarded allow for uncertain observations in non-prod
new DeterminizationRule
{
Name = "GuardedAllowNonProd",
Priority = 50,
Condition = (ctx, _) =>
ctx.TrustScore < 0.5 &&
ctx.UncertaintyScore.Entropy > 0.4 &&
ctx.Environment != DeploymentEnvironment.Production,
Action = (ctx, _) =>
DeterminizationResult.GuardedPass(
$"Uncertain observation (entropy={ctx.UncertaintyScore.Entropy:F2}, trust={ctx.TrustScore:F2}) allowed with guardrails in {ctx.Environment}",
BuildGuardrails(ctx, GuardRailsLevel.Moderate))
},
// Rule 7: Allow if unreachable with high confidence
new DeterminizationRule
{
Name = "UnreachableAllow",
Priority = 60,
Condition = (ctx, thresholds) =>
ctx.SignalSnapshot.Reachability.HasValue &&
!ctx.SignalSnapshot.Reachability.Value!.IsReachable &&
ctx.SignalSnapshot.Reachability.Value.Confidence >= thresholds.MinConfidenceForNotAffected,
Action = (ctx, _) =>
DeterminizationResult.Allowed(
$"Vulnerable code is unreachable (confidence={ctx.SignalSnapshot.Reachability.Value!.Confidence:P0})")
},
// Rule 8: Allow if VEX not_affected with trusted issuer
new DeterminizationRule
{
Name = "VexNotAffectedAllow",
Priority = 65,
Condition = (ctx, thresholds) =>
ctx.SignalSnapshot.Vex.HasValue &&
ctx.SignalSnapshot.Vex.Value!.IsNotAffected &&
ctx.SignalSnapshot.Vex.Value.IssuerTrust >= thresholds.MinConfidenceForNotAffected,
Action = (ctx, _) =>
DeterminizationResult.Allowed(
$"VEX statement indicates not_affected (trust={ctx.SignalSnapshot.Vex.Value!.IssuerTrust:P0})")
},
// Rule 9: Allow if sufficient evidence and low entropy
new DeterminizationRule
{
Name = "SufficientEvidenceAllow",
Priority = 70,
Condition = (ctx, thresholds) =>
ctx.UncertaintyScore.Entropy <= thresholds.MaxEntropyForAllow &&
ctx.TrustScore >= thresholds.MinConfidenceForNotAffected,
Action = (ctx, _) =>
DeterminizationResult.Allowed(
$"Sufficient evidence (entropy={ctx.UncertaintyScore.Entropy:F2}, trust={ctx.TrustScore:F2}) for confident determination")
},
// Rule 10: Guarded allow for moderate uncertainty
new DeterminizationRule
{
Name = "GuardedAllowModerateUncertainty",
Priority = 80,
Condition = (ctx, _) =>
ctx.UncertaintyScore.Tier <= UncertaintyTier.Moderate &&
ctx.TrustScore >= 0.4,
Action = (ctx, _) =>
DeterminizationResult.GuardedPass(
$"Moderate uncertainty (tier={ctx.UncertaintyScore.Tier}, trust={ctx.TrustScore:F2}) allowed with monitoring",
BuildGuardrails(ctx, GuardRailsLevel.Light))
},
// Rule 11: Default - require more evidence
new DeterminizationRule
{
Name = "DefaultDefer",
Priority = 100,
Condition = (_, _) => true,
Action = (ctx, _) =>
DeterminizationResult.Deferred(
$"Insufficient evidence for determination (entropy={ctx.UncertaintyScore.Entropy:F2}, tier={ctx.UncertaintyScore.Tier})")
}
});
private enum GuardRailsLevel { Light, Moderate, Strict }
private static GuardRails BuildGuardrails(DeterminizationContext ctx, GuardRailsLevel level) =>
level switch
{
GuardRailsLevel.Light => new GuardRails
{
EnableMonitoring = true,
RestrictToNonProd = false,
RequireApproval = false,
ReevalAfter = TimeSpan.FromDays(14),
Notes = $"Light guardrails: entropy={ctx.UncertaintyScore.Entropy:F2}, trust={ctx.TrustScore:F2}, env={ctx.Environment}"
},
GuardRailsLevel.Moderate => new GuardRails
{
EnableMonitoring = true,
RestrictToNonProd = ctx.Environment == DeploymentEnvironment.Production,
RequireApproval = false,
ReevalAfter = TimeSpan.FromDays(7),
Notes = $"Moderate guardrails: entropy={ctx.UncertaintyScore.Entropy:F2}, trust={ctx.TrustScore:F2}, env={ctx.Environment}"
},
GuardRailsLevel.Strict => new GuardRails
{
EnableMonitoring = true,
RestrictToNonProd = true,
RequireApproval = true,
ReevalAfter = TimeSpan.FromDays(3),
Notes = $"Strict guardrails: entropy={ctx.UncertaintyScore.Entropy:F2}, trust={ctx.TrustScore:F2}, env={ctx.Environment}"
},
_ => GuardRails.Default()
};
}
/// <summary>
/// A single determinization rule.
/// </summary>
public sealed record DeterminizationRule
{
/// <summary>Rule name for audit/logging.</summary>
public required string Name { get; init; }
/// <summary>Priority (lower = evaluated first).</summary>
public required int Priority { get; init; }
/// <summary>Condition function.</summary>
public required Func<DeterminizationContext, EnvironmentThresholds, bool> Condition { get; init; }
/// <summary>Action function.</summary>
public required Func<DeterminizationContext, EnvironmentThresholds, DeterminizationResult> Action { get; init; }
}

View File

@@ -0,0 +1,53 @@
using StellaOps.Policy;
using StellaOps.Policy.Determinization.Models;
namespace StellaOps.Policy.Engine.Policies;
/// <summary>
/// Policy for evaluating determinization decisions (allow/quarantine/escalate).
/// </summary>
public interface IDeterminizationPolicy
{
/// <summary>
/// Evaluate a CVE observation against determinization rules.
/// </summary>
/// <param name="context">Determinization context.</param>
/// <returns>Policy decision result.</returns>
DeterminizationResult Evaluate(DeterminizationContext context);
}
/// <summary>
/// Result of determinization policy evaluation.
/// </summary>
public sealed record DeterminizationResult
{
/// <summary>Policy verdict status.</summary>
public required PolicyVerdictStatus Status { get; init; }
/// <summary>Explanation of the decision.</summary>
public required string Reason { get; init; }
/// <summary>Guardrails if GuardedPass.</summary>
public GuardRails? GuardRails { get; init; }
/// <summary>Rule that matched.</summary>
public string? MatchedRule { get; init; }
/// <summary>Suggested observation state.</summary>
public ObservationState? SuggestedState { get; init; }
public static DeterminizationResult Allowed(string reason) =>
new() { Status = PolicyVerdictStatus.Pass, Reason = reason };
public static DeterminizationResult GuardedPass(string reason, GuardRails guardRails) =>
new() { Status = PolicyVerdictStatus.GuardedPass, Reason = reason, GuardRails = guardRails };
public static DeterminizationResult Quarantined(string reason, PolicyVerdictStatus status = PolicyVerdictStatus.Blocked) =>
new() { Status = status, Reason = reason };
public static DeterminizationResult Escalated(string reason, PolicyVerdictStatus status = PolicyVerdictStatus.Escalated) =>
new() { Status = status, Reason = reason };
public static DeterminizationResult Deferred(string reason, PolicyVerdictStatus status = PolicyVerdictStatus.Deferred) =>
new() { Status = status, Reason = reason };
}

View File

@@ -27,6 +27,7 @@
<ProjectReference Include="../../Router/__Libraries/StellaOps.Messaging/StellaOps.Messaging.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Provcache/StellaOps.Provcache.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Policy.Determinization/StellaOps.Policy.Determinization.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Policy.Exceptions/StellaOps.Policy.Exceptions.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Policy.Unknowns/StellaOps.Policy.Unknowns.csproj" />
<ProjectReference Include="../StellaOps.PolicyDsl/StellaOps.PolicyDsl.csproj" />

View File

@@ -0,0 +1,44 @@
using StellaOps.Policy.Determinization.Models;
namespace StellaOps.Policy.Engine.Subscriptions;
/// <summary>
/// Events for signal updates that trigger re-evaluation.
/// </summary>
public static class DeterminizationEventTypes
{
public const string EpssUpdated = "epss.updated";
public const string VexUpdated = "vex.updated";
public const string ReachabilityUpdated = "reachability.updated";
public const string RuntimeUpdated = "runtime.updated";
public const string BackportUpdated = "backport.updated";
public const string ObservationStateChanged = "observation.state_changed";
}
/// <summary>
/// Event published when a signal is updated.
/// </summary>
public sealed record SignalUpdatedEvent
{
public required string EventType { get; init; }
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>
/// Event published when observation state changes.
/// </summary>
public sealed record ObservationStateChangedEvent
{
public required Guid ObservationId { get; init; }
public required string CveId { get; init; }
public required string Purl { get; init; }
public required ObservationState PreviousState { get; init; }
public required ObservationState NewState { get; init; }
public required string Reason { get; init; }
public required DateTimeOffset ChangedAt { get; init; }
}

View File

@@ -0,0 +1,12 @@
namespace StellaOps.Policy.Engine.Subscriptions;
/// <summary>
/// Handler for signal update events.
/// </summary>
public interface ISignalUpdateSubscription
{
/// <summary>
/// Handle a signal update and re-evaluate affected observations.
/// </summary>
Task HandleAsync(SignalUpdatedEvent evt, CancellationToken ct = default);
}

View File

@@ -0,0 +1,113 @@
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Determinization.Models;
using StellaOps.Policy.Engine.Gates;
namespace StellaOps.Policy.Engine.Subscriptions;
/// <summary>
/// Implementation of signal update handling.
/// </summary>
public sealed class SignalUpdateHandler : ISignalUpdateSubscription
{
private readonly IObservationRepository _observations;
private readonly IDeterminizationGate _gate;
private readonly IEventPublisher _eventPublisher;
private readonly ILogger<SignalUpdateHandler> _logger;
public SignalUpdateHandler(
IObservationRepository observations,
IDeterminizationGate gate,
IEventPublisher eventPublisher,
ILogger<SignalUpdateHandler> logger)
{
_observations = observations;
_gate = gate;
_eventPublisher = eventPublisher;
_logger = logger;
}
public async Task HandleAsync(SignalUpdatedEvent evt, CancellationToken ct = default)
{
_logger.LogInformation(
"Processing signal update: {EventType} for CVE {CveId} on {Purl}",
evt.EventType,
evt.CveId,
evt.Purl);
// Find observations affected by this signal
var affected = await _observations.FindByCveAndPurlAsync(evt.CveId, evt.Purl, ct);
foreach (var obs in affected)
{
try
{
await ReEvaluateObservationAsync(obs, evt, ct);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to re-evaluate observation {ObservationId} after signal update",
obs.Id);
}
}
}
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}",
obs.Id,
trigger.EventType);
await Task.CompletedTask;
}
}
/// <summary>
/// Repository for CVE observations.
/// </summary>
public interface IObservationRepository
{
/// <summary>
/// Find observations by CVE ID and component PURL.
/// </summary>
Task<IReadOnlyList<CveObservation>> FindByCveAndPurlAsync(
string cveId,
string purl,
CancellationToken ct = default);
}
/// <summary>
/// Event publisher abstraction.
/// </summary>
public interface IEventPublisher
{
/// <summary>
/// Publish an event.
/// </summary>
Task PublishAsync<TEvent>(TEvent evt, CancellationToken ct = default)
where TEvent : class;
}
/// <summary>
/// CVE observation model.
/// </summary>
public sealed record CveObservation
{
public required Guid Id { get; init; }
public required string CveId { get; init; }
public required string SubjectPurl { get; init; }
public required ObservationState State { get; init; }
public required DateTimeOffset ObservedAt { get; init; }
}