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; }
}

View File

@@ -90,7 +90,7 @@ public sealed record GateEvaluationJob
/// <summary>
/// Background service that processes gate evaluation jobs from the queue.
/// Orchestrates: image analysis drift delta computation gate evaluation.
/// Orchestrates: image analysis -> drift delta computation -> gate evaluation.
/// </summary>
public sealed class GateEvaluationWorker : BackgroundService
{

View File

@@ -0,0 +1,171 @@
# StellaOps.Policy.Determinization - Agent Guide
## Module Overview
The **Determinization** library handles CVEs that arrive without complete evidence (EPSS, VEX, reachability). It treats unknown observations as probabilistic with entropy-weighted trust that matures as evidence arrives.
**Key Concepts:**
- `ObservationState`: Lifecycle state for CVE observations (PendingDeterminization, Determined, Disputed, etc.)
- `SignalState<T>`: Null-aware wrapper distinguishing "not queried" from "queried but absent"
- `UncertaintyScore`: Knowledge completeness measurement (high entropy = missing signals)
- `ObservationDecay`: Time-based confidence decay with configurable half-life
- `GuardRails`: Monitoring requirements when allowing uncertain observations
## Directory Structure
```
src/Policy/__Libraries/StellaOps.Policy.Determinization/
├── Models/ # Core data models
│ ├── ObservationState.cs
│ ├── SignalState.cs
│ ├── SignalSnapshot.cs
│ ├── UncertaintyScore.cs
│ ├── ObservationDecay.cs
│ ├── GuardRails.cs
│ └── DeterminizationContext.cs
├── Evidence/ # Signal evidence types
│ ├── EpssEvidence.cs
│ ├── VexClaimSummary.cs
│ ├── ReachabilityEvidence.cs
│ └── ...
├── Scoring/ # Calculation services
│ ├── UncertaintyScoreCalculator.cs
│ ├── DecayedConfidenceCalculator.cs
│ ├── TrustScoreAggregator.cs
│ └── SignalWeights.cs
├── Policies/ # Policy rules (in Policy.Engine)
└── DeterminizationOptions.cs
```
## Key Patterns
### 1. SignalState<T> Usage
Always use `SignalState<T>` to wrap signal values:
```csharp
// Good - explicit status
var epss = SignalState<EpssEvidence>.WithValue(evidence, queriedAt, "first.org");
var vex = SignalState<VexClaimSummary>.Absent(queriedAt, "vendor");
var reach = SignalState<ReachabilityEvidence>.NotQueried();
var failed = SignalState<CvssEvidence>.Failed("Timeout");
// Bad - nullable without status
EpssEvidence? epss = null; // Can't tell if not queried or absent
```
### 2. Uncertainty Calculation
Entropy = 1 - (weighted present signals / max weight):
```csharp
// All signals present = 0.0 entropy (fully certain)
// No signals present = 1.0 entropy (fully uncertain)
// Formula uses configurable weights per signal type
```
### 3. Decay Calculation
Exponential decay with floor:
```csharp
decayed = max(floor, exp(-ln(2) * age_days / half_life_days))
// Default: 14-day half-life, 0.35 floor
// After 14 days: ~50% confidence
// After 28 days: ~35% confidence (floor)
```
### 4. Policy Rules
Rules evaluate in priority order (lower = first):
| Priority | Rule | Outcome |
|----------|------|---------|
| 10 | Runtime shows loaded | Escalated |
| 20 | EPSS >= threshold | Blocked |
| 25 | Proven reachable | Blocked |
| 30 | High entropy in prod | Blocked |
| 40 | Evidence stale | Deferred |
| 50 | Uncertain + non-prod | GuardedPass |
| 60 | Unreachable + confident | Pass |
| 70 | Sufficient evidence | Pass |
| 100 | Default | Deferred |
## Testing Guidelines
### Unit Tests Required
1. `SignalState<T>` factory methods
2. `UncertaintyScoreCalculator` entropy bounds [0.0, 1.0]
3. `DecayedConfidenceCalculator` half-life formula
4. Policy rule priority ordering
5. State transition logic
### Property Tests
- Entropy always in [0.0, 1.0]
- Decay monotonically decreasing with age
- Same snapshot produces same uncertainty
### Integration Tests
- DI registration with configuration
- Signal snapshot building
- Policy gate evaluation
## Configuration
```yaml
Determinization:
EpssQuarantineThreshold: 0.4
GuardedAllowScoreThreshold: 0.5
GuardedAllowEntropyThreshold: 0.4
ProductionBlockEntropyThreshold: 0.3
DecayHalfLifeDays: 14
DecayFloor: 0.35
GuardedReviewIntervalDays: 7
MaxGuardedDurationDays: 30
SignalWeights:
Vex: 0.25
Epss: 0.15
Reachability: 0.25
Runtime: 0.15
Backport: 0.10
SbomLineage: 0.10
```
## Common Pitfalls
1. **Don't confuse EntropySignal with UncertaintyScore**: `EntropySignal` measures code complexity; `UncertaintyScore` measures knowledge completeness.
2. **Always inject TimeProvider**: Never use `DateTime.UtcNow` directly for decay calculations.
3. **Normalize weights before calculation**: Call `SignalWeights.Normalize()` to ensure weights sum to 1.0.
4. **Check signal status before accessing value**: `signal.HasValue` must be true before using `signal.Value!`.
5. **Handle all ObservationStates**: Switch expressions must be exhaustive.
## Dependencies
- `StellaOps.Policy` (PolicyVerdictStatus, existing confidence models)
- `System.Collections.Immutable` (ImmutableArray for collections)
- `Microsoft.Extensions.Options` (configuration)
- `Microsoft.Extensions.Logging` (logging)
## Related Modules
- **Policy.Engine**: DeterminizationGate integrates with policy pipeline
- **Feedser**: Signal attachers emit SignalState<T>
- **VexLens**: VEX updates emit SignalUpdatedEvent
- **Graph**: CVE nodes carry ObservationState and UncertaintyScore
- **Findings**: Observation persistence and audit trail
## Sprint References
- SPRINT_20260106_001_001_LB: Core models
- SPRINT_20260106_001_002_LB: Scoring services
- SPRINT_20260106_001_003_POLICY: Policy integration
- SPRINT_20260106_001_004_BE: Backend integration
- SPRINT_20260106_001_005_FE: Frontend UI

View File

@@ -0,0 +1,40 @@
namespace StellaOps.Policy.Determinization;
/// <summary>
/// Configuration options for the Determinization subsystem.
/// </summary>
public sealed record DeterminizationOptions
{
/// <summary>Default section name in appsettings.json.</summary>
public const string SectionName = "Determinization";
/// <summary>Signal weights for entropy calculation (default: advisory-recommended weights).</summary>
public Scoring.SignalWeights SignalWeights { get; init; } = Scoring.SignalWeights.Default;
/// <summary>Prior distribution for missing signals (default: Conservative).</summary>
public Scoring.PriorDistribution PriorDistribution { get; init; } = Scoring.PriorDistribution.Conservative;
/// <summary>Half-life for confidence decay in days (default: 14 days).</summary>
public double ConfidenceHalfLifeDays { get; init; } = 14.0;
/// <summary>Minimum confidence floor after decay (default: 0.1).</summary>
public double ConfidenceFloor { get; init; } = 0.1;
/// <summary>Threshold for triggering manual review (default: entropy >= 0.60).</summary>
public double ManualReviewEntropyThreshold { get; init; } = 0.60;
/// <summary>Threshold for triggering refresh (default: entropy >= 0.40).</summary>
public double RefreshEntropyThreshold { get; init; } = 0.40;
/// <summary>Maximum age before observation is considered stale (default: 30 days).</summary>
public double StaleObservationDays { get; init; } = 30.0;
/// <summary>Enable detailed determinization logging (default: false).</summary>
public bool EnableDetailedLogging { get; init; } = false;
/// <summary>Enable automatic refresh for stale observations (default: true).</summary>
public bool EnableAutoRefresh { get; init; } = true;
/// <summary>Maximum retry attempts for failed signal queries (default: 3).</summary>
public int MaxSignalQueryRetries { get; init; } = 3;
}

View File

@@ -0,0 +1,51 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Evidence;
/// <summary>
/// Backport detection evidence.
/// </summary>
public sealed record BackportEvidence
{
/// <summary>
/// Backport detected.
/// </summary>
[JsonPropertyName("detected")]
public required bool Detected { get; init; }
/// <summary>
/// Backport source (e.g., "vendor-advisory", "patch-diff", "build-id").
/// </summary>
[JsonPropertyName("source")]
public required string Source { get; init; }
/// <summary>
/// Vendor package version.
/// </summary>
[JsonPropertyName("vendor_version")]
public string? VendorVersion { get; init; }
/// <summary>
/// Upstream version.
/// </summary>
[JsonPropertyName("upstream_version")]
public string? UpstreamVersion { get; init; }
/// <summary>
/// Patch identifier (e.g., commit hash, KB number).
/// </summary>
[JsonPropertyName("patch_id")]
public string? PatchId { get; init; }
/// <summary>
/// When this backport was detected (UTC).
/// </summary>
[JsonPropertyName("detected_at")]
public required DateTimeOffset DetectedAt { get; init; }
/// <summary>
/// Confidence in this evidence [0.0, 1.0].
/// </summary>
[JsonPropertyName("confidence")]
public required double Confidence { get; init; }
}

View File

@@ -0,0 +1,45 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Evidence;
/// <summary>
/// CVSS (Common Vulnerability Scoring System) evidence.
/// </summary>
public sealed record CvssEvidence
{
/// <summary>
/// CVSS version (e.g., "3.1", "4.0").
/// </summary>
[JsonPropertyName("version")]
public required string Version { get; init; }
/// <summary>
/// Base score [0.0, 10.0].
/// </summary>
[JsonPropertyName("base_score")]
public required double BaseScore { get; init; }
/// <summary>
/// Severity (e.g., "LOW", "MEDIUM", "HIGH", "CRITICAL").
/// </summary>
[JsonPropertyName("severity")]
public required string Severity { get; init; }
/// <summary>
/// Vector string (e.g., "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H").
/// </summary>
[JsonPropertyName("vector")]
public string? Vector { get; init; }
/// <summary>
/// Source of CVSS score (e.g., "NVD", "vendor").
/// </summary>
[JsonPropertyName("source")]
public required string Source { get; init; }
/// <summary>
/// When this CVSS score was published (UTC).
/// </summary>
[JsonPropertyName("published_at")]
public required DateTimeOffset PublishedAt { get; init; }
}

View File

@@ -0,0 +1,40 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Evidence;
/// <summary>
/// EPSS (Exploit Prediction Scoring System) evidence.
/// </summary>
public sealed record EpssEvidence
{
/// <summary>
/// CVE identifier.
/// </summary>
[JsonPropertyName("cve")]
public required string Cve { get; init; }
/// <summary>
/// EPSS score [0.0, 1.0].
/// Probability of exploitation in the next 30 days.
/// </summary>
[JsonPropertyName("epss")]
public required double Epss { get; init; }
/// <summary>
/// EPSS percentile [0.0, 1.0].
/// </summary>
[JsonPropertyName("percentile")]
public required double Percentile { get; init; }
/// <summary>
/// When this EPSS value was published (UTC).
/// </summary>
[JsonPropertyName("published_at")]
public required DateTimeOffset PublishedAt { get; init; }
/// <summary>
/// EPSS model version.
/// </summary>
[JsonPropertyName("model_version")]
public string? ModelVersion { get; init; }
}

View File

@@ -0,0 +1,60 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Evidence;
/// <summary>
/// Reachability analysis evidence.
/// </summary>
public sealed record ReachabilityEvidence
{
/// <summary>
/// Reachability status.
/// </summary>
[JsonPropertyName("status")]
public required ReachabilityStatus Status { get; init; }
/// <summary>
/// Call path depth (if reachable).
/// </summary>
[JsonPropertyName("depth")]
public int? Depth { get; init; }
/// <summary>
/// Entry point function name (if reachable).
/// </summary>
[JsonPropertyName("entry_point")]
public string? EntryPoint { get; init; }
/// <summary>
/// Vulnerable function name.
/// </summary>
[JsonPropertyName("vulnerable_function")]
public string? VulnerableFunction { get; init; }
/// <summary>
/// When this reachability analysis was performed (UTC).
/// </summary>
[JsonPropertyName("analyzed_at")]
public required DateTimeOffset AnalyzedAt { get; init; }
/// <summary>
/// PathWitness digest (if available).
/// </summary>
[JsonPropertyName("witness_digest")]
public string? WitnessDigest { get; init; }
}
/// <summary>
/// Reachability status.
/// </summary>
public enum ReachabilityStatus
{
/// <summary>Vulnerable code is reachable from entry points.</summary>
Reachable,
/// <summary>Vulnerable code is not reachable.</summary>
Unreachable,
/// <summary>Reachability indeterminate (analysis incomplete or failed).</summary>
Indeterminate
}

View File

@@ -0,0 +1,45 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Evidence;
/// <summary>
/// Runtime detection evidence.
/// </summary>
public sealed record RuntimeEvidence
{
/// <summary>
/// Runtime detection status.
/// </summary>
[JsonPropertyName("detected")]
public required bool Detected { get; init; }
/// <summary>
/// Detection source (e.g., "tracer", "eBPF", "logs").
/// </summary>
[JsonPropertyName("source")]
public required string Source { get; init; }
/// <summary>
/// Number of invocations detected.
/// </summary>
[JsonPropertyName("invocation_count")]
public int? InvocationCount { get; init; }
/// <summary>
/// When runtime observation started (UTC).
/// </summary>
[JsonPropertyName("observation_start")]
public required DateTimeOffset ObservationStart { get; init; }
/// <summary>
/// When runtime observation ended (UTC).
/// </summary>
[JsonPropertyName("observation_end")]
public required DateTimeOffset ObservationEnd { get; init; }
/// <summary>
/// Confidence in this evidence [0.0, 1.0].
/// </summary>
[JsonPropertyName("confidence")]
public required double Confidence { get; init; }
}

View File

@@ -0,0 +1,46 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Evidence;
/// <summary>
/// SBOM lineage evidence.
/// Tracks provenance and chain of custody.
/// </summary>
public sealed record SbomLineageEvidence
{
/// <summary>
/// SBOM digest.
/// </summary>
[JsonPropertyName("sbom_digest")]
public required string SbomDigest { get; init; }
/// <summary>
/// SBOM format (e.g., "SPDX", "CycloneDX").
/// </summary>
[JsonPropertyName("format")]
public required string Format { get; init; }
/// <summary>
/// Attestation digest (DSSE envelope).
/// </summary>
[JsonPropertyName("attestation_digest")]
public string? AttestationDigest { get; init; }
/// <summary>
/// Number of components in SBOM.
/// </summary>
[JsonPropertyName("component_count")]
public required int ComponentCount { get; init; }
/// <summary>
/// When this SBOM was generated (UTC).
/// </summary>
[JsonPropertyName("generated_at")]
public required DateTimeOffset GeneratedAt { get; init; }
/// <summary>
/// Build provenance available.
/// </summary>
[JsonPropertyName("has_provenance")]
public required bool HasProvenance { get; init; }
}

View File

@@ -0,0 +1,40 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Evidence;
/// <summary>
/// VEX (Vulnerability Exploitability eXchange) claim summary.
/// </summary>
public sealed record VexClaimSummary
{
/// <summary>
/// VEX status.
/// </summary>
[JsonPropertyName("status")]
public required string Status { get; init; } // "affected", "not_affected", "fixed", "under_investigation"
/// <summary>
/// Confidence in this claim [0.0, 1.0].
/// Weighted average if multiple sources.
/// </summary>
[JsonPropertyName("confidence")]
public required double Confidence { get; init; }
/// <summary>
/// Number of VEX statements supporting this claim.
/// </summary>
[JsonPropertyName("statement_count")]
public required int StatementCount { get; init; }
/// <summary>
/// When this summary was computed (UTC).
/// </summary>
[JsonPropertyName("computed_at")]
public required DateTimeOffset ComputedAt { get; init; }
/// <summary>
/// Justification text (if provided).
/// </summary>
[JsonPropertyName("justification")]
public string? Justification { get; init; }
}

View File

@@ -0,0 +1,8 @@
global using System;
global using System.Collections.Generic;
global using System.Collections.Immutable;
global using System.Linq;
global using System.Threading;
global using System.Threading.Tasks;
global using Microsoft.Extensions.Logging;
global using Microsoft.Extensions.Options;

View File

@@ -0,0 +1,73 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Context for determinization evaluation.
/// Contains environment, criticality, and policy settings.
/// </summary>
public sealed record DeterminizationContext
{
/// <summary>
/// Deployment environment.
/// </summary>
[JsonPropertyName("environment")]
public required DeploymentEnvironment Environment { get; init; }
/// <summary>
/// Asset criticality level.
/// </summary>
[JsonPropertyName("criticality")]
public required AssetCriticality Criticality { get; init; }
/// <summary>
/// Entropy threshold for this context.
/// Observations above this trigger guardrails.
/// </summary>
[JsonPropertyName("entropy_threshold")]
public required double EntropyThreshold { get; init; }
/// <summary>
/// Decay threshold for this context.
/// Observations below this are considered stale.
/// </summary>
[JsonPropertyName("decay_threshold")]
public required double DecayThreshold { get; init; }
/// <summary>
/// Creates context with default production settings.
/// </summary>
public static DeterminizationContext Production() => new()
{
Environment = DeploymentEnvironment.Production,
Criticality = AssetCriticality.High,
EntropyThreshold = 0.4,
DecayThreshold = 0.50
};
/// <summary>
/// Creates context with relaxed development settings.
/// </summary>
public static DeterminizationContext Development() => new()
{
Environment = DeploymentEnvironment.Development,
Criticality = AssetCriticality.Low,
EntropyThreshold = 0.6,
DecayThreshold = 0.35
};
/// <summary>
/// Creates context with custom thresholds.
/// </summary>
public static DeterminizationContext Create(
DeploymentEnvironment environment,
AssetCriticality criticality,
double entropyThreshold,
double decayThreshold) => new()
{
Environment = environment,
Criticality = criticality,
EntropyThreshold = entropyThreshold,
DecayThreshold = decayThreshold
};
}

View File

@@ -0,0 +1,126 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Result of determinization evaluation.
/// Combines observation state, uncertainty score, and guardrails.
/// </summary>
public sealed record DeterminizationResult
{
/// <summary>
/// Resulting observation state.
/// </summary>
[JsonPropertyName("state")]
public required ObservationState State { get; init; }
/// <summary>
/// Uncertainty score at evaluation time.
/// </summary>
[JsonPropertyName("uncertainty")]
public required UncertaintyScore Uncertainty { get; init; }
/// <summary>
/// Decay status at evaluation time.
/// </summary>
[JsonPropertyName("decay")]
public required ObservationDecay Decay { get; init; }
/// <summary>
/// Applied guardrails (if any).
/// </summary>
[JsonPropertyName("guardrails")]
public GuardRails? Guardrails { get; init; }
/// <summary>
/// Evaluation context.
/// </summary>
[JsonPropertyName("context")]
public required DeterminizationContext Context { get; init; }
/// <summary>
/// When this result was computed (UTC).
/// </summary>
[JsonPropertyName("evaluated_at")]
public required DateTimeOffset EvaluatedAt { get; init; }
/// <summary>
/// Decision rationale.
/// </summary>
[JsonPropertyName("rationale")]
public string? Rationale { get; init; }
/// <summary>
/// Creates result for determined observation (low uncertainty).
/// </summary>
public static DeterminizationResult Determined(
UncertaintyScore uncertainty,
ObservationDecay decay,
DeterminizationContext context,
DateTimeOffset evaluatedAt) => new()
{
State = ObservationState.Determined,
Uncertainty = uncertainty,
Decay = decay,
Guardrails = GuardRails.None(),
Context = context,
EvaluatedAt = evaluatedAt,
Rationale = "Evidence sufficient for confident determination"
};
/// <summary>
/// Creates result for pending observation (high uncertainty).
/// </summary>
public static DeterminizationResult Pending(
UncertaintyScore uncertainty,
ObservationDecay decay,
GuardRails guardrails,
DeterminizationContext context,
DateTimeOffset evaluatedAt) => new()
{
State = ObservationState.PendingDeterminization,
Uncertainty = uncertainty,
Decay = decay,
Guardrails = guardrails,
Context = context,
EvaluatedAt = evaluatedAt,
Rationale = $"Uncertainty ({uncertainty.Entropy:F2}) above threshold ({context.EntropyThreshold:F2})"
};
/// <summary>
/// Creates result for stale observation requiring refresh.
/// </summary>
public static DeterminizationResult Stale(
UncertaintyScore uncertainty,
ObservationDecay decay,
DeterminizationContext context,
DateTimeOffset evaluatedAt) => new()
{
State = ObservationState.StaleRequiresRefresh,
Uncertainty = uncertainty,
Decay = decay,
Guardrails = GuardRails.Strict(),
Context = context,
EvaluatedAt = evaluatedAt,
Rationale = $"Evidence decayed below threshold ({context.DecayThreshold:F2})"
};
/// <summary>
/// Creates result for disputed observation (conflicting signals).
/// </summary>
public static DeterminizationResult Disputed(
UncertaintyScore uncertainty,
ObservationDecay decay,
DeterminizationContext context,
DateTimeOffset evaluatedAt,
string reason) => new()
{
State = ObservationState.Disputed,
Uncertainty = uncertainty,
Decay = decay,
Guardrails = GuardRails.Strict(),
Context = context,
EvaluatedAt = evaluatedAt,
Rationale = $"Conflicting signals detected: {reason}"
};
}

View File

@@ -0,0 +1,112 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Guardrails policy configuration for uncertain observations.
/// Defines monitoring/restrictions when evidence is incomplete.
/// </summary>
public sealed record GuardRails
{
/// <summary>
/// Enable runtime monitoring.
/// </summary>
[JsonPropertyName("enable_monitoring")]
public required bool EnableMonitoring { get; init; }
/// <summary>
/// Restrict deployment to non-production environments.
/// </summary>
[JsonPropertyName("restrict_to_non_prod")]
public required bool RestrictToNonProd { get; init; }
/// <summary>
/// Require manual approval before deployment.
/// </summary>
[JsonPropertyName("require_approval")]
public required bool RequireApproval { get; init; }
/// <summary>
/// Schedule automatic re-evaluation after this duration.
/// </summary>
[JsonPropertyName("reeval_after")]
public TimeSpan? ReevalAfter { get; init; }
/// <summary>
/// Additional notes/rationale for guardrails.
/// </summary>
[JsonPropertyName("notes")]
public string? Notes { get; init; }
/// <summary>
/// Creates GuardRails with default safe settings.
/// </summary>
public static GuardRails Default() => new()
{
EnableMonitoring = true,
RestrictToNonProd = false,
RequireApproval = false,
ReevalAfter = TimeSpan.FromDays(7),
Notes = null
};
/// <summary>
/// Creates GuardRails for high-uncertainty observations.
/// </summary>
public static GuardRails Strict() => new()
{
EnableMonitoring = true,
RestrictToNonProd = true,
RequireApproval = true,
ReevalAfter = TimeSpan.FromDays(3),
Notes = "High uncertainty - strict guardrails applied"
};
/// <summary>
/// Creates GuardRails with no restrictions (all evidence present).
/// </summary>
public static GuardRails None() => new()
{
EnableMonitoring = false,
RestrictToNonProd = false,
RequireApproval = false,
ReevalAfter = null,
Notes = null
};
}
/// <summary>
/// Deployment environment classification.
/// </summary>
public enum DeploymentEnvironment
{
/// <summary>Development environment.</summary>
Development = 0,
/// <summary>Testing environment.</summary>
Testing = 1,
/// <summary>Staging/pre-production environment.</summary>
Staging = 2,
/// <summary>Production environment.</summary>
Production = 3
}
/// <summary>
/// Asset criticality classification.
/// </summary>
public enum AssetCriticality
{
/// <summary>Low criticality - minimal impact if compromised.</summary>
Low = 0,
/// <summary>Medium criticality - moderate impact.</summary>
Medium = 1,
/// <summary>High criticality - significant impact.</summary>
High = 2,
/// <summary>Critical - severe impact if compromised.</summary>
Critical = 3
}

View File

@@ -0,0 +1,99 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Per-observation decay configuration.
/// Tracks evidence staleness with configurable half-life.
/// Formula: decayed = max(floor, exp(-ln(2) * age_days / half_life_days))
/// </summary>
public sealed record ObservationDecay
{
/// <summary>
/// When the observation was first recorded (UTC).
/// </summary>
[JsonPropertyName("observed_at")]
public required DateTimeOffset ObservedAt { get; init; }
/// <summary>
/// When the observation was last refreshed (UTC).
/// </summary>
[JsonPropertyName("refreshed_at")]
public required DateTimeOffset RefreshedAt { get; init; }
/// <summary>
/// Half-life in days.
/// Default: 14 days.
/// </summary>
[JsonPropertyName("half_life_days")]
public required double HalfLifeDays { get; init; }
/// <summary>
/// Minimum confidence floor.
/// Default: 0.35 (consistent with FreshnessCalculator).
/// </summary>
[JsonPropertyName("floor")]
public required double Floor { get; init; }
/// <summary>
/// Staleness threshold (0.0-1.0).
/// If decay multiplier drops below this, observation becomes stale.
/// Default: 0.50
/// </summary>
[JsonPropertyName("staleness_threshold")]
public required double StalenessThreshold { get; init; }
/// <summary>
/// Calculates the current decay multiplier.
/// </summary>
public double CalculateDecay(DateTimeOffset now)
{
var ageDays = (now - RefreshedAt).TotalDays;
if (ageDays <= 0)
return 1.0;
var decay = Math.Exp(-Math.Log(2) * ageDays / HalfLifeDays);
return Math.Max(Floor, decay);
}
/// <summary>
/// Returns true if the observation is stale (decay below threshold).
/// </summary>
public bool IsStale(DateTimeOffset now) =>
CalculateDecay(now) < StalenessThreshold;
/// <summary>
/// Creates ObservationDecay with default settings.
/// </summary>
public static ObservationDecay Create(DateTimeOffset observedAt, DateTimeOffset? refreshedAt = null) => new()
{
ObservedAt = observedAt,
RefreshedAt = refreshedAt ?? observedAt,
HalfLifeDays = 14.0,
Floor = 0.35,
StalenessThreshold = 0.50
};
/// <summary>
/// Creates a fresh observation (just recorded).
/// </summary>
public static ObservationDecay Fresh(DateTimeOffset now) =>
Create(now, now);
/// <summary>
/// Creates ObservationDecay with custom settings.
/// </summary>
public static ObservationDecay WithSettings(
DateTimeOffset observedAt,
DateTimeOffset refreshedAt,
double halfLifeDays,
double floor,
double stalenessThreshold) => new()
{
ObservedAt = observedAt,
RefreshedAt = refreshedAt,
HalfLifeDays = halfLifeDays,
Floor = floor,
StalenessThreshold = stalenessThreshold
};
}

View File

@@ -0,0 +1,44 @@
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Observation state for CVE tracking, independent of VEX status.
/// Allows a CVE to be "Affected" (VEX) but "PendingDeterminization" (observation).
/// </summary>
public enum ObservationState
{
/// <summary>
/// Initial state: CVE discovered but evidence incomplete.
/// Triggers guardrail-based policy evaluation.
/// </summary>
PendingDeterminization = 0,
/// <summary>
/// Evidence sufficient for confident determination.
/// Normal policy evaluation applies.
/// </summary>
Determined = 1,
/// <summary>
/// Multiple signals conflict (K4 Conflict state).
/// Requires human review regardless of confidence.
/// </summary>
Disputed = 2,
/// <summary>
/// Evidence decayed below threshold; needs refresh.
/// Auto-triggered when decay > threshold.
/// </summary>
StaleRequiresRefresh = 3,
/// <summary>
/// Manually flagged for review.
/// Bypasses automatic determinization.
/// </summary>
ManualReviewRequired = 4,
/// <summary>
/// CVE suppressed/ignored by policy exception.
/// Evidence tracking continues but decisions skip.
/// </summary>
Suppressed = 5
}

View File

@@ -0,0 +1,57 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Describes a missing signal that contributes to uncertainty.
/// </summary>
public sealed record SignalGap
{
/// <summary>
/// Signal name (e.g., "epss", "vex", "reachability").
/// </summary>
[JsonPropertyName("signal")]
public required string Signal { get; init; }
/// <summary>
/// Reason the signal is missing.
/// </summary>
[JsonPropertyName("reason")]
public required SignalGapReason Reason { get; init; }
/// <summary>
/// Prior assumption used in absence of signal.
/// </summary>
[JsonPropertyName("prior")]
public double? Prior { get; init; }
/// <summary>
/// Weight this signal contributes to total uncertainty.
/// </summary>
[JsonPropertyName("weight")]
public double Weight { get; init; }
/// <summary>
/// Human-readable description.
/// </summary>
[JsonPropertyName("description")]
public string? Description { get; init; }
}
/// <summary>
/// Reason a signal is missing.
/// </summary>
public enum SignalGapReason
{
/// <summary>Signal not yet queried.</summary>
NotQueried,
/// <summary>Signal legitimately does not exist (e.g., EPSS not published yet).</summary>
NotAvailable,
/// <summary>Signal query failed due to external error.</summary>
QueryFailed,
/// <summary>Signal not applicable for this artifact type.</summary>
NotApplicable
}

View File

@@ -0,0 +1,26 @@
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Query status for a signal.
/// Distinguishes between "not yet queried", "queried with result", and "query failed".
/// </summary>
public enum SignalQueryStatus
{
/// <summary>
/// Signal has not been queried yet.
/// Default state before any lookup attempt.
/// </summary>
NotQueried = 0,
/// <summary>
/// Signal query succeeded.
/// Value may be present or null (signal legitimately absent).
/// </summary>
Queried = 1,
/// <summary>
/// Signal query failed due to error (network, API timeout, etc.).
/// Value is null but reason is external failure, not absence.
/// </summary>
Failed = 2
}

View File

@@ -0,0 +1,88 @@
using System.Text.Json.Serialization;
using StellaOps.Policy.Determinization.Evidence;
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Point-in-time snapshot of all signals for a CVE observation.
/// Used as input to uncertainty scoring.
/// </summary>
public sealed record SignalSnapshot
{
/// <summary>
/// CVE identifier.
/// </summary>
[JsonPropertyName("cve")]
public required string Cve { get; init; }
/// <summary>
/// Component PURL.
/// </summary>
[JsonPropertyName("purl")]
public required string Purl { get; init; }
/// <summary>
/// EPSS signal.
/// </summary>
[JsonPropertyName("epss")]
public required SignalState<EpssEvidence> Epss { get; init; }
/// <summary>
/// VEX signal.
/// </summary>
[JsonPropertyName("vex")]
public required SignalState<VexClaimSummary> Vex { get; init; }
/// <summary>
/// Reachability signal.
/// </summary>
[JsonPropertyName("reachability")]
public required SignalState<ReachabilityEvidence> Reachability { get; init; }
/// <summary>
/// Runtime signal.
/// </summary>
[JsonPropertyName("runtime")]
public required SignalState<RuntimeEvidence> Runtime { get; init; }
/// <summary>
/// Backport signal.
/// </summary>
[JsonPropertyName("backport")]
public required SignalState<BackportEvidence> Backport { get; init; }
/// <summary>
/// SBOM lineage signal.
/// </summary>
[JsonPropertyName("sbom")]
public required SignalState<SbomLineageEvidence> Sbom { get; init; }
/// <summary>
/// CVSS signal.
/// </summary>
[JsonPropertyName("cvss")]
public required SignalState<CvssEvidence> Cvss { get; init; }
/// <summary>
/// When this snapshot was captured (UTC).
/// </summary>
[JsonPropertyName("snapshot_at")]
public required DateTimeOffset SnapshotAt { get; init; }
/// <summary>
/// Creates an empty snapshot with all signals NotQueried.
/// </summary>
public static SignalSnapshot Empty(string cve, string purl, DateTimeOffset snapshotAt) => new()
{
Cve = cve,
Purl = purl,
Epss = SignalState<EpssEvidence>.NotQueried(),
Vex = SignalState<VexClaimSummary>.NotQueried(),
Reachability = SignalState<ReachabilityEvidence>.NotQueried(),
Runtime = SignalState<RuntimeEvidence>.NotQueried(),
Backport = SignalState<BackportEvidence>.NotQueried(),
Sbom = SignalState<SbomLineageEvidence>.NotQueried(),
Cvss = SignalState<CvssEvidence>.NotQueried(),
SnapshotAt = snapshotAt
};
}

View File

@@ -0,0 +1,90 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Wraps a signal value with query status metadata.
/// Distinguishes between: not queried, queried with value, queried but absent, query failed.
/// </summary>
/// <typeparam name="T">The signal value type.</typeparam>
public sealed record SignalState<T>
{
/// <summary>
/// Query status for this signal.
/// </summary>
[JsonPropertyName("status")]
public required SignalQueryStatus Status { get; init; }
/// <summary>
/// Signal value, if queried and present.
/// Null can mean: not queried, legitimately absent, or query failed.
/// Check Status to disambiguate.
/// </summary>
[JsonPropertyName("value")]
public T? Value { get; init; }
/// <summary>
/// When this signal was last queried (UTC).
/// Null if never queried.
/// </summary>
[JsonPropertyName("queried_at")]
public DateTimeOffset? QueriedAt { get; init; }
/// <summary>
/// Error message if Status == Failed.
/// </summary>
[JsonPropertyName("error")]
public string? Error { get; init; }
/// <summary>
/// Creates a SignalState in NotQueried status.
/// </summary>
public static SignalState<T> NotQueried() => new()
{
Status = SignalQueryStatus.NotQueried,
Value = default,
QueriedAt = null,
Error = null
};
/// <summary>
/// Creates a SignalState with a successful query result.
/// Value may be null if the signal legitimately does not exist.
/// </summary>
public static SignalState<T> Queried(T? value, DateTimeOffset queriedAt) => new()
{
Status = SignalQueryStatus.Queried,
Value = value,
QueriedAt = queriedAt,
Error = null
};
/// <summary>
/// Creates a SignalState representing a failed query.
/// </summary>
public static SignalState<T> Failed(string error, DateTimeOffset attemptedAt) => new()
{
Status = SignalQueryStatus.Failed,
Value = default,
QueriedAt = attemptedAt,
Error = error
};
/// <summary>
/// Returns true if the signal was queried and has a non-null value.
/// </summary>
[JsonIgnore]
public bool HasValue => Status == SignalQueryStatus.Queried && Value is not null;
/// <summary>
/// Returns true if the signal query failed.
/// </summary>
[JsonIgnore]
public bool IsFailed => Status == SignalQueryStatus.Failed;
/// <summary>
/// Returns true if the signal has not been queried yet.
/// </summary>
[JsonIgnore]
public bool IsNotQueried => Status == SignalQueryStatus.NotQueried;
}

View File

@@ -0,0 +1,123 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Uncertainty tier classification based on entropy score.
/// </summary>
public enum UncertaintyTier
{
/// <summary>
/// Very high confidence (entropy &lt; 0.2).
/// All or most key signals present and consistent.
/// </summary>
Minimal = 0,
/// <summary>
/// High confidence (entropy 0.2-0.4).
/// Most key signals present.
/// </summary>
Low = 1,
/// <summary>
/// Moderate confidence (entropy 0.4-0.6).
/// Some signals missing or conflicting.
/// </summary>
Moderate = 2,
/// <summary>
/// Low confidence (entropy 0.6-0.8).
/// Many signals missing or conflicting.
/// </summary>
High = 3,
/// <summary>
/// Very low confidence (entropy &gt;= 0.8).
/// Critical signals missing or heavily conflicting.
/// </summary>
Critical = 4
}
/// <summary>
/// Quantifies knowledge completeness (not code entropy).
/// Calculated from signal presence/absence weighted by importance.
/// Formula: entropy = 1 - (sum of weighted present signals / max possible weight)
/// </summary>
public sealed record UncertaintyScore
{
/// <summary>
/// Entropy value [0.0, 1.0].
/// 0 = complete knowledge, 1 = complete uncertainty.
/// </summary>
[JsonPropertyName("entropy")]
public required double Entropy { get; init; }
/// <summary>
/// Uncertainty tier derived from entropy.
/// </summary>
[JsonPropertyName("tier")]
public required UncertaintyTier Tier { get; init; }
/// <summary>
/// Missing signals contributing to uncertainty.
/// </summary>
[JsonPropertyName("gaps")]
public required IReadOnlyList<SignalGap> Gaps { get; init; }
/// <summary>
/// Total weight of present signals.
/// </summary>
[JsonPropertyName("present_weight")]
public required double PresentWeight { get; init; }
/// <summary>
/// Maximum possible weight (sum of all signal weights).
/// </summary>
[JsonPropertyName("max_weight")]
public required double MaxWeight { get; init; }
/// <summary>
/// When this score was calculated (UTC).
/// </summary>
[JsonPropertyName("calculated_at")]
public required DateTimeOffset CalculatedAt { get; init; }
/// <summary>
/// Creates an UncertaintyScore with calculated tier.
/// </summary>
public static UncertaintyScore Create(
double entropy,
IReadOnlyList<SignalGap> gaps,
double presentWeight,
double maxWeight,
DateTimeOffset calculatedAt)
{
if (entropy < 0.0 || entropy > 1.0)
throw new ArgumentOutOfRangeException(nameof(entropy), "Entropy must be in [0.0, 1.0]");
var tier = entropy switch
{
< 0.2 => UncertaintyTier.Minimal,
< 0.4 => UncertaintyTier.Low,
< 0.6 => UncertaintyTier.Moderate,
< 0.8 => UncertaintyTier.High,
_ => UncertaintyTier.Critical
};
return new UncertaintyScore
{
Entropy = entropy,
Tier = tier,
Gaps = gaps,
PresentWeight = presentWeight,
MaxWeight = maxWeight,
CalculatedAt = calculatedAt
};
}
/// <summary>
/// Creates a zero-entropy score (complete knowledge).
/// </summary>
public static UncertaintyScore Zero(double maxWeight, DateTimeOffset calculatedAt) =>
Create(0.0, Array.Empty<SignalGap>(), maxWeight, maxWeight, calculatedAt);
}

View File

@@ -0,0 +1,75 @@
using System.Diagnostics.Metrics;
namespace StellaOps.Policy.Determinization.Scoring;
/// <summary>
/// Calculates decayed confidence scores using exponential half-life decay.
/// </summary>
public sealed class DecayedConfidenceCalculator : IDecayedConfidenceCalculator
{
private static readonly Meter Meter = new("StellaOps.Policy.Determinization");
private static readonly Histogram<double> DecayMultiplierHistogram = Meter.CreateHistogram<double>(
"stellaops_determinization_decay_multiplier",
unit: "ratio",
description: "Confidence decay multiplier based on observation age and half-life");
private readonly ILogger<DecayedConfidenceCalculator> _logger;
public DecayedConfidenceCalculator(ILogger<DecayedConfidenceCalculator> logger)
{
_logger = logger;
}
public double Calculate(
double baseConfidence,
double ageDays,
double halfLifeDays = 14.0,
double floor = 0.1)
{
if (baseConfidence < 0.0 || baseConfidence > 1.0)
throw new ArgumentOutOfRangeException(nameof(baseConfidence), "Must be between 0.0 and 1.0");
if (ageDays < 0.0)
throw new ArgumentOutOfRangeException(nameof(ageDays), "Cannot be negative");
if (halfLifeDays <= 0.0)
throw new ArgumentOutOfRangeException(nameof(halfLifeDays), "Must be positive");
if (floor < 0.0 || floor > 1.0)
throw new ArgumentOutOfRangeException(nameof(floor), "Must be between 0.0 and 1.0");
var decayFactor = CalculateDecayFactor(ageDays, halfLifeDays);
var decayed = baseConfidence * decayFactor;
var result = Math.Max(floor, decayed);
_logger.LogDebug(
"Decayed confidence from {Base:F4} to {Result:F4} (age={AgeDays:F2}d, half-life={HalfLife:F2}d, floor={Floor:F2})",
baseConfidence,
result,
ageDays,
halfLifeDays,
floor);
// Emit metric for decay multiplier (factor before floor is applied)
DecayMultiplierHistogram.Record(decayFactor,
new KeyValuePair<string, object?>("half_life_days", halfLifeDays),
new KeyValuePair<string, object?>("age_days", ageDays));
return result;
}
public double CalculateDecayFactor(double ageDays, double halfLifeDays = 14.0)
{
if (ageDays < 0.0)
throw new ArgumentOutOfRangeException(nameof(ageDays), "Cannot be negative");
if (halfLifeDays <= 0.0)
throw new ArgumentOutOfRangeException(nameof(halfLifeDays), "Must be positive");
// Formula: exp(-ln(2) * age_days / half_life_days)
var exponent = -Math.Log(2.0) * ageDays / halfLifeDays;
var factor = Math.Exp(exponent);
return Math.Clamp(factor, 0.0, 1.0);
}
}

View File

@@ -0,0 +1,27 @@
namespace StellaOps.Policy.Determinization.Scoring;
/// <summary>
/// Calculates decayed confidence scores using exponential half-life decay.
/// </summary>
public interface IDecayedConfidenceCalculator
{
/// <summary>
/// Calculate decayed confidence from observation age.
/// Formula: decayed = max(floor, exp(-ln(2) * age_days / half_life_days))
/// </summary>
/// <param name="baseConfidence">Original confidence score (0.0-1.0)</param>
/// <param name="ageDays">Age of observation in days</param>
/// <param name="halfLifeDays">Half-life period (default: 14 days)</param>
/// <param name="floor">Minimum confidence floor (default: 0.1)</param>
/// <returns>Decayed confidence score</returns>
double Calculate(
double baseConfidence,
double ageDays,
double halfLifeDays = 14.0,
double floor = 0.1);
/// <summary>
/// Calculate decay factor only (without applying to base confidence).
/// </summary>
double CalculateDecayFactor(double ageDays, double halfLifeDays = 14.0);
}

View File

@@ -0,0 +1,23 @@
using StellaOps.Policy.Determinization.Models;
namespace StellaOps.Policy.Determinization.Scoring;
/// <summary>
/// Calculates uncertainty scores based on signal completeness (entropy).
/// </summary>
public interface IUncertaintyScoreCalculator
{
/// <summary>
/// Calculate uncertainty score from a signal snapshot.
/// Formula: entropy = 1 - (weighted_present_signals / max_possible_weight)
/// </summary>
/// <param name="snapshot">Signal snapshot containing presence indicators</param>
/// <param name="weights">Signal weights (optional, uses defaults if null)</param>
/// <returns>Uncertainty score with tier classification</returns>
UncertaintyScore Calculate(SignalSnapshot snapshot, SignalWeights? weights = null);
/// <summary>
/// Calculate raw entropy value (0.0 = complete knowledge, 1.0 = no knowledge).
/// </summary>
double CalculateEntropy(SignalSnapshot snapshot, SignalWeights? weights = null);
}

View File

@@ -0,0 +1,40 @@
namespace StellaOps.Policy.Determinization.Scoring;
/// <summary>
/// Prior distribution for missing signals (Bayesian approach).
/// </summary>
public sealed record PriorDistribution
{
/// <summary>Conservative prior: assume affected until proven otherwise.</summary>
public static readonly PriorDistribution Conservative = new()
{
AffectedProbability = 0.70,
NotAffectedProbability = 0.20,
UnknownProbability = 0.10
};
/// <summary>Neutral prior: equal weighting for affected/not-affected.</summary>
public static readonly PriorDistribution Neutral = new()
{
AffectedProbability = 0.40,
NotAffectedProbability = 0.40,
UnknownProbability = 0.20
};
/// <summary>Probability of "Affected" status (default: 0.70 conservative).</summary>
public required double AffectedProbability { get; init; }
/// <summary>Probability of "Not Affected" status (default: 0.20).</summary>
public required double NotAffectedProbability { get; init; }
/// <summary>Probability of "Unknown" status (default: 0.10).</summary>
public required double UnknownProbability { get; init; }
/// <summary>Sum of all probabilities (should equal 1.0).</summary>
public double Total =>
AffectedProbability + NotAffectedProbability + UnknownProbability;
/// <summary>Validates that probabilities sum to approximately 1.0.</summary>
public bool IsNormalized(double tolerance = 0.001) =>
Math.Abs(Total - 1.0) < tolerance;
}

View File

@@ -0,0 +1,45 @@
namespace StellaOps.Policy.Determinization.Scoring;
/// <summary>
/// Configurable signal weights for entropy calculation.
/// </summary>
public sealed record SignalWeights
{
/// <summary>Default weights following advisory recommendations.</summary>
public static readonly SignalWeights Default = new()
{
VexWeight = 0.25,
EpssWeight = 0.15,
ReachabilityWeight = 0.25,
RuntimeWeight = 0.15,
BackportWeight = 0.10,
SbomLineageWeight = 0.10
};
/// <summary>Weight for VEX claim signals (default: 0.25).</summary>
public required double VexWeight { get; init; }
/// <summary>Weight for EPSS signals (default: 0.15).</summary>
public required double EpssWeight { get; init; }
/// <summary>Weight for Reachability signals (default: 0.25).</summary>
public required double ReachabilityWeight { get; init; }
/// <summary>Weight for Runtime detection signals (default: 0.15).</summary>
public required double RuntimeWeight { get; init; }
/// <summary>Weight for Backport evidence signals (default: 0.10).</summary>
public required double BackportWeight { get; init; }
/// <summary>Weight for SBOM lineage signals (default: 0.10).</summary>
public required double SbomLineageWeight { get; init; }
/// <summary>Sum of all weights (should equal 1.0 for normalized calculations).</summary>
public double TotalWeight =>
VexWeight + EpssWeight + ReachabilityWeight +
RuntimeWeight + BackportWeight + SbomLineageWeight;
/// <summary>Validates that weights sum to approximately 1.0.</summary>
public bool IsNormalized(double tolerance = 0.001) =>
Math.Abs(TotalWeight - 1.0) < tolerance;
}

View File

@@ -0,0 +1,125 @@
using StellaOps.Policy.Determinization.Evidence;
using StellaOps.Policy.Determinization.Models;
namespace StellaOps.Policy.Determinization.Scoring;
/// <summary>
/// Aggregates individual signal scores into a final trust/confidence score.
/// </summary>
public sealed class TrustScoreAggregator
{
private readonly ILogger<TrustScoreAggregator> _logger;
public TrustScoreAggregator(ILogger<TrustScoreAggregator> logger)
{
_logger = logger;
}
/// <summary>
/// Aggregate signal scores using weighted average with uncertainty penalty.
/// </summary>
/// <param name="snapshot">Signal snapshot with all available signals</param>
/// <param name="uncertaintyScore">Uncertainty score from entropy calculation</param>
/// <param name="weights">Signal weights (optional)</param>
/// <returns>Aggregated trust score (0.0-1.0)</returns>
public double Aggregate(
SignalSnapshot snapshot,
UncertaintyScore uncertaintyScore,
SignalWeights? weights = null)
{
ArgumentNullException.ThrowIfNull(snapshot);
ArgumentNullException.ThrowIfNull(uncertaintyScore);
var effectiveWeights = weights ?? SignalWeights.Default;
// Calculate weighted sum of present signals
var weightedSum = 0.0;
var totalWeight = 0.0;
var presentCount = 0;
if (!snapshot.Vex.IsNotQueried && snapshot.Vex.Value is not null)
{
var score = CalculateVexScore(snapshot.Vex.Value);
weightedSum += score * effectiveWeights.VexWeight;
totalWeight += effectiveWeights.VexWeight;
presentCount++;
}
if (!snapshot.Epss.IsNotQueried && snapshot.Epss.Value is not null)
{
var score = snapshot.Epss.Value.Epss; // EPSS score is the risk score
weightedSum += score * effectiveWeights.EpssWeight;
totalWeight += effectiveWeights.EpssWeight;
presentCount++;
}
if (!snapshot.Reachability.IsNotQueried && snapshot.Reachability.Value is not null)
{
var score = snapshot.Reachability.Value.Status == ReachabilityStatus.Reachable ? 1.0 : 0.0;
weightedSum += score * effectiveWeights.ReachabilityWeight;
totalWeight += effectiveWeights.ReachabilityWeight;
presentCount++;
}
if (!snapshot.Runtime.IsNotQueried && snapshot.Runtime.Value is not null)
{
var score = snapshot.Runtime.Value.Detected ? 1.0 : 0.0;
weightedSum += score * effectiveWeights.RuntimeWeight;
totalWeight += effectiveWeights.RuntimeWeight;
presentCount++;
}
if (!snapshot.Backport.IsNotQueried && snapshot.Backport.Value is not null)
{
var score = snapshot.Backport.Value.Detected ? 0.0 : 1.0; // Inverted: backport = lower risk
weightedSum += score * effectiveWeights.BackportWeight;
totalWeight += effectiveWeights.BackportWeight;
presentCount++;
}
if (!snapshot.Sbom.IsNotQueried && snapshot.Sbom.Value is not null)
{
// For now, just check if SBOM exists (conservative scoring)
var score = 0.5; // Neutral score for SBOM lineage
weightedSum += score * effectiveWeights.SbomLineageWeight;
totalWeight += effectiveWeights.SbomLineageWeight;
presentCount++;
}
// If no signals present, return 0.5 (neutral) penalized by uncertainty
if (totalWeight == 0.0)
{
_logger.LogWarning("No signals present for aggregation; returning neutral score penalized by uncertainty");
return 0.5 * (1.0 - uncertaintyScore.Entropy);
}
// Weighted average
var baseScore = weightedSum / totalWeight;
// Apply uncertainty penalty: lower confidence when entropy is high
var confidenceFactor = 1.0 - uncertaintyScore.Entropy;
var adjustedScore = baseScore * confidenceFactor;
_logger.LogDebug(
"Aggregated trust score {Score:F4} from {PresentSignals} signals (base={Base:F4}, confidence={Confidence:F4})",
adjustedScore,
presentCount,
baseScore,
confidenceFactor);
return Math.Clamp(adjustedScore, 0.0, 1.0);
}
private static double CalculateVexScore(VexClaimSummary vex)
{
// Map VEX status to risk score
return vex.Status.ToLowerInvariant() switch
{
"affected" => 1.0,
"under_investigation" => 0.7,
"not_affected" => 0.0,
"fixed" => 0.1,
_ => 0.5
};
}
}

View File

@@ -0,0 +1,103 @@
using System.Diagnostics.Metrics;
using StellaOps.Policy.Determinization.Models;
namespace StellaOps.Policy.Determinization.Scoring;
/// <summary>
/// Calculates uncertainty scores based on signal completeness using entropy formula.
/// </summary>
public sealed class UncertaintyScoreCalculator : IUncertaintyScoreCalculator
{
private static readonly Meter Meter = new("StellaOps.Policy.Determinization");
private static readonly Histogram<double> EntropyHistogram = Meter.CreateHistogram<double>(
"stellaops_determinization_uncertainty_entropy",
unit: "ratio",
description: "Uncertainty entropy score (0.0 = complete knowledge, 1.0 = no knowledge)");
private readonly ILogger<UncertaintyScoreCalculator> _logger;
public UncertaintyScoreCalculator(ILogger<UncertaintyScoreCalculator> logger)
{
_logger = logger;
}
public UncertaintyScore Calculate(SignalSnapshot snapshot, SignalWeights? weights = null)
{
ArgumentNullException.ThrowIfNull(snapshot);
var effectiveWeights = weights ?? SignalWeights.Default;
var entropy = CalculateEntropy(snapshot, effectiveWeights);
// Calculate present weight
var presentWeight = effectiveWeights.TotalWeight * (1.0 - entropy);
// Calculate gaps (missing signals)
var gaps = new List<SignalGap>();
if (snapshot.Vex.IsNotQueried)
gaps.Add(new SignalGap { Signal = "VEX", Reason = SignalGapReason.NotQueried, Weight = effectiveWeights.VexWeight });
if (snapshot.Epss.IsNotQueried)
gaps.Add(new SignalGap { Signal = "EPSS", Reason = SignalGapReason.NotQueried, Weight = effectiveWeights.EpssWeight });
if (snapshot.Reachability.IsNotQueried)
gaps.Add(new SignalGap { Signal = "Reachability", Reason = SignalGapReason.NotQueried, Weight = effectiveWeights.ReachabilityWeight });
if (snapshot.Runtime.IsNotQueried)
gaps.Add(new SignalGap { Signal = "Runtime", Reason = SignalGapReason.NotQueried, Weight = effectiveWeights.RuntimeWeight });
if (snapshot.Backport.IsNotQueried)
gaps.Add(new SignalGap { Signal = "Backport", Reason = SignalGapReason.NotQueried, Weight = effectiveWeights.BackportWeight });
if (snapshot.Sbom.IsNotQueried)
gaps.Add(new SignalGap { Signal = "SBOMLineage", Reason = SignalGapReason.NotQueried, Weight = effectiveWeights.SbomLineageWeight });
return UncertaintyScore.Create(
entropy,
gaps,
presentWeight,
effectiveWeights.TotalWeight,
snapshot.SnapshotAt);
}
public double CalculateEntropy(SignalSnapshot snapshot, SignalWeights? weights = null)
{
ArgumentNullException.ThrowIfNull(snapshot);
var effectiveWeights = weights ?? SignalWeights.Default;
// Calculate total weight of present signals
var presentWeight = 0.0;
if (!snapshot.Vex.IsNotQueried)
presentWeight += effectiveWeights.VexWeight;
if (!snapshot.Epss.IsNotQueried)
presentWeight += effectiveWeights.EpssWeight;
if (!snapshot.Reachability.IsNotQueried)
presentWeight += effectiveWeights.ReachabilityWeight;
if (!snapshot.Runtime.IsNotQueried)
presentWeight += effectiveWeights.RuntimeWeight;
if (!snapshot.Backport.IsNotQueried)
presentWeight += effectiveWeights.BackportWeight;
if (!snapshot.Sbom.IsNotQueried)
presentWeight += effectiveWeights.SbomLineageWeight;
// Entropy = 1 - (present / total_possible)
var totalPossibleWeight = effectiveWeights.TotalWeight;
var entropy = 1.0 - (presentWeight / totalPossibleWeight);
_logger.LogDebug(
"Calculated entropy {Entropy:F4} from {PresentWeight:F2}/{TotalWeight:F2} signal weight",
entropy,
presentWeight,
totalPossibleWeight);
var clampedEntropy = Math.Clamp(entropy, 0.0, 1.0);
// Emit metric
EntropyHistogram.Record(clampedEntropy,
new KeyValuePair<string, object?>("cve", snapshot.Cve),
new KeyValuePair<string, object?>("purl", snapshot.Purl));
return clampedEntropy;
}
}

View File

@@ -0,0 +1,63 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Determinization.Scoring;
namespace StellaOps.Policy.Determinization;
/// <summary>
/// Service registration for Determinization subsystem.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers determinization services with the DI container.
/// </summary>
/// <param name="services">Service collection</param>
/// <param name="configuration">Configuration root (for options binding)</param>
/// <returns>Service collection for chaining</returns>
public static IServiceCollection AddDeterminization(
this IServiceCollection services,
IConfiguration configuration)
{
// Register options
services.AddOptions<DeterminizationOptions>()
.Bind(configuration.GetSection(DeterminizationOptions.SectionName))
.ValidateOnStart();
// Register scoring calculators (both interface and concrete for flexibility)
services.TryAddSingleton<UncertaintyScoreCalculator>();
services.TryAddSingleton<IUncertaintyScoreCalculator>(sp => sp.GetRequiredService<UncertaintyScoreCalculator>());
services.TryAddSingleton<DecayedConfidenceCalculator>();
services.TryAddSingleton<IDecayedConfidenceCalculator>(sp => sp.GetRequiredService<DecayedConfidenceCalculator>());
services.TryAddSingleton<TrustScoreAggregator>();
return services;
}
/// <summary>
/// Registers determinization services with custom options.
/// </summary>
public static IServiceCollection AddDeterminization(
this IServiceCollection services,
Action<DeterminizationOptions> configureOptions)
{
services.AddOptions<DeterminizationOptions>()
.Configure(configureOptions)
.ValidateOnStart();
// Register scoring calculators (both interface and concrete for flexibility)
services.TryAddSingleton<UncertaintyScoreCalculator>();
services.TryAddSingleton<IUncertaintyScoreCalculator>(sp => sp.GetRequiredService<UncertaintyScoreCalculator>());
services.TryAddSingleton<DecayedConfidenceCalculator>();
services.TryAddSingleton<IDecayedConfidenceCalculator>(sp => sp.GetRequiredService<DecayedConfidenceCalculator>());
services.TryAddSingleton<TrustScoreAggregator>();
return services;
}
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Concelier.Models\StellaOps.Concelier.Models.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
global using System;
global using System.Collections.Generic;
global using System.Collections.Immutable;
global using System.Linq;
global using System.Text;
global using System.Text.Json;
global using System.Text.Json.Serialization;
global using Microsoft.Extensions.Logging;

View File

@@ -0,0 +1,52 @@
namespace StellaOps.Policy.Explainability;
/// <summary>
/// Renders verdict rationales in multiple formats.
/// </summary>
public interface IVerdictRationaleRenderer
{
/// <summary>
/// Renders a complete verdict rationale from verdict components.
/// </summary>
VerdictRationale Render(VerdictRationaleInput input);
/// <summary>
/// Renders rationale as plain text (4-line format).
/// </summary>
string RenderPlainText(VerdictRationale rationale);
/// <summary>
/// Renders rationale as Markdown.
/// </summary>
string RenderMarkdown(VerdictRationale rationale);
/// <summary>
/// Renders rationale as canonical JSON (RFC 8785).
/// </summary>
string RenderJson(VerdictRationale rationale);
}
/// <summary>
/// Input for verdict rationale rendering.
/// </summary>
public sealed record VerdictRationaleInput
{
public required VerdictReference VerdictRef { get; init; }
public required string Cve { get; init; }
public required ComponentIdentity Component { get; init; }
public ReachabilityDetail? Reachability { get; init; }
public required string PolicyClauseId { get; init; }
public required string PolicyRuleDescription { get; init; }
public required IReadOnlyList<string> PolicyConditions { get; init; }
public AttestationReference? PathWitness { get; init; }
public IReadOnlyList<AttestationReference>? VexStatements { get; init; }
public AttestationReference? Provenance { get; init; }
public required string Verdict { get; init; }
public double? Score { get; init; }
public required string Recommendation { get; init; }
public MitigationGuidance? Mitigation { get; init; }
public required DateTimeOffset GeneratedAt { get; init; }
public required string VerdictDigest { get; init; }
public string? PolicyDigest { get; init; }
public string? EvidenceDigest { get; init; }
}

View File

@@ -0,0 +1,12 @@
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Policy.Explainability;
public static class ExplainabilityServiceCollectionExtensions
{
public static IServiceCollection AddVerdictExplainability(this IServiceCollection services)
{
services.AddSingleton<IVerdictRationaleRenderer, VerdictRationaleRenderer>();
return services;
}
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../__Libraries/StellaOps.Canonical.Json/StellaOps.Canonical.Json.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,197 @@
namespace StellaOps.Policy.Explainability;
/// <summary>
/// Structured verdict rationale following the 4-line template.
/// Line 1: Evidence summary
/// Line 2: Policy clause that triggered the decision
/// Line 3: Attestations and proofs supporting the verdict
/// Line 4: Final decision with score and recommendation
/// </summary>
public sealed record VerdictRationale
{
/// <summary>Schema version for forward compatibility.</summary>
[JsonPropertyName("schema_version")]
public string SchemaVersion { get; init; } = "1.0";
/// <summary>Unique rationale ID (content-addressed).</summary>
[JsonPropertyName("rationale_id")]
public required string RationaleId { get; init; }
/// <summary>Reference to the verdict being explained.</summary>
[JsonPropertyName("verdict_ref")]
public required VerdictReference VerdictRef { get; init; }
/// <summary>Line 1: Evidence summary.</summary>
[JsonPropertyName("evidence")]
public required RationaleEvidence Evidence { get; init; }
/// <summary>Line 2: Policy clause that triggered the decision.</summary>
[JsonPropertyName("policy_clause")]
public required RationalePolicyClause PolicyClause { get; init; }
/// <summary>Line 3: Attestations and proofs supporting the verdict.</summary>
[JsonPropertyName("attestations")]
public required RationaleAttestations Attestations { get; init; }
/// <summary>Line 4: Final decision with score and recommendation.</summary>
[JsonPropertyName("decision")]
public required RationaleDecision Decision { get; init; }
/// <summary>Generation timestamp (UTC).</summary>
[JsonPropertyName("generated_at")]
public required DateTimeOffset GeneratedAt { get; init; }
/// <summary>Input digests for reproducibility.</summary>
[JsonPropertyName("input_digests")]
public required RationaleInputDigests InputDigests { get; init; }
}
/// <summary>Reference to the verdict being explained.</summary>
public sealed record VerdictReference
{
[JsonPropertyName("attestation_id")]
public required string AttestationId { get; init; }
[JsonPropertyName("artifact_digest")]
public required string ArtifactDigest { get; init; }
[JsonPropertyName("policy_id")]
public required string PolicyId { get; init; }
[JsonPropertyName("cve")]
public string? Cve { get; init; }
[JsonPropertyName("component_purl")]
public string? ComponentPurl { get; init; }
}
/// <summary>Line 1: Evidence summary.</summary>
public sealed record RationaleEvidence
{
[JsonPropertyName("cve")]
public required string Cve { get; init; }
[JsonPropertyName("component")]
public required ComponentIdentity Component { get; init; }
[JsonPropertyName("reachability")]
public ReachabilityDetail? Reachability { get; init; }
[JsonPropertyName("formatted_text")]
public required string FormattedText { get; init; }
}
public sealed record ComponentIdentity
{
[JsonPropertyName("purl")]
public required string Purl { get; init; }
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("version")]
public string? Version { get; init; }
[JsonPropertyName("ecosystem")]
public string? Ecosystem { get; init; }
}
public sealed record ReachabilityDetail
{
[JsonPropertyName("vulnerable_function")]
public string? VulnerableFunction { get; init; }
[JsonPropertyName("entry_point")]
public string? EntryPoint { get; init; }
[JsonPropertyName("path_summary")]
public string? PathSummary { get; init; }
}
/// <summary>Line 2: Policy clause reference.</summary>
public sealed record RationalePolicyClause
{
[JsonPropertyName("clause_id")]
public required string ClauseId { get; init; }
[JsonPropertyName("rule_description")]
public required string RuleDescription { get; init; }
[JsonPropertyName("conditions")]
public required IReadOnlyList<string> Conditions { get; init; }
[JsonPropertyName("formatted_text")]
public required string FormattedText { get; init; }
}
/// <summary>Line 3: Attestations and proofs.</summary>
public sealed record RationaleAttestations
{
[JsonPropertyName("path_witness")]
public AttestationReference? PathWitness { get; init; }
[JsonPropertyName("vex_statements")]
public IReadOnlyList<AttestationReference>? VexStatements { get; init; }
[JsonPropertyName("provenance")]
public AttestationReference? Provenance { get; init; }
[JsonPropertyName("formatted_text")]
public required string FormattedText { get; init; }
}
public sealed record AttestationReference
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("type")]
public required string Type { get; init; }
[JsonPropertyName("digest")]
public string? Digest { get; init; }
[JsonPropertyName("summary")]
public string? Summary { get; init; }
}
/// <summary>Line 4: Final decision.</summary>
public sealed record RationaleDecision
{
[JsonPropertyName("verdict")]
public required string Verdict { get; init; }
[JsonPropertyName("score")]
public double? Score { get; init; }
[JsonPropertyName("recommendation")]
public required string Recommendation { get; init; }
[JsonPropertyName("mitigation")]
public MitigationGuidance? Mitigation { get; init; }
[JsonPropertyName("formatted_text")]
public required string FormattedText { get; init; }
}
public sealed record MitigationGuidance
{
[JsonPropertyName("action")]
public required string Action { get; init; }
[JsonPropertyName("details")]
public string? Details { get; init; }
}
/// <summary>Input digests for reproducibility.</summary>
public sealed record RationaleInputDigests
{
[JsonPropertyName("verdict_digest")]
public required string VerdictDigest { get; init; }
[JsonPropertyName("policy_digest")]
public string? PolicyDigest { get; init; }
[JsonPropertyName("evidence_digest")]
public string? EvidenceDigest { get; init; }
}

View File

@@ -0,0 +1,200 @@
using System.Security.Cryptography;
using StellaOps.Canonical.Json;
namespace StellaOps.Policy.Explainability;
/// <summary>
/// Renders verdict rationales in multiple formats following the 4-line template.
/// </summary>
public sealed class VerdictRationaleRenderer : IVerdictRationaleRenderer
{
private readonly ILogger<VerdictRationaleRenderer> _logger;
public VerdictRationaleRenderer(ILogger<VerdictRationaleRenderer> logger)
{
_logger = logger;
}
public VerdictRationale Render(VerdictRationaleInput input)
{
var evidence = RenderEvidence(input);
var policyClause = RenderPolicyClause(input);
var attestations = RenderAttestations(input);
var decision = RenderDecision(input);
var inputDigests = new RationaleInputDigests
{
VerdictDigest = input.VerdictDigest,
PolicyDigest = input.PolicyDigest,
EvidenceDigest = input.EvidenceDigest
};
var rationale = new VerdictRationale
{
RationaleId = string.Empty, // Will be computed below
VerdictRef = input.VerdictRef,
Evidence = evidence,
PolicyClause = policyClause,
Attestations = attestations,
Decision = decision,
GeneratedAt = input.GeneratedAt,
InputDigests = inputDigests
};
// Compute content-addressed ID
var rationaleId = ComputeRationaleId(rationale);
return rationale with { RationaleId = rationaleId };
}
public string RenderPlainText(VerdictRationale rationale)
{
var sb = new StringBuilder();
sb.AppendLine(rationale.Evidence.FormattedText);
sb.AppendLine(rationale.PolicyClause.FormattedText);
sb.AppendLine(rationale.Attestations.FormattedText);
sb.AppendLine(rationale.Decision.FormattedText);
return sb.ToString();
}
public string RenderMarkdown(VerdictRationale rationale)
{
var sb = new StringBuilder();
sb.AppendLine($"## Verdict Rationale: {rationale.Evidence.Cve}");
sb.AppendLine();
sb.AppendLine("### Evidence");
sb.AppendLine(rationale.Evidence.FormattedText);
sb.AppendLine();
sb.AppendLine("### Policy Clause");
sb.AppendLine(rationale.PolicyClause.FormattedText);
sb.AppendLine();
sb.AppendLine("### Attestations");
sb.AppendLine(rationale.Attestations.FormattedText);
sb.AppendLine();
sb.AppendLine("### Decision");
sb.AppendLine(rationale.Decision.FormattedText);
sb.AppendLine();
sb.AppendLine($"*Rationale ID: `{rationale.RationaleId}`*");
return sb.ToString();
}
public string RenderJson(VerdictRationale rationale)
{
return CanonJson.Serialize(rationale);
}
private RationaleEvidence RenderEvidence(VerdictRationaleInput input)
{
var text = new StringBuilder();
text.Append($"CVE-{input.Cve.Replace("CVE-", "")} in `{input.Component.Name ?? input.Component.Purl}` {input.Component.Version}");
if (input.Reachability != null)
{
text.Append($"; symbol `{input.Reachability.VulnerableFunction}` reachable from `{input.Reachability.EntryPoint}`");
if (!string.IsNullOrEmpty(input.Reachability.PathSummary))
{
text.Append($" ({input.Reachability.PathSummary})");
}
}
text.Append('.');
return new RationaleEvidence
{
Cve = input.Cve,
Component = input.Component,
Reachability = input.Reachability,
FormattedText = text.ToString()
};
}
private RationalePolicyClause RenderPolicyClause(VerdictRationaleInput input)
{
var text = $"Policy {input.PolicyClauseId}: {input.PolicyRuleDescription}";
if (input.PolicyConditions.Any())
{
text += $" ({string.Join(", ", input.PolicyConditions)})";
}
text += ".";
return new RationalePolicyClause
{
ClauseId = input.PolicyClauseId,
RuleDescription = input.PolicyRuleDescription,
Conditions = input.PolicyConditions,
FormattedText = text
};
}
private RationaleAttestations RenderAttestations(VerdictRationaleInput input)
{
var parts = new List<string>();
if (input.PathWitness != null)
{
parts.Add($"Path witness: {input.PathWitness.Summary ?? input.PathWitness.Id}");
}
if (input.VexStatements?.Any() == true)
{
var vexSummary = string.Join(", ", input.VexStatements.Select(v => v.Summary ?? v.Id));
parts.Add($"VEX statements: {vexSummary}");
}
if (input.Provenance != null)
{
parts.Add($"Provenance: {input.Provenance.Summary ?? input.Provenance.Id}");
}
var text = parts.Any()
? string.Join("; ", parts) + "."
: "No attestations available.";
return new RationaleAttestations
{
PathWitness = input.PathWitness,
VexStatements = input.VexStatements,
Provenance = input.Provenance,
FormattedText = text
};
}
private RationaleDecision RenderDecision(VerdictRationaleInput input)
{
var text = new StringBuilder();
text.Append($"{input.Verdict}");
if (input.Score.HasValue)
{
text.Append($" (score {input.Score.Value:F2})");
}
text.Append($". {input.Recommendation}");
if (input.Mitigation != null)
{
text.Append($": {input.Mitigation.Action}");
if (!string.IsNullOrEmpty(input.Mitigation.Details))
{
text.Append($" ({input.Mitigation.Details})");
}
}
text.Append('.');
return new RationaleDecision
{
Verdict = input.Verdict,
Score = input.Score,
Recommendation = input.Recommendation,
Mitigation = input.Mitigation,
FormattedText = text.ToString()
};
}
private string ComputeRationaleId(VerdictRationale rationale)
{
var canonicalJson = CanonJson.Serialize(rationale with { RationaleId = string.Empty });
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonicalJson));
return $"rat:sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}

View File

@@ -0,0 +1,229 @@
// <copyright file="FacetQuotaGate.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.Facet;
using StellaOps.Policy.TrustLattice;
namespace StellaOps.Policy.Gates;
/// <summary>
/// Configuration options for <see cref="FacetQuotaGate"/>.
/// </summary>
public sealed record FacetQuotaGateOptions
{
/// <summary>
/// Gets or sets a value indicating whether the gate is enabled.
/// </summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Gets or sets the action to take when no facet seal is available for comparison.
/// </summary>
public NoSealAction NoSealAction { get; init; } = NoSealAction.Pass;
/// <summary>
/// Gets or sets the default quota to apply when no facet-specific quota is configured.
/// </summary>
public FacetQuota DefaultQuota { get; init; } = FacetQuota.Default;
/// <summary>
/// Gets or sets per-facet quota overrides.
/// </summary>
public ImmutableDictionary<string, FacetQuota> FacetQuotas { get; init; } =
ImmutableDictionary<string, FacetQuota>.Empty;
}
/// <summary>
/// Specifies the action when no baseline seal is available.
/// </summary>
public enum NoSealAction
{
/// <summary>
/// Pass the gate when no seal is available (first scan).
/// </summary>
Pass,
/// <summary>
/// Warn when no seal is available.
/// </summary>
Warn,
/// <summary>
/// Block when no seal is available.
/// </summary>
Block
}
/// <summary>
/// Policy gate that enforces per-facet drift quotas.
/// This gate evaluates facet drift reports and enforces quotas configured per facet.
/// </summary>
/// <remarks>
/// The FacetQuotaGate operates on pre-computed <see cref="FacetDriftReport"/> instances,
/// which should be attached to the <see cref="PolicyGateContext"/> before evaluation.
/// If no drift report is available, the gate behavior is determined by <see cref="FacetQuotaGateOptions.NoSealAction"/>.
/// </remarks>
public sealed class FacetQuotaGate : IPolicyGate
{
private readonly FacetQuotaGateOptions _options;
private readonly IFacetDriftDetector _driftDetector;
private readonly ILogger<FacetQuotaGate> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="FacetQuotaGate"/> class.
/// </summary>
/// <param name="options">Gate configuration options.</param>
/// <param name="driftDetector">The facet drift detector.</param>
/// <param name="logger">Logger instance.</param>
public FacetQuotaGate(
FacetQuotaGateOptions? options = null,
IFacetDriftDetector? driftDetector = null,
ILogger<FacetQuotaGate>? logger = null)
{
_options = options ?? new FacetQuotaGateOptions();
_driftDetector = driftDetector ?? throw new ArgumentNullException(nameof(driftDetector));
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<FacetQuotaGate>.Instance;
}
/// <inheritdoc/>
public Task<GateResult> EvaluateAsync(
MergeResult mergeResult,
PolicyGateContext context,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(mergeResult);
ArgumentNullException.ThrowIfNull(context);
// Check if gate is enabled
if (!_options.Enabled)
{
return Task.FromResult(Pass("Gate disabled"));
}
// Check for drift report in metadata
var driftReport = GetDriftReportFromContext(context);
if (driftReport is null)
{
return Task.FromResult(HandleNoSeal());
}
// Evaluate drift report against quotas
var result = EvaluateDriftReport(driftReport);
return Task.FromResult(result);
}
private static FacetDriftReport? GetDriftReportFromContext(PolicyGateContext context)
{
// Drift report is expected to be in metadata under a well-known key
if (context.Metadata?.TryGetValue("FacetDriftReport", out var value) == true &&
value is string json)
{
// In a real implementation, deserialize from JSON
// For now, return null to trigger the no-seal path
return null;
}
return null;
}
private GateResult HandleNoSeal()
{
return _options.NoSealAction switch
{
NoSealAction.Pass => Pass("No baseline seal available - first scan"),
NoSealAction.Warn => new GateResult
{
GateName = nameof(FacetQuotaGate),
Passed = true,
Reason = "no_baseline_seal",
Details = ImmutableDictionary<string, object>.Empty
.Add("action", "warn")
.Add("message", "No baseline seal available for comparison")
},
NoSealAction.Block => new GateResult
{
GateName = nameof(FacetQuotaGate),
Passed = false,
Reason = "no_baseline_seal",
Details = ImmutableDictionary<string, object>.Empty
.Add("action", "block")
.Add("message", "Baseline seal required but not available")
},
_ => Pass("Unknown NoSealAction - defaulting to pass")
};
}
private GateResult EvaluateDriftReport(FacetDriftReport report)
{
// Find worst verdict across all facets
var worstVerdict = report.OverallVerdict;
var breachedFacets = report.FacetDrifts
.Where(d => d.QuotaVerdict != QuotaVerdict.Ok)
.ToList();
if (breachedFacets.Count == 0)
{
_logger.LogDebug("All facets within quota limits");
return Pass("All facets within quota limits");
}
// Build details
var details = ImmutableDictionary<string, object>.Empty
.Add("overallVerdict", worstVerdict.ToString())
.Add("breachedFacets", breachedFacets.Select(f => f.FacetId).ToArray())
.Add("totalChangedFiles", report.TotalChangedFiles)
.Add("imageDigest", report.ImageDigest);
foreach (var facet in breachedFacets)
{
details = details.Add(
$"facet:{facet.FacetId}",
new Dictionary<string, object>
{
["verdict"] = facet.QuotaVerdict.ToString(),
["churnPercent"] = facet.ChurnPercent,
["added"] = facet.Added.Length,
["removed"] = facet.Removed.Length,
["modified"] = facet.Modified.Length
});
}
return worstVerdict switch
{
QuotaVerdict.Ok => Pass("All quotas satisfied"),
QuotaVerdict.Warning => new GateResult
{
GateName = nameof(FacetQuotaGate),
Passed = true,
Reason = "quota_warning",
Details = details
},
QuotaVerdict.Blocked => new GateResult
{
GateName = nameof(FacetQuotaGate),
Passed = false,
Reason = "quota_exceeded",
Details = details
},
QuotaVerdict.RequiresVex => new GateResult
{
GateName = nameof(FacetQuotaGate),
Passed = false,
Reason = "requires_vex_authorization",
Details = details.Add("vexRequired", true)
},
_ => Pass("Unknown verdict - defaulting to pass")
};
}
private static GateResult Pass(string reason) => new()
{
GateName = nameof(FacetQuotaGate),
Passed = true,
Reason = reason,
Details = ImmutableDictionary<string, object>.Empty
};
}

View File

@@ -0,0 +1,73 @@
// <copyright file="FacetQuotaGateServiceCollectionExtensions.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Facet;
namespace StellaOps.Policy.Gates;
/// <summary>
/// Extension methods for registering <see cref="FacetQuotaGate"/> with dependency injection.
/// </summary>
public static class FacetQuotaGateServiceCollectionExtensions
{
/// <summary>
/// Adds the <see cref="FacetQuotaGate"/> to the service collection with default options.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFacetQuotaGate(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
return services.AddFacetQuotaGate(_ => { });
}
/// <summary>
/// Adds the <see cref="FacetQuotaGate"/> to the service collection with custom configuration.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configure">Action to configure <see cref="FacetQuotaGateOptions"/>.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFacetQuotaGate(
this IServiceCollection services,
Action<FacetQuotaGateOptions> configure)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
var options = new FacetQuotaGateOptions();
configure(options);
// Ensure facet drift detector is registered
services.TryAddSingleton<IFacetDriftDetector>(sp =>
{
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
return new FacetDriftDetector(timeProvider);
});
// Register the gate options
services.AddSingleton(options);
// Register the gate
services.TryAddSingleton<FacetQuotaGate>();
return services;
}
/// <summary>
/// Registers the <see cref="FacetQuotaGate"/> with a <see cref="IPolicyGateRegistry"/>.
/// </summary>
/// <param name="registry">The policy gate registry.</param>
/// <param name="gateName">Optional custom gate name. Defaults to "facet-quota".</param>
/// <returns>The registry for chaining.</returns>
public static IPolicyGateRegistry RegisterFacetQuotaGate(
this IPolicyGateRegistry registry,
string gateName = "facet-quota")
{
ArgumentNullException.ThrowIfNull(registry);
registry.Register<FacetQuotaGate>(gateName);
return registry;
}
}

View File

@@ -1,17 +1,51 @@
using System;
using System.Collections.Immutable;
using StellaOps.Policy.Determinization.Models;
namespace StellaOps.Policy;
/// <summary>
/// Runtime monitoring requirements for GuardedPass verdicts.
/// </summary>
/// <param name="MonitoringIntervalDays">Days between re-evaluation checks.</param>
/// <param name="RequireProof">Whether runtime proof is required before production deployment.</param>
/// <param name="AlertOnChange">Whether to send alerts if verdict changes on re-evaluation.</param>
public sealed record GuardRails(
int MonitoringIntervalDays,
bool RequireProof,
bool AlertOnChange);
/// <summary>
/// Status outcomes for policy verdicts.
/// </summary>
public enum PolicyVerdictStatus
{
Pass,
Blocked,
Ignored,
Warned,
Deferred,
Escalated,
RequiresVex,
/// <summary>Finding meets policy requirements.</summary>
Pass = 0,
/// <summary>
/// Finding allowed with runtime monitoring enabled.
/// Used for uncertain observations that don't exceed risk thresholds.
/// </summary>
GuardedPass = 1,
/// <summary>Finding fails policy checks; must be remediated.</summary>
Blocked = 2,
/// <summary>Finding deliberately ignored via exception.</summary>
Ignored = 3,
/// <summary>Finding passes but with warnings.</summary>
Warned = 4,
/// <summary>Decision deferred; needs additional evidence.</summary>
Deferred = 5,
/// <summary>Decision escalated for human review.</summary>
Escalated = 6,
/// <summary>VEX statement required to make decision.</summary>
RequiresVex = 7
}
public sealed record PolicyVerdict(
@@ -29,8 +63,20 @@ public sealed record PolicyVerdict(
string? ConfidenceBand = null,
double? UnknownAgeDays = null,
string? SourceTrust = null,
string? Reachability = null)
string? Reachability = null,
GuardRails? GuardRails = null,
UncertaintyScore? UncertaintyScore = null,
ObservationState? SuggestedObservationState = null)
{
/// <summary>
/// Whether this verdict allows the finding to proceed (Pass or GuardedPass).
/// </summary>
public bool IsAllowing => Status is PolicyVerdictStatus.Pass or PolicyVerdictStatus.GuardedPass;
/// <summary>
/// Whether this verdict requires monitoring (GuardedPass only).
/// </summary>
public bool RequiresMonitoring => Status == PolicyVerdictStatus.GuardedPass;
public static PolicyVerdict CreateBaseline(string findingId, PolicyScoringConfig scoringConfig)
{
var inputs = ImmutableDictionary<string, double>.Empty;
@@ -49,7 +95,10 @@ public sealed record PolicyVerdict(
ConfidenceBand: null,
UnknownAgeDays: null,
SourceTrust: null,
Reachability: null);
Reachability: null,
GuardRails: null,
UncertaintyScore: null,
SuggestedObservationState: null);
}
public ImmutableDictionary<string, double> GetInputs()

View File

@@ -28,9 +28,11 @@
<ItemGroup>
<ProjectReference Include="../../StellaOps.Policy.RiskProfile/StellaOps.Policy.RiskProfile.csproj" />
<ProjectReference Include="../StellaOps.Policy.Determinization/StellaOps.Policy.Determinization.csproj" />
<ProjectReference Include="../../../Attestor/__Libraries/StellaOps.Attestor.ProofChain/StellaOps.Attestor.ProofChain.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Canonical.Json/StellaOps.Canonical.Json.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Facet/StellaOps.Facet.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,114 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Policy.Determinization.Scoring;
using Xunit;
namespace StellaOps.Policy.Determinization.Tests;
public class DecayedConfidenceCalculatorTests
{
private readonly DecayedConfidenceCalculator _calculator;
public DecayedConfidenceCalculatorTests()
{
_calculator = new DecayedConfidenceCalculator(NullLogger<DecayedConfidenceCalculator>.Instance);
}
[Fact]
public void Calculate_ZeroAge_ReturnsBaseConfidence()
{
// Arrange
var baseConfidence = 0.8;
var ageDays = 0.0;
// Act
var result = _calculator.Calculate(baseConfidence, ageDays);
// Assert
result.Should().Be(baseConfidence);
}
[Fact]
public void Calculate_HalfLife_ReturnsHalfConfidence()
{
// Arrange
var baseConfidence = 1.0;
var halfLifeDays = 14.0;
var ageDays = 14.0;
// Act
var result = _calculator.Calculate(baseConfidence, ageDays, halfLifeDays);
// Assert
result.Should().BeApproximately(0.5, 0.01);
}
[Fact]
public void Calculate_TwoHalfLives_ReturnsQuarterConfidence()
{
// Arrange
var baseConfidence = 1.0;
var halfLifeDays = 14.0;
var ageDays = 28.0;
// Act
var result = _calculator.Calculate(baseConfidence, ageDays, halfLifeDays);
// Assert
result.Should().BeApproximately(0.25, 0.01);
}
[Fact]
public void Calculate_VeryOld_ReturnsFloor()
{
// Arrange
var baseConfidence = 1.0;
var halfLifeDays = 14.0;
var ageDays = 200.0;
var floor = 0.1;
// Act
var result = _calculator.Calculate(baseConfidence, ageDays, halfLifeDays, floor);
// Assert
result.Should().Be(floor);
}
[Fact]
public void CalculateDecayFactor_ZeroAge_ReturnsOne()
{
// Act
var factor = _calculator.CalculateDecayFactor(0.0);
// Assert
factor.Should().Be(1.0);
}
[Fact]
public void CalculateDecayFactor_HalfLife_ReturnsHalf()
{
// Act
var factor = _calculator.CalculateDecayFactor(14.0, 14.0);
// Assert
factor.Should().BeApproximately(0.5, 0.01);
}
[Theory]
[InlineData(-0.1)]
[InlineData(1.1)]
public void Calculate_InvalidBaseConfidence_ThrowsArgumentOutOfRange(double invalidConfidence)
{
// Act & Assert
var act = () => _calculator.Calculate(invalidConfidence, 10.0);
act.Should().Throw<ArgumentOutOfRangeException>();
}
[Fact]
public void Calculate_NegativeAge_ThrowsArgumentOutOfRange()
{
// Act & Assert
var act = () => _calculator.Calculate(0.8, -1.0);
act.Should().Throw<ArgumentOutOfRangeException>();
}
}

View File

@@ -0,0 +1,122 @@
// Copyright © 2025 StellaOps Contributors
// Licensed under AGPL-3.0-or-later
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Policy.Determinization;
using StellaOps.Policy.Determinization.Models;
using StellaOps.Policy.Determinization.Scoring;
using Xunit;
namespace StellaOps.Policy.Determinization.Tests.Integration;
[Trait("Category", "Unit")]
public sealed class ServiceRegistrationIntegrationTests
{
[Fact]
public void AddDeterminization_WithConfiguration_RegistersAllServices()
{
// Arrange
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Determinization:ConfidenceHalfLifeDays"] = "21",
["Determinization:ConfidenceFloor"] = "0.15",
["Determinization:ManualReviewEntropyThreshold"] = "0.65",
["Determinization:SignalWeights:VexWeight"] = "0.30",
["Determinization:SignalWeights:EpssWeight"] = "0.15"
})
.Build();
var services = new ServiceCollection();
services.AddSingleton(TimeProvider.System);
services.AddLogging();
// Act
services.AddDeterminization(configuration);
var provider = services.BuildServiceProvider();
// Assert - verify all services are registered
provider.GetService<IUncertaintyScoreCalculator>().Should().NotBeNull();
provider.GetService<IDecayedConfidenceCalculator>().Should().NotBeNull();
provider.GetService<TrustScoreAggregator>().Should().NotBeNull();
}
[Fact]
public void AddDeterminization_WithConfigureAction_RegistersAllServices()
{
// Arrange
var services = new ServiceCollection();
services.AddSingleton(TimeProvider.System);
services.AddLogging();
// Act
services.AddDeterminization(options =>
{
// Options are immutable records, so can't mutate
// This tests that the configure action is called
});
var provider = services.BuildServiceProvider();
// Assert
provider.GetService<IUncertaintyScoreCalculator>().Should().NotBeNull();
provider.GetService<IDecayedConfidenceCalculator>().Should().NotBeNull();
provider.GetService<TrustScoreAggregator>().Should().NotBeNull();
}
[Fact]
public void RegisteredServices_AreResolvableAndFunctional()
{
// Arrange
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
var services = new ServiceCollection();
services.AddSingleton(TimeProvider.System);
services.AddLogging(b => b.AddProvider(NullLoggerProvider.Instance));
services.AddDeterminization(configuration);
var provider = services.BuildServiceProvider();
// Act - resolve and use services
var uncertaintyCalc = provider.GetRequiredService<IUncertaintyScoreCalculator>();
var decayCalc = provider.GetRequiredService<IDecayedConfidenceCalculator>();
var trustAgg = provider.GetRequiredService<TrustScoreAggregator>();
// Test uncertainty calculator
var snapshot = SignalSnapshot.Empty("CVE-2024-1234", "pkg:maven/test@1.0", DateTimeOffset.UtcNow);
// Assert - verify they work
var score = uncertaintyCalc.Calculate(snapshot);
score.Entropy.Should().Be(1.0); // All signals missing = maximum entropy
var decayed = decayCalc.Calculate(baseConfidence: 0.9, ageDays: 14.0, halfLifeDays: 14.0);
decayed.Should().BeApproximately(0.45, 0.01); // Half-life decay
// Trust aggregator requires an uncertainty score
var trust = trustAgg.Aggregate(snapshot, score);
trust.Should().BeInRange(0.0, 1.0);
}
[Fact]
public void RegisteredServices_AreSingletons()
{
// Arrange
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>()).Build();
var services = new ServiceCollection();
services.AddSingleton(TimeProvider.System);
services.AddLogging();
services.AddDeterminization(configuration);
var provider = services.BuildServiceProvider();
// Act - resolve same service multiple times
var calc1 = provider.GetService<IUncertaintyScoreCalculator>();
var calc2 = provider.GetService<IUncertaintyScoreCalculator>();
// Assert - should be same instance (singleton)
calc1.Should().BeSameAs(calc2);
}
}

View File

@@ -0,0 +1,80 @@
using FluentAssertions;
using StellaOps.Policy.Determinization.Models;
using Xunit;
namespace StellaOps.Policy.Determinization.Tests.Models;
public class DeterminizationResultTests
{
[Fact]
public void Determined_Should_CreateCorrectResult()
{
// Arrange
var uncertainty = UncertaintyScore.Zero(1.0, DateTimeOffset.UtcNow);
var decay = ObservationDecay.Fresh(DateTimeOffset.UtcNow);
var context = DeterminizationContext.Production();
var evaluatedAt = DateTimeOffset.UtcNow;
// Act
var result = DeterminizationResult.Determined(uncertainty, decay, context, evaluatedAt);
// Assert
result.State.Should().Be(ObservationState.Determined);
result.Guardrails.Should().NotBeNull();
result.Guardrails!.EnableMonitoring.Should().BeFalse();
}
[Fact]
public void Pending_Should_ApplyGuardrails()
{
// Arrange
var uncertainty = UncertaintyScore.Create(0.6, Array.Empty<SignalGap>(), 0.4, 1.0, DateTimeOffset.UtcNow);
var decay = ObservationDecay.Fresh(DateTimeOffset.UtcNow);
var guardrails = GuardRails.Strict();
var context = DeterminizationContext.Production();
var evaluatedAt = DateTimeOffset.UtcNow;
// Act
var result = DeterminizationResult.Pending(uncertainty, decay, guardrails, context, evaluatedAt);
// Assert
result.State.Should().Be(ObservationState.PendingDeterminization);
result.Guardrails.Should().NotBeNull();
result.Guardrails!.EnableMonitoring.Should().BeTrue();
}
[Fact]
public void Stale_Should_RequireRefresh()
{
// Arrange
var uncertainty = UncertaintyScore.Zero(1.0, DateTimeOffset.UtcNow);
var decay = ObservationDecay.Create(DateTimeOffset.UtcNow.AddDays(-30), DateTimeOffset.UtcNow.AddDays(-30));
var context = DeterminizationContext.Production();
var evaluatedAt = DateTimeOffset.UtcNow;
// Act
var result = DeterminizationResult.Stale(uncertainty, decay, context, evaluatedAt);
// Assert
result.State.Should().Be(ObservationState.StaleRequiresRefresh);
result.Guardrails.Should().NotBeNull();
}
[Fact]
public void Disputed_Should_IncludeReason()
{
// Arrange
var uncertainty = UncertaintyScore.Create(0.7, Array.Empty<SignalGap>(), 0.3, 1.0, DateTimeOffset.UtcNow);
var decay = ObservationDecay.Fresh(DateTimeOffset.UtcNow);
var context = DeterminizationContext.Production();
var evaluatedAt = DateTimeOffset.UtcNow;
var reason = "VEX says not_affected but reachability analysis shows vulnerable path";
// Act
var result = DeterminizationResult.Disputed(uncertainty, decay, context, evaluatedAt, reason);
// Assert
result.State.Should().Be(ObservationState.Disputed);
result.Rationale.Should().Contain(reason);
}
}

View File

@@ -0,0 +1,87 @@
using FluentAssertions;
using StellaOps.Policy.Determinization.Models;
using Xunit;
namespace StellaOps.Policy.Determinization.Tests.Models;
public class ObservationDecayTests
{
[Fact]
public void Fresh_Should_CreateZeroAgeDecay()
{
// Arrange
var now = DateTimeOffset.UtcNow;
// Act
var decay = ObservationDecay.Fresh(now);
// Assert
decay.ObservedAt.Should().Be(now);
decay.RefreshedAt.Should().Be(now);
decay.CalculateDecay(now).Should().Be(1.0);
}
[Fact]
public void CalculateDecay_Should_ApplyHalfLifeFormula()
{
// Arrange
var observedAt = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
var decay = ObservationDecay.Create(observedAt, observedAt);
// After 14 days (one half-life), decay should be ~0.5
var after14Days = observedAt.AddDays(14);
// Act
var decayValue = decay.CalculateDecay(after14Days);
// Assert
decayValue.Should().BeApproximately(0.5, 0.01);
}
[Fact]
public void CalculateDecay_Should_NotDropBelowFloor()
{
// Arrange
var observedAt = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
var decay = ObservationDecay.Create(observedAt, observedAt);
// Very old observation (1 year)
var afterYear = observedAt.AddDays(365);
// Act
var decayValue = decay.CalculateDecay(afterYear);
// Assert
decayValue.Should().BeGreaterThanOrEqualTo(decay.Floor);
}
[Fact]
public void IsStale_Should_DetectStaleObservations()
{
// Arrange
var observedAt = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
var decay = ObservationDecay.Create(observedAt, observedAt);
// Decay drops below 0.5 threshold around 14 days
var before = observedAt.AddDays(10);
var after = observedAt.AddDays(20);
// Act & Assert
decay.IsStale(before).Should().BeFalse();
decay.IsStale(after).Should().BeTrue();
}
[Fact]
public void CalculateDecay_Should_ReturnOneForFutureDates()
{
// Arrange
var now = DateTimeOffset.UtcNow;
var decay = ObservationDecay.Fresh(now);
// Act (future date, should not decay)
var futureDecay = decay.CalculateDecay(now.AddDays(-1));
// Assert
futureDecay.Should().Be(1.0);
}
}

View File

@@ -0,0 +1,32 @@
using FluentAssertions;
using StellaOps.Policy.Determinization.Models;
using Xunit;
namespace StellaOps.Policy.Determinization.Tests.Models;
public class SignalSnapshotTests
{
[Fact]
public void Empty_Should_CreateAllNotQueriedSignals()
{
// Arrange
var cve = "CVE-2024-1234";
var purl = "pkg:maven/org.example/lib@1.0.0";
var snapshotAt = DateTimeOffset.UtcNow;
// Act
var snapshot = SignalSnapshot.Empty(cve, purl, snapshotAt);
// Assert
snapshot.Cve.Should().Be(cve);
snapshot.Purl.Should().Be(purl);
snapshot.SnapshotAt.Should().Be(snapshotAt);
snapshot.Epss.IsNotQueried.Should().BeTrue();
snapshot.Vex.IsNotQueried.Should().BeTrue();
snapshot.Reachability.IsNotQueried.Should().BeTrue();
snapshot.Runtime.IsNotQueried.Should().BeTrue();
snapshot.Backport.IsNotQueried.Should().BeTrue();
snapshot.Sbom.IsNotQueried.Should().BeTrue();
snapshot.Cvss.IsNotQueried.Should().BeTrue();
}
}

View File

@@ -0,0 +1,83 @@
using FluentAssertions;
using StellaOps.Policy.Determinization.Models;
using Xunit;
namespace StellaOps.Policy.Determinization.Tests.Models;
public class SignalStateTests
{
[Fact]
public void NotQueried_Should_CreateCorrectState()
{
// Act
var state = SignalState<string>.NotQueried();
// Assert
state.Status.Should().Be(SignalQueryStatus.NotQueried);
state.Value.Should().BeNull();
state.QueriedAt.Should().BeNull();
state.Error.Should().BeNull();
state.IsNotQueried.Should().BeTrue();
state.HasValue.Should().BeFalse();
state.IsFailed.Should().BeFalse();
}
[Fact]
public void Queried_WithValue_Should_CreateCorrectState()
{
// Arrange
var value = "test-value";
var queriedAt = DateTimeOffset.UtcNow;
// Act
var state = SignalState<string>.Queried(value, queriedAt);
// Assert
state.Status.Should().Be(SignalQueryStatus.Queried);
state.Value.Should().Be(value);
state.QueriedAt.Should().Be(queriedAt);
state.Error.Should().BeNull();
state.HasValue.Should().BeTrue();
state.IsNotQueried.Should().BeFalse();
state.IsFailed.Should().BeFalse();
}
[Fact]
public void Queried_WithNull_Should_CreateCorrectState()
{
// Arrange
var queriedAt = DateTimeOffset.UtcNow;
// Act
var state = SignalState<string>.Queried(null, queriedAt);
// Assert
state.Status.Should().Be(SignalQueryStatus.Queried);
state.Value.Should().BeNull();
state.QueriedAt.Should().Be(queriedAt);
state.Error.Should().BeNull();
state.HasValue.Should().BeFalse();
state.IsNotQueried.Should().BeFalse();
state.IsFailed.Should().BeFalse();
}
[Fact]
public void Failed_Should_CreateCorrectState()
{
// Arrange
var error = "Network timeout";
var attemptedAt = DateTimeOffset.UtcNow;
// Act
var state = SignalState<string>.Failed(error, attemptedAt);
// Assert
state.Status.Should().Be(SignalQueryStatus.Failed);
state.Value.Should().BeNull();
state.QueriedAt.Should().Be(attemptedAt);
state.Error.Should().Be(error);
state.IsFailed.Should().BeTrue();
state.HasValue.Should().BeFalse();
state.IsNotQueried.Should().BeFalse();
}
}

View File

@@ -0,0 +1,66 @@
using FluentAssertions;
using StellaOps.Policy.Determinization.Models;
using Xunit;
namespace StellaOps.Policy.Determinization.Tests.Models;
public class UncertaintyScoreTests
{
[Theory]
[InlineData(0.0, UncertaintyTier.Minimal)]
[InlineData(0.1, UncertaintyTier.Minimal)]
[InlineData(0.2, UncertaintyTier.Low)]
[InlineData(0.3, UncertaintyTier.Low)]
[InlineData(0.4, UncertaintyTier.Moderate)]
[InlineData(0.5, UncertaintyTier.Moderate)]
[InlineData(0.6, UncertaintyTier.High)]
[InlineData(0.7, UncertaintyTier.High)]
[InlineData(0.8, UncertaintyTier.Critical)]
[InlineData(0.9, UncertaintyTier.Critical)]
[InlineData(1.0, UncertaintyTier.Critical)]
public void Create_Should_MapEntropyToCorrectTier(double entropy, UncertaintyTier expectedTier)
{
// Arrange
var gaps = Array.Empty<SignalGap>();
var calculatedAt = DateTimeOffset.UtcNow;
// Act
var score = UncertaintyScore.Create(entropy, gaps, 1.0, 1.0, calculatedAt);
// Assert
score.Tier.Should().Be(expectedTier);
score.Entropy.Should().Be(entropy);
}
[Fact]
public void Create_Should_ThrowOnInvalidEntropy()
{
// Arrange
var gaps = Array.Empty<SignalGap>();
var calculatedAt = DateTimeOffset.UtcNow;
// Act & Assert
Assert.Throws<ArgumentOutOfRangeException>(() =>
UncertaintyScore.Create(-0.1, gaps, 1.0, 1.0, calculatedAt));
Assert.Throws<ArgumentOutOfRangeException>(() =>
UncertaintyScore.Create(1.1, gaps, 1.0, 1.0, calculatedAt));
}
[Fact]
public void Zero_Should_CreateMinimalUncertainty()
{
// Arrange
var maxWeight = 1.0;
var calculatedAt = DateTimeOffset.UtcNow;
// Act
var score = UncertaintyScore.Zero(maxWeight, calculatedAt);
// Assert
score.Entropy.Should().Be(0.0);
score.Tier.Should().Be(UncertaintyTier.Minimal);
score.Gaps.Should().BeEmpty();
score.PresentWeight.Should().Be(maxWeight);
score.MaxWeight.Should().Be(maxWeight);
}
}

View File

@@ -0,0 +1,245 @@
// -----------------------------------------------------------------------------
// DecayPropertyTests.cs
// Sprint: SPRINT_20260106_001_002_LB_determinization_scoring
// Task: DCS-022 - Write property tests: decay monotonically decreasing
// Description: Property-based tests ensuring decay is monotonically decreasing
// as age increases.
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Policy.Determinization.Scoring;
using Xunit;
namespace StellaOps.Policy.Determinization.Tests.PropertyTests;
/// <summary>
/// Property tests verifying decay behavior.
/// DCS-022: decay must be monotonically decreasing as age increases.
/// </summary>
[Trait("Category", "Unit")]
[Trait("Property", "DecayMonotonicity")]
public class DecayPropertyTests
{
private readonly DecayedConfidenceCalculator _calculator;
public DecayPropertyTests()
{
_calculator = new DecayedConfidenceCalculator(NullLogger<DecayedConfidenceCalculator>.Instance);
}
/// <summary>
/// Property: Decay is monotonically decreasing as age increases.
/// For any a1 less than a2, decay(a1) >= decay(a2).
/// </summary>
[Theory]
[InlineData(0, 1)]
[InlineData(1, 7)]
[InlineData(7, 14)]
[InlineData(14, 28)]
[InlineData(28, 90)]
[InlineData(90, 365)]
public void Decay_AsAgeIncreases_NeverIncreases(int youngerDays, int olderDays)
{
// Arrange
var halfLifeDays = 14.0;
// Act
var youngerDecay = _calculator.CalculateDecayFactor(youngerDays, halfLifeDays);
var olderDecay = _calculator.CalculateDecayFactor(olderDays, halfLifeDays);
// Assert
youngerDecay.Should().BeGreaterThanOrEqualTo(olderDecay,
$"decay at age {youngerDays}d should be >= decay at age {olderDays}d");
}
/// <summary>
/// Property: At age 0, decay is exactly 1.0.
/// </summary>
[Theory]
[InlineData(7)]
[InlineData(14)]
[InlineData(30)]
[InlineData(90)]
public void Decay_AtAgeZero_IsOne(double halfLifeDays)
{
// Act
var decay = _calculator.CalculateDecayFactor(0, halfLifeDays);
// Assert
decay.Should().Be(1.0, "decay at age 0 should be 1.0");
}
/// <summary>
/// Property: At age = half-life, decay is approximately 0.5.
/// </summary>
[Theory]
[InlineData(7)]
[InlineData(14)]
[InlineData(30)]
[InlineData(90)]
public void Decay_AtHalfLife_IsApproximatelyHalf(double halfLifeDays)
{
// Act
var decay = _calculator.CalculateDecayFactor(halfLifeDays, halfLifeDays);
// Assert
decay.Should().BeApproximately(0.5, 0.01,
$"decay at half-life ({halfLifeDays}d) should be ~0.5");
}
/// <summary>
/// Property: At age = 2 * half-life, decay is approximately 0.25.
/// </summary>
[Theory]
[InlineData(7)]
[InlineData(14)]
[InlineData(30)]
public void Decay_AtTwoHalfLives_IsApproximatelyQuarter(double halfLifeDays)
{
// Act
var decay = _calculator.CalculateDecayFactor(halfLifeDays * 2, halfLifeDays);
// Assert
decay.Should().BeApproximately(0.25, 0.01,
$"decay at 2x half-life ({halfLifeDays * 2}d) should be ~0.25");
}
/// <summary>
/// Property: Decay is always in (0, 1] for non-negative age.
/// </summary>
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(7)]
[InlineData(14)]
[InlineData(30)]
[InlineData(90)]
[InlineData(365)]
[InlineData(1000)]
public void Decay_ForAnyNonNegativeAge_IsBetweenZeroAndOne(double ageDays)
{
// Arrange
var halfLifeDays = 14.0;
// Act
var decay = _calculator.CalculateDecayFactor(ageDays, halfLifeDays);
// Assert
decay.Should().BeGreaterThan(0.0, "decay should never reach 0");
decay.Should().BeLessThanOrEqualTo(1.0, "decay should never exceed 1");
}
/// <summary>
/// Property: Calculate() with floor ensures result never goes below floor.
/// </summary>
[Theory]
[InlineData(0.01)]
[InlineData(0.05)]
[InlineData(0.1)]
public void Calculate_AtExtremeAge_NeverGoesBelowFloor(double floor)
{
// Arrange - very old observation (10 years)
var ageDays = 3650;
var halfLifeDays = 14.0;
var baseConfidence = 1.0;
// Act - using Calculate which applies floor
var decayed = _calculator.Calculate(baseConfidence, ageDays, halfLifeDays, floor);
// Assert
decayed.Should().BeGreaterThanOrEqualTo(floor,
$"decayed confidence should never go below floor {floor}");
}
/// <summary>
/// Property: Raw decay factor can approach but never go below 0.
/// </summary>
[Fact]
public void DecayFactor_AtExtremeAge_ApproachesZeroButNeverNegative()
{
// Arrange - very old observation (10 years)
var ageDays = 3650;
var halfLifeDays = 14.0;
// Act
var decayFactor = _calculator.CalculateDecayFactor(ageDays, halfLifeDays);
// Assert
decayFactor.Should().BeGreaterThanOrEqualTo(0.0, "decay factor should never be negative");
decayFactor.Should().BeLessThanOrEqualTo(1.0, "decay factor should never exceed 1");
}
/// <summary>
/// Property: Sequence of consecutive days has strictly decreasing decay.
/// </summary>
[Fact]
public void Decay_ConsecutiveDays_StrictlyDecreasing()
{
// Arrange
var halfLifeDays = 14.0;
var previousDecay = double.MaxValue;
// Act & Assert - check 100 consecutive days
for (var day = 0; day < 100; day++)
{
var currentDecay = _calculator.CalculateDecayFactor(day, halfLifeDays);
if (day > 0)
{
currentDecay.Should().BeLessThan(previousDecay,
$"decay at day {day} should be less than day {day - 1}");
}
previousDecay = currentDecay;
}
}
/// <summary>
/// Property: Shorter half-life decays faster than longer half-life.
/// </summary>
[Theory]
[InlineData(7, 14)]
[InlineData(14, 30)]
[InlineData(30, 90)]
public void Decay_ShorterHalfLife_DecaysFaster(double shortHalfLife, double longHalfLife)
{
// Arrange - use an age greater than both half-lives
var ageDays = Math.Max(shortHalfLife, longHalfLife) * 2;
// Act
var shortDecay = _calculator.CalculateDecayFactor(ageDays, shortHalfLife);
var longDecay = _calculator.CalculateDecayFactor(ageDays, longHalfLife);
// Assert
shortDecay.Should().BeLessThan(longDecay,
$"shorter half-life ({shortHalfLife}d) should decay faster than longer ({longHalfLife}d)");
}
/// <summary>
/// Property: Zero or negative half-life should not crash (edge case).
/// </summary>
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(-14)]
public void Decay_WithInvalidHalfLife_DoesNotThrowOrReturnsReasonableValue(double halfLifeDays)
{
// Act
var act = () => _calculator.CalculateDecayFactor(7, halfLifeDays);
// Assert - implementation may throw or return clamped value
// We just verify it doesn't crash with unhandled exception
try
{
var result = act();
// If it returns a value, it should still be bounded
result.Should().BeGreaterThanOrEqualTo(0.0);
result.Should().BeLessThanOrEqualTo(1.0);
}
catch (ArgumentException)
{
// This is acceptable - throwing for invalid input is valid behavior
}
}
}

View File

@@ -0,0 +1,275 @@
// -----------------------------------------------------------------------------
// DeterminismPropertyTests.cs
// Sprint: SPRINT_20260106_001_002_LB_determinization_scoring
// Task: DCS-023 - Write determinism tests: same snapshot same entropy
// Description: Property-based tests ensuring identical inputs produce identical
// outputs across multiple invocations and calculator instances.
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Policy.Determinization.Evidence;
using StellaOps.Policy.Determinization.Models;
using StellaOps.Policy.Determinization.Scoring;
using Xunit;
namespace StellaOps.Policy.Determinization.Tests.PropertyTests;
/// <summary>
/// Property tests verifying determinism.
/// DCS-023: same inputs must yield same outputs, always.
/// </summary>
[Trait("Category", "Unit")]
[Trait("Property", "Determinism")]
public class DeterminismPropertyTests
{
private readonly DateTimeOffset _fixedTime = new(2026, 1, 7, 12, 0, 0, TimeSpan.Zero);
/// <summary>
/// Property: Same snapshot produces same entropy on repeated calls.
/// </summary>
[Fact]
public void Entropy_SameSnapshot_ProducesSameResult()
{
// Arrange
var calculator = new UncertaintyScoreCalculator(NullLogger<UncertaintyScoreCalculator>.Instance);
var snapshot = CreateDeterministicSnapshot();
// Act - calculate 10 times
var results = new List<double>();
for (var i = 0; i < 10; i++)
{
results.Add(calculator.CalculateEntropy(snapshot));
}
// Assert - all results should be identical
results.Distinct().Should().HaveCount(1, "same input should always produce same entropy");
}
/// <summary>
/// Property: Different calculator instances produce same entropy for same snapshot.
/// </summary>
[Fact]
public void Entropy_DifferentInstances_ProduceSameResult()
{
// Arrange
var snapshot = CreateDeterministicSnapshot();
// Act - create multiple instances and calculate
var results = new List<double>();
for (var i = 0; i < 5; i++)
{
var calculator = new UncertaintyScoreCalculator(NullLogger<UncertaintyScoreCalculator>.Instance);
results.Add(calculator.CalculateEntropy(snapshot));
}
// Assert - all results should be identical
results.Distinct().Should().HaveCount(1, "different instances should produce same entropy for same input");
}
/// <summary>
/// Property: Parallel execution produces consistent results.
/// </summary>
[Fact]
public void Entropy_ParallelExecution_ProducesConsistentResults()
{
// Arrange
var calculator = new UncertaintyScoreCalculator(NullLogger<UncertaintyScoreCalculator>.Instance);
var snapshot = CreateDeterministicSnapshot();
// Act - calculate in parallel
var tasks = Enumerable.Range(0, 100)
.Select(_ => Task.Run(() => calculator.CalculateEntropy(snapshot)))
.ToArray();
Task.WaitAll(tasks);
var results = tasks.Select(t => t.Result).ToList();
// Assert - all results should be identical
results.Distinct().Should().HaveCount(1, "parallel execution should produce consistent results");
}
/// <summary>
/// Property: Same decay calculation produces same result.
/// </summary>
[Fact]
public void Decay_SameInputs_ProducesSameResult()
{
// Arrange
var calculator = new DecayedConfidenceCalculator(NullLogger<DecayedConfidenceCalculator>.Instance);
var ageDays = 7.0;
var halfLifeDays = 14.0;
// Act - calculate 10 times
var results = new List<double>();
for (var i = 0; i < 10; i++)
{
results.Add(calculator.CalculateDecayFactor(ageDays, halfLifeDays));
}
// Assert - all results should be identical
results.Distinct().Should().HaveCount(1, "same input should always produce same decay factor");
}
/// <summary>
/// Property: Same snapshot with same weights produces same entropy.
/// </summary>
[Theory]
[InlineData(0.25, 0.15, 0.25, 0.15, 0.10, 0.10)]
[InlineData(0.30, 0.20, 0.20, 0.10, 0.10, 0.10)]
[InlineData(0.16, 0.16, 0.16, 0.16, 0.18, 0.18)]
public void Entropy_SameSnapshotSameWeights_ProducesSameResult(
double vex, double epss, double reach, double runtime, double backport, double sbom)
{
// Arrange
var calculator = new UncertaintyScoreCalculator(NullLogger<UncertaintyScoreCalculator>.Instance);
var snapshot = CreateDeterministicSnapshot();
var weights = new SignalWeights
{
VexWeight = vex,
EpssWeight = epss,
ReachabilityWeight = reach,
RuntimeWeight = runtime,
BackportWeight = backport,
SbomLineageWeight = sbom
};
// Act - calculate 5 times
var results = new List<double>();
for (var i = 0; i < 5; i++)
{
results.Add(calculator.CalculateEntropy(snapshot, weights));
}
// Assert - all results should be identical
results.Distinct().Should().HaveCount(1, "same snapshot + weights should always produce same entropy");
}
/// <summary>
/// Property: Order of snapshot construction doesn't affect entropy.
/// </summary>
[Fact]
public void Entropy_EquivalentSnapshots_ProduceSameResult()
{
// Arrange
var calculator = new UncertaintyScoreCalculator(NullLogger<UncertaintyScoreCalculator>.Instance);
// Create two snapshots with same values but constructed differently
var snapshot1 = CreateSnapshotWithVexFirst();
var snapshot2 = CreateSnapshotWithEpssFirst();
// Act
var entropy1 = calculator.CalculateEntropy(snapshot1);
var entropy2 = calculator.CalculateEntropy(snapshot2);
// Assert
entropy1.Should().Be(entropy2, "equivalent snapshots should produce identical entropy");
}
/// <summary>
/// Property: Decay with floor is deterministic.
/// </summary>
[Theory]
[InlineData(1.0, 30, 14.0, 0.1)]
[InlineData(0.8, 7, 7.0, 0.05)]
[InlineData(0.5, 100, 30.0, 0.2)]
public void Decay_WithFloor_IsDeterministic(double baseConfidence, int ageDays, double halfLifeDays, double floor)
{
// Arrange
var calculator = new DecayedConfidenceCalculator(NullLogger<DecayedConfidenceCalculator>.Instance);
// Act - calculate 10 times
var results = new List<double>();
for (var i = 0; i < 10; i++)
{
results.Add(calculator.Calculate(baseConfidence, ageDays, halfLifeDays, floor));
}
// Assert - all results should be identical
results.Distinct().Should().HaveCount(1, "decay with floor should be deterministic");
}
/// <summary>
/// Property: Entropy calculation is independent of external state.
/// </summary>
[Fact]
public void Entropy_IndependentOfGlobalState_ProducesConsistentResults()
{
// Arrange
var snapshot = CreateDeterministicSnapshot();
// Act - interleave calculations with some "noise"
var results = new List<double>();
for (var i = 0; i < 10; i++)
{
// Create new calculator each time to verify no shared state issues
var calculator = new UncertaintyScoreCalculator(NullLogger<UncertaintyScoreCalculator>.Instance);
// Do some unrelated operations
_ = Guid.NewGuid();
_ = DateTime.UtcNow;
results.Add(calculator.CalculateEntropy(snapshot));
}
// Assert - all results should be identical
results.Distinct().Should().HaveCount(1, "entropy should be independent of external state");
}
#region Helper Methods
private SignalSnapshot CreateDeterministicSnapshot()
{
return new SignalSnapshot
{
Cve = "CVE-2024-1234",
Purl = "pkg:test@1.0.0",
Vex = SignalState<VexClaimSummary>.Queried(
new VexClaimSummary { Status = "affected", Confidence = 0.9, StatementCount = 1, ComputedAt = _fixedTime },
_fixedTime),
Epss = SignalState<EpssEvidence>.Queried(
new EpssEvidence { Cve = "CVE-2024-1234", Epss = 0.5, Percentile = 0.8, PublishedAt = _fixedTime },
_fixedTime),
Reachability = SignalState<ReachabilityEvidence>.Queried(
new ReachabilityEvidence { Status = ReachabilityStatus.Reachable, AnalyzedAt = _fixedTime },
_fixedTime),
Runtime = SignalState<RuntimeEvidence>.NotQueried(),
Backport = SignalState<BackportEvidence>.NotQueried(),
Sbom = SignalState<SbomLineageEvidence>.NotQueried(),
Cvss = SignalState<CvssEvidence>.Queried(
new CvssEvidence { Vector = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", Version = "3.1", BaseScore = 9.8, Severity = "CRITICAL", Source = "NVD", PublishedAt = _fixedTime },
_fixedTime),
SnapshotAt = _fixedTime
};
}
private SignalSnapshot CreateSnapshotWithVexFirst()
{
var snapshot = SignalSnapshot.Empty("CVE-2024-1234", "pkg:test@1.0", _fixedTime);
return snapshot with
{
Vex = SignalState<VexClaimSummary>.Queried(
new VexClaimSummary { Status = "affected", Confidence = 0.9, StatementCount = 1, ComputedAt = _fixedTime },
_fixedTime),
Epss = SignalState<EpssEvidence>.Queried(
new EpssEvidence { Cve = "CVE-2024-1234", Epss = 0.5, Percentile = 0.8, PublishedAt = _fixedTime },
_fixedTime)
};
}
private SignalSnapshot CreateSnapshotWithEpssFirst()
{
var snapshot = SignalSnapshot.Empty("CVE-2024-1234", "pkg:test@1.0", _fixedTime);
return snapshot with
{
Epss = SignalState<EpssEvidence>.Queried(
new EpssEvidence { Cve = "CVE-2024-1234", Epss = 0.5, Percentile = 0.8, PublishedAt = _fixedTime },
_fixedTime),
Vex = SignalState<VexClaimSummary>.Queried(
new VexClaimSummary { Status = "affected", Confidence = 0.9, StatementCount = 1, ComputedAt = _fixedTime },
_fixedTime)
};
}
#endregion
}

View File

@@ -0,0 +1,289 @@
// -----------------------------------------------------------------------------
// EntropyPropertyTests.cs
// Sprint: SPRINT_20260106_001_002_LB_determinization_scoring
// Task: DCS-021 - Write property tests: entropy always [0.0, 1.0]
// Description: Property-based tests ensuring entropy is always within bounds
// regardless of signal combinations or weight configurations.
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Policy.Determinization.Evidence;
using StellaOps.Policy.Determinization.Models;
using StellaOps.Policy.Determinization.Scoring;
using Xunit;
namespace StellaOps.Policy.Determinization.Tests.PropertyTests;
/// <summary>
/// Property tests verifying entropy bounds.
/// DCS-021: entropy must always be in [0.0, 1.0] regardless of inputs.
/// </summary>
[Trait("Category", "Unit")]
[Trait("Property", "EntropyBounds")]
public class EntropyPropertyTests
{
private readonly UncertaintyScoreCalculator _calculator;
private readonly DateTimeOffset _now = DateTimeOffset.UtcNow;
public EntropyPropertyTests()
{
_calculator = new UncertaintyScoreCalculator(NullLogger<UncertaintyScoreCalculator>.Instance);
}
/// <summary>
/// Property: For any combination of signal states, entropy is in [0.0, 1.0].
/// </summary>
[Theory]
[MemberData(nameof(AllSignalCombinations))]
public void Entropy_ForAnySignalCombination_IsWithinBounds(
bool hasVex, bool hasEpss, bool hasReach, bool hasRuntime, bool hasBackport, bool hasSbom)
{
// Arrange
var snapshot = CreateSnapshot(hasVex, hasEpss, hasReach, hasRuntime, hasBackport, hasSbom);
// Act
var entropy = _calculator.CalculateEntropy(snapshot);
// Assert
entropy.Should().BeGreaterThanOrEqualTo(0.0, "entropy must be >= 0.0");
entropy.Should().BeLessThanOrEqualTo(1.0, "entropy must be <= 1.0");
}
/// <summary>
/// Property: Entropy with zero weights should not throw and return 0.0.
/// </summary>
[Fact]
public void Entropy_WithZeroWeights_ReturnsZeroWithoutDivisionByZero()
{
// Arrange
var snapshot = SignalSnapshot.Empty("CVE-2024-1234", "pkg:test@1.0", _now);
// Note: Zero weights would cause division by zero; test with very small weights instead
var nearZeroWeights = new SignalWeights
{
VexWeight = 0.000001,
EpssWeight = 0.000001,
ReachabilityWeight = 0.000001,
RuntimeWeight = 0.000001,
BackportWeight = 0.000001,
SbomLineageWeight = 0.000001
};
// Act
var act = () => _calculator.CalculateEntropy(snapshot, nearZeroWeights);
// Assert - should not throw, and result should be bounded
var entropy = act.Should().NotThrow().Subject;
// Note: 0/0 edge case - implementation may return NaN, 0, or 1
// The clamp ensures it's always in bounds if not NaN
if (!double.IsNaN(entropy))
{
entropy.Should().BeGreaterThanOrEqualTo(0.0);
entropy.Should().BeLessThanOrEqualTo(1.0);
}
}
/// <summary>
/// Property: Entropy with extreme weights still produces bounded result.
/// </summary>
[Theory]
[InlineData(0.0001, 0.0001, 0.0001, 0.0001, 0.0001, 0.0001)]
[InlineData(1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0)]
[InlineData(0.0, 0.0, 0.0, 0.0, 0.0, 1.0)]
[InlineData(1.0, 0.0, 0.0, 0.0, 0.0, 0.0)]
public void Entropy_WithExtremeWeights_IsWithinBounds(
double vex, double epss, double reach, double runtime, double backport, double sbom)
{
// Arrange
var snapshot = CreateFullSnapshot();
var weights = new SignalWeights
{
VexWeight = vex,
EpssWeight = epss,
ReachabilityWeight = reach,
RuntimeWeight = runtime,
BackportWeight = backport,
SbomLineageWeight = sbom
};
// Act
var entropy = _calculator.CalculateEntropy(snapshot, weights);
// Assert
if (!double.IsNaN(entropy))
{
entropy.Should().BeGreaterThanOrEqualTo(0.0);
entropy.Should().BeLessThanOrEqualTo(1.0);
}
}
/// <summary>
/// Property: All signals present yields entropy = 0.0.
/// </summary>
[Fact]
public void Entropy_AllSignalsPresent_IsZero()
{
// Arrange
var snapshot = CreateFullSnapshot();
// Act
var entropy = _calculator.CalculateEntropy(snapshot);
// Assert
entropy.Should().Be(0.0, "all signals present should yield minimal entropy");
}
/// <summary>
/// Property: No signals present yields entropy = 1.0.
/// </summary>
[Fact]
public void Entropy_NoSignalsPresent_IsOne()
{
// Arrange
var snapshot = SignalSnapshot.Empty("CVE-2024-1234", "pkg:test@1.0", _now);
// Act
var entropy = _calculator.CalculateEntropy(snapshot);
// Assert
entropy.Should().Be(1.0, "no signals present should yield maximum entropy");
}
/// <summary>
/// Property: Adding a signal never increases entropy.
/// </summary>
[Theory]
[InlineData("vex")]
[InlineData("epss")]
[InlineData("reachability")]
[InlineData("runtime")]
[InlineData("backport")]
[InlineData("sbom")]
public void Entropy_AddingSignal_NeverIncreasesEntropy(string signalToAdd)
{
// Arrange
var baseSnapshot = SignalSnapshot.Empty("CVE-2024-1234", "pkg:test@1.0", _now);
var baseEntropy = _calculator.CalculateEntropy(baseSnapshot);
// Act - add one signal
var snapshotWithSignal = AddSignal(baseSnapshot, signalToAdd);
var newEntropy = _calculator.CalculateEntropy(snapshotWithSignal);
// Assert
newEntropy.Should().BeLessThanOrEqualTo(baseEntropy,
$"adding signal '{signalToAdd}' should not increase entropy");
}
/// <summary>
/// Property: Removing a signal never decreases entropy.
/// </summary>
[Theory]
[InlineData("vex")]
[InlineData("epss")]
[InlineData("reachability")]
[InlineData("runtime")]
[InlineData("backport")]
[InlineData("sbom")]
public void Entropy_RemovingSignal_NeverDecreasesEntropy(string signalToRemove)
{
// Arrange
var fullSnapshot = CreateFullSnapshot();
var fullEntropy = _calculator.CalculateEntropy(fullSnapshot);
// Act - remove one signal
var snapshotWithoutSignal = RemoveSignal(fullSnapshot, signalToRemove);
var newEntropy = _calculator.CalculateEntropy(snapshotWithoutSignal);
// Assert
newEntropy.Should().BeGreaterThanOrEqualTo(fullEntropy,
$"removing signal '{signalToRemove}' should not decrease entropy");
}
#region Test Data Generators
public static IEnumerable<object[]> AllSignalCombinations()
{
// Generate all 64 combinations of 6 boolean flags
for (var i = 0; i < 64; i++)
{
yield return new object[]
{
(i & 1) != 0, // hasVex
(i & 2) != 0, // hasEpss
(i & 4) != 0, // hasReach
(i & 8) != 0, // hasRuntime
(i & 16) != 0, // hasBackport
(i & 32) != 0 // hasSbom
};
}
}
#endregion
#region Helper Methods
private SignalSnapshot CreateSnapshot(
bool hasVex, bool hasEpss, bool hasReach, bool hasRuntime, bool hasBackport, bool hasSbom)
{
return new SignalSnapshot
{
Cve = "CVE-2024-1234",
Purl = "pkg:test@1.0",
Vex = hasVex
? SignalState<VexClaimSummary>.Queried(new VexClaimSummary { Status = "affected", Confidence = 0.9, StatementCount = 1, ComputedAt = _now }, _now)
: SignalState<VexClaimSummary>.NotQueried(),
Epss = hasEpss
? SignalState<EpssEvidence>.Queried(new EpssEvidence { Cve = "CVE-2024-1234", Epss = 0.5, Percentile = 0.8, PublishedAt = _now }, _now)
: SignalState<EpssEvidence>.NotQueried(),
Reachability = hasReach
? SignalState<ReachabilityEvidence>.Queried(new ReachabilityEvidence { Status = ReachabilityStatus.Reachable, AnalyzedAt = _now }, _now)
: SignalState<ReachabilityEvidence>.NotQueried(),
Runtime = hasRuntime
? SignalState<RuntimeEvidence>.Queried(new RuntimeEvidence { Detected = true, Source = "test", ObservationStart = _now.AddDays(-7), ObservationEnd = _now, Confidence = 0.9 }, _now)
: SignalState<RuntimeEvidence>.NotQueried(),
Backport = hasBackport
? SignalState<BackportEvidence>.Queried(new BackportEvidence { Detected = false, Source = "test", DetectedAt = _now, Confidence = 0.85 }, _now)
: SignalState<BackportEvidence>.NotQueried(),
Sbom = hasSbom
? SignalState<SbomLineageEvidence>.Queried(new SbomLineageEvidence { SbomDigest = "sha256:abc", Format = "SPDX", ComponentCount = 150, GeneratedAt = _now, HasProvenance = true }, _now)
: SignalState<SbomLineageEvidence>.NotQueried(),
Cvss = SignalState<CvssEvidence>.Queried(new CvssEvidence { Vector = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", Version = "3.1", BaseScore = 9.8, Severity = "CRITICAL", Source = "NVD", PublishedAt = _now }, _now),
SnapshotAt = _now
};
}
private SignalSnapshot CreateFullSnapshot()
{
return CreateSnapshot(true, true, true, true, true, true);
}
private SignalSnapshot AddSignal(SignalSnapshot snapshot, string signal)
{
return signal switch
{
"vex" => snapshot with { Vex = SignalState<VexClaimSummary>.Queried(new VexClaimSummary { Status = "affected", Confidence = 0.9, StatementCount = 1, ComputedAt = _now }, _now) },
"epss" => snapshot with { Epss = SignalState<EpssEvidence>.Queried(new EpssEvidence { Cve = "CVE-2024-1234", Epss = 0.5, Percentile = 0.8, PublishedAt = _now }, _now) },
"reachability" => snapshot with { Reachability = SignalState<ReachabilityEvidence>.Queried(new ReachabilityEvidence { Status = ReachabilityStatus.Reachable, AnalyzedAt = _now }, _now) },
"runtime" => snapshot with { Runtime = SignalState<RuntimeEvidence>.Queried(new RuntimeEvidence { Detected = true, Source = "test", ObservationStart = _now.AddDays(-7), ObservationEnd = _now, Confidence = 0.9 }, _now) },
"backport" => snapshot with { Backport = SignalState<BackportEvidence>.Queried(new BackportEvidence { Detected = false, Source = "test", DetectedAt = _now, Confidence = 0.85 }, _now) },
"sbom" => snapshot with { Sbom = SignalState<SbomLineageEvidence>.Queried(new SbomLineageEvidence { SbomDigest = "sha256:abc", Format = "SPDX", ComponentCount = 150, GeneratedAt = _now, HasProvenance = true }, _now) },
_ => snapshot
};
}
private SignalSnapshot RemoveSignal(SignalSnapshot snapshot, string signal)
{
return signal switch
{
"vex" => snapshot with { Vex = SignalState<VexClaimSummary>.NotQueried() },
"epss" => snapshot with { Epss = SignalState<EpssEvidence>.NotQueried() },
"reachability" => snapshot with { Reachability = SignalState<ReachabilityEvidence>.NotQueried() },
"runtime" => snapshot with { Runtime = SignalState<RuntimeEvidence>.NotQueried() },
"backport" => snapshot with { Backport = SignalState<BackportEvidence>.NotQueried() },
"sbom" => snapshot with { Sbom = SignalState<SbomLineageEvidence>.NotQueried() },
_ => snapshot
};
}
#endregion
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Moq" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Policy.Determinization\StellaOps.Policy.Determinization.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,122 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Policy.Determinization.Evidence;
using StellaOps.Policy.Determinization.Models;
using StellaOps.Policy.Determinization.Scoring;
using Xunit;
namespace StellaOps.Policy.Determinization.Tests;
public class TrustScoreAggregatorTests
{
private readonly TrustScoreAggregator _aggregator;
public TrustScoreAggregatorTests()
{
_aggregator = new TrustScoreAggregator(NullLogger<TrustScoreAggregator>.Instance);
}
[Fact]
public void Aggregate_AllAffectedSignals_ReturnsHighScore()
{
// Arrange
var now = DateTimeOffset.UtcNow;
var snapshot = new SignalSnapshot
{
Cve = "CVE-2024-1234",
Purl = "pkg:maven/test@1.0",
Vex = SignalState<VexClaimSummary>.Queried(new VexClaimSummary { Status = "affected", Confidence = 0.95, StatementCount = 3, ComputedAt = now }, now),
Epss = SignalState<EpssEvidence>.Queried(new EpssEvidence { Cve = "CVE-2024-1234", Epss = 0.9, Percentile = 0.95, PublishedAt = now }, now),
Reachability = SignalState<ReachabilityEvidence>.Queried(new ReachabilityEvidence { Status = ReachabilityStatus.Reachable, AnalyzedAt = now }, now),
Runtime = SignalState<RuntimeEvidence>.Queried(new RuntimeEvidence { Detected = true, Source = "test", ObservationStart = now.AddDays(-7), ObservationEnd = now, Confidence = 0.9 }, now),
Backport = SignalState<BackportEvidence>.Queried(new BackportEvidence { Detected = false, Source = "test", DetectedAt = now, Confidence = 0.85 }, now),
Sbom = SignalState<SbomLineageEvidence>.Queried(new SbomLineageEvidence { SbomDigest = "sha256:abc", Format = "SPDX", ComponentCount = 150, GeneratedAt = now, HasProvenance = true }, now),
Cvss = SignalState<CvssEvidence>.NotQueried(),
SnapshotAt = now
};
var uncertaintyScore = UncertaintyScore.Create(0.1, new List<SignalGap>(), 0.9, 1.0, now);
// Act
var score = _aggregator.Aggregate(snapshot, uncertaintyScore);
// Assert
score.Should().BeGreaterThan(0.7);
}
[Fact]
public void Aggregate_AllNotAffectedSignals_ReturnsLowScore()
{
// Arrange
var now = DateTimeOffset.UtcNow;
var snapshot = new SignalSnapshot
{
Cve = "CVE-2024-1234",
Purl = "pkg:maven/test@1.0",
Vex = SignalState<VexClaimSummary>.Queried(new VexClaimSummary { Status = "not_affected", Confidence = 0.88, StatementCount = 2, ComputedAt = now }, now),
Epss = SignalState<EpssEvidence>.Queried(new EpssEvidence { Cve = "CVE-2024-1234", Epss = 0.01, Percentile = 0.1, PublishedAt = now }, now),
Reachability = SignalState<ReachabilityEvidence>.Queried(new ReachabilityEvidence { Status = ReachabilityStatus.Unreachable, AnalyzedAt = now }, now),
Runtime = SignalState<RuntimeEvidence>.Queried(new RuntimeEvidence { Detected = false, Source = "test", ObservationStart = now.AddDays(-7), ObservationEnd = now, Confidence = 0.92 }, now),
Backport = SignalState<BackportEvidence>.Queried(new BackportEvidence { Detected = true, Source = "test", DetectedAt = now, Confidence = 0.95 }, now),
Sbom = SignalState<SbomLineageEvidence>.Queried(new SbomLineageEvidence { SbomDigest = "sha256:abc", Format = "SPDX", ComponentCount = 200, GeneratedAt = now, HasProvenance = false }, now),
Cvss = SignalState<CvssEvidence>.NotQueried(),
SnapshotAt = now
};
var uncertaintyScore = UncertaintyScore.Create(0.1, new List<SignalGap>(), 0.9, 1.0, now);
// Act
var score = _aggregator.Aggregate(snapshot, uncertaintyScore);
// Assert
score.Should().BeLessThan(0.2);
}
[Fact]
public void Aggregate_NoSignals_ReturnsNeutralScorePenalized()
{
// Arrange
var snapshot = SignalSnapshot.Empty("CVE-2024-1234", "pkg:maven/test@1.0", DateTimeOffset.UtcNow);
var gaps = new List<SignalGap>
{
new() { Signal = "VEX", Reason = SignalGapReason.NotQueried, Weight = 0.25 }
};
var uncertaintyScore = UncertaintyScore.Create(0.8, gaps, 0.2, 1.0, DateTimeOffset.UtcNow);
// Act
var score = _aggregator.Aggregate(snapshot, uncertaintyScore);
// Assert
score.Should().BeApproximately(0.1, 0.05); // 0.5 * (1 - 0.8) = 0.1
}
[Fact]
public void Aggregate_HighUncertainty_PenalizesScore()
{
// Arrange
var now = DateTimeOffset.UtcNow;
var snapshot = new SignalSnapshot
{
Cve = "CVE-2024-1234",
Purl = "pkg:maven/test@1.0",
Vex = SignalState<VexClaimSummary>.Queried(new VexClaimSummary { Status = "affected", Confidence = 0.95, StatementCount = 3, ComputedAt = now }, now),
Epss = SignalState<EpssEvidence>.NotQueried(),
Reachability = SignalState<ReachabilityEvidence>.NotQueried(),
Runtime = SignalState<RuntimeEvidence>.NotQueried(),
Backport = SignalState<BackportEvidence>.NotQueried(),
Sbom = SignalState<SbomLineageEvidence>.NotQueried(),
Cvss = SignalState<CvssEvidence>.NotQueried(),
SnapshotAt = now
};
var gaps = new List<SignalGap>
{
new() { Signal = "EPSS", Reason = SignalGapReason.NotQueried, Weight = 0.15 },
new() { Signal = "Reachability", Reason = SignalGapReason.NotQueried, Weight = 0.25 }
};
var uncertaintyScore = UncertaintyScore.Create(0.75, gaps, 0.25, 1.0, now);
// Act
var score = _aggregator.Aggregate(snapshot, uncertaintyScore);
// Assert - high uncertainty should significantly reduce the score
score.Should().BeLessThan(0.5);
}
}

View File

@@ -0,0 +1,114 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Policy.Determinization.Evidence;
using StellaOps.Policy.Determinization.Models;
using StellaOps.Policy.Determinization.Scoring;
using Xunit;
namespace StellaOps.Policy.Determinization.Tests;
public class UncertaintyScoreCalculatorTests
{
private readonly UncertaintyScoreCalculator _calculator;
public UncertaintyScoreCalculatorTests()
{
_calculator = new UncertaintyScoreCalculator(NullLogger<UncertaintyScoreCalculator>.Instance);
}
[Fact]
public void Calculate_AllSignalsPresent_ReturnsMinimalEntropy()
{
// Arrange
var snapshot = CreateFullSnapshot();
// Act
var score = _calculator.Calculate(snapshot);
// Assert
score.Entropy.Should().Be(0.0);
score.Tier.Should().Be(UncertaintyTier.Minimal);
}
[Fact]
public void Calculate_NoSignalsPresent_ReturnsCriticalEntropy()
{
// Arrange
var snapshot = SignalSnapshot.Empty("CVE-2024-1234", "pkg:maven/test@1.0", DateTimeOffset.UtcNow);
// Act
var score = _calculator.Calculate(snapshot);
// Assert
score.Entropy.Should().Be(1.0);
score.Tier.Should().Be(UncertaintyTier.Critical);
score.Gaps.Should().HaveCount(6);
}
[Fact]
public void Calculate_HalfSignalsPresent_ReturnsModerateEntropy()
{
// Arrange
var now = DateTimeOffset.UtcNow;
var snapshot = new SignalSnapshot
{
Cve = "CVE-2024-1234",
Purl = "pkg:maven/test@1.0",
Vex = SignalState<VexClaimSummary>.Queried(new VexClaimSummary { Status = "affected", Confidence = 0.95, StatementCount = 3, ComputedAt = now }, now),
Epss = SignalState<EpssEvidence>.Queried(new EpssEvidence { Cve = "CVE-2024-1234", Epss = 0.5, Percentile = 0.8, PublishedAt = now }, now),
Reachability = SignalState<ReachabilityEvidence>.Queried(new ReachabilityEvidence { Status = ReachabilityStatus.Reachable, AnalyzedAt = now }, now),
Runtime = SignalState<RuntimeEvidence>.NotQueried(),
Backport = SignalState<BackportEvidence>.NotQueried(),
Sbom = SignalState<SbomLineageEvidence>.NotQueried(),
Cvss = SignalState<CvssEvidence>.NotQueried(),
SnapshotAt = now
};
// Act
var score = _calculator.Calculate(snapshot);
// Assert (VEX=0.25 + EPSS=0.15 + Reach=0.25 = 0.65 present, entropy = 1 - 0.65 = 0.35)
score.Entropy.Should().BeApproximately(0.35, 0.01);
score.Tier.Should().Be(UncertaintyTier.Low);
}
[Fact]
public void CalculateEntropy_CustomWeights_UsesProvidedWeights()
{
// Arrange
var snapshot = CreateFullSnapshot();
var customWeights = new SignalWeights
{
VexWeight = 0.5,
EpssWeight = 0.3,
ReachabilityWeight = 0.1,
RuntimeWeight = 0.05,
BackportWeight = 0.03,
SbomLineageWeight = 0.02
};
// Act
var entropy = _calculator.CalculateEntropy(snapshot, customWeights);
// Assert
entropy.Should().Be(0.0);
}
private SignalSnapshot CreateFullSnapshot()
{
var now = DateTimeOffset.UtcNow;
return new SignalSnapshot
{
Cve = "CVE-2024-1234",
Purl = "pkg:maven/test@1.0",
Vex = SignalState<VexClaimSummary>.Queried(new VexClaimSummary { Status = "affected", Confidence = 0.95, StatementCount = 3, ComputedAt = now }, now),
Epss = SignalState<EpssEvidence>.Queried(new EpssEvidence { Cve = "CVE-2024-1234", Epss = 0.5, Percentile = 0.8, PublishedAt = now }, now),
Reachability = SignalState<ReachabilityEvidence>.Queried(new ReachabilityEvidence { Status = ReachabilityStatus.Reachable, AnalyzedAt = now }, now),
Runtime = SignalState<RuntimeEvidence>.Queried(new RuntimeEvidence { Detected = true, Source = "test", ObservationStart = now.AddDays(-7), ObservationEnd = now, Confidence = 0.9 }, now),
Backport = SignalState<BackportEvidence>.Queried(new BackportEvidence { Detected = false, Source = "test", DetectedAt = now, Confidence = 0.85 }, now),
Sbom = SignalState<SbomLineageEvidence>.Queried(new SbomLineageEvidence { SbomDigest = "sha256:abc", Format = "SPDX", ComponentCount = 150, GeneratedAt = now, HasProvenance = true }, now),
Cvss = SignalState<CvssEvidence>.Queried(new CvssEvidence { Vector = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", Version = "3.1", BaseScore = 9.8, Severity = "CRITICAL", Source = "NVD", PublishedAt = now }, now),
SnapshotAt = now
};
}
}

View File

@@ -0,0 +1,222 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Policy;
using StellaOps.Policy.Determinization;
using StellaOps.Policy.Determinization.Models;
using StellaOps.Policy.Engine.Gates;
using StellaOps.Policy.Engine.Gates.Determinization;
using StellaOps.Policy.Engine.Policies;
using StellaOps.Policy.Gates;
using StellaOps.Policy.TrustLattice;
namespace StellaOps.Policy.Engine.Tests.Gates.Determinization;
public class DeterminizationGateTests
{
private readonly Mock<ISignalSnapshotBuilder> _snapshotBuilderMock;
private readonly Mock<IUncertaintyScoreCalculator> _uncertaintyCalculatorMock;
private readonly Mock<IDecayedConfidenceCalculator> _decayCalculatorMock;
private readonly Mock<TrustScoreAggregator> _trustAggregatorMock;
private readonly DeterminizationGate _gate;
public DeterminizationGateTests()
{
_snapshotBuilderMock = new Mock<ISignalSnapshotBuilder>();
_uncertaintyCalculatorMock = new Mock<IUncertaintyScoreCalculator>();
_decayCalculatorMock = new Mock<IDecayedConfidenceCalculator>();
_trustAggregatorMock = new Mock<TrustScoreAggregator>();
var options = Options.Create(new DeterminizationOptions());
var policy = new DeterminizationPolicy(options, NullLogger<DeterminizationPolicy>.Instance);
_gate = new DeterminizationGate(
policy,
_uncertaintyCalculatorMock.Object,
_decayCalculatorMock.Object,
_trustAggregatorMock.Object,
_snapshotBuilderMock.Object,
NullLogger<DeterminizationGate>.Instance);
}
[Fact]
public async Task EvaluateAsync_BuildsCorrectMetadata()
{
// Arrange
var snapshot = CreateSnapshot();
var uncertaintyScore = new UncertaintyScore
{
Entropy = 0.45,
Tier = UncertaintyTier.Moderate,
Completeness = 0.55,
MissingSignals = []
};
_snapshotBuilderMock
.Setup(x => x.BuildAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(snapshot);
_uncertaintyCalculatorMock
.Setup(x => x.Calculate(It.IsAny<SignalSnapshot>()))
.Returns(uncertaintyScore);
_decayCalculatorMock
.Setup(x => x.Calculate(It.IsAny<double>(), It.IsAny<double>(), It.IsAny<double>(), It.IsAny<double>()))
.Returns(0.85);
_trustAggregatorMock
.Setup(x => x.Aggregate(It.IsAny<SignalSnapshot>(), It.IsAny<UncertaintyScore>()))
.Returns(0.7);
var context = new PolicyGateContext
{
CveId = "CVE-2024-0001",
SubjectKey = "pkg:npm/test@1.0.0",
Environment = "development"
};
var mergeResult = new MergeResult
{
FinalScore = 0.5,
FinalTrustLevel = TrustLevel.Medium,
Claims = []
};
// Act
var result = await _gate.EvaluateAsync(mergeResult, context);
// Assert
result.Details.Should().ContainKey("uncertainty_entropy");
result.Details["uncertainty_entropy"].Should().Be(0.45);
result.Details.Should().ContainKey("uncertainty_tier");
result.Details["uncertainty_tier"].Should().Be("Moderate");
result.Details.Should().ContainKey("uncertainty_completeness");
result.Details["uncertainty_completeness"].Should().Be(0.55);
result.Details.Should().ContainKey("trust_score");
result.Details["trust_score"].Should().Be(0.7);
result.Details.Should().ContainKey("decay_multiplier");
result.Details.Should().ContainKey("decay_is_stale");
result.Details.Should().ContainKey("decay_age_days");
}
[Fact]
public async Task EvaluateAsync_WithGuardRails_IncludesGuardrailsMetadata()
{
// Arrange
var snapshot = CreateSnapshot();
var uncertaintyScore = new UncertaintyScore
{
Entropy = 0.5,
Tier = UncertaintyTier.Moderate,
Completeness = 0.5,
MissingSignals = []
};
_snapshotBuilderMock
.Setup(x => x.BuildAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(snapshot);
_uncertaintyCalculatorMock
.Setup(x => x.Calculate(It.IsAny<SignalSnapshot>()))
.Returns(uncertaintyScore);
_decayCalculatorMock
.Setup(x => x.Calculate(It.IsAny<double>(), It.IsAny<double>(), It.IsAny<double>(), It.IsAny<double>()))
.Returns(0.85);
_trustAggregatorMock
.Setup(x => x.Aggregate(It.IsAny<SignalSnapshot>(), It.IsAny<UncertaintyScore>()))
.Returns(0.3);
var context = new PolicyGateContext
{
CveId = "CVE-2024-0001",
SubjectKey = "pkg:npm/test@1.0.0",
Environment = "development"
};
var mergeResult = new MergeResult
{
FinalScore = 0.5,
FinalTrustLevel = TrustLevel.Medium,
Claims = []
};
// Act
var result = await _gate.EvaluateAsync(mergeResult, context);
// Assert
result.Details.Should().ContainKey("guardrails_monitoring");
result.Details.Should().ContainKey("guardrails_reeval_after");
}
[Fact]
public async Task EvaluateAsync_WithMatchedRule_IncludesRuleName()
{
// Arrange
var snapshot = CreateSnapshot();
var uncertaintyScore = new UncertaintyScore
{
Entropy = 0.2,
Tier = UncertaintyTier.Low,
Completeness = 0.8,
MissingSignals = []
};
_snapshotBuilderMock
.Setup(x => x.BuildAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(snapshot);
_uncertaintyCalculatorMock
.Setup(x => x.Calculate(It.IsAny<SignalSnapshot>()))
.Returns(uncertaintyScore);
_decayCalculatorMock
.Setup(x => x.Calculate(It.IsAny<double>(), It.IsAny<double>(), It.IsAny<double>(), It.IsAny<double>()))
.Returns(0.9);
_trustAggregatorMock
.Setup(x => x.Aggregate(It.IsAny<SignalSnapshot>(), It.IsAny<UncertaintyScore>()))
.Returns(0.8);
var context = new PolicyGateContext
{
CveId = "CVE-2024-0001",
SubjectKey = "pkg:npm/test@1.0.0",
Environment = "production"
};
var mergeResult = new MergeResult
{
FinalScore = 0.8,
FinalTrustLevel = TrustLevel.High,
Claims = []
};
// Act
var result = await _gate.EvaluateAsync(mergeResult, context);
// Assert
result.Details.Should().ContainKey("matched_rule");
result.Details["matched_rule"].Should().NotBeNull();
}
private static SignalSnapshot CreateSnapshot() => new()
{
Cve = "CVE-2024-0001",
Purl = "pkg:npm/test@1.0.0",
Epss = SignalState<EpssEvidence>.NotQueried(),
Vex = SignalState<VexClaimSummary>.NotQueried(),
Reachability = SignalState<ReachabilityEvidence>.NotQueried(),
Runtime = SignalState<RuntimeEvidence>.NotQueried(),
Backport = SignalState<BackportEvidence>.NotQueried(),
Sbom = SignalState<SbomLineageEvidence>.NotQueried(),
Cvss = SignalState<CvssEvidence>.NotQueried(),
SnapshotAt = DateTimeOffset.UtcNow
};
}

View File

@@ -0,0 +1,543 @@
// -----------------------------------------------------------------------------
// FacetQuotaGateIntegrationTests.cs
// Sprint: SPRINT_20260105_002_003_FACET (QTA-015)
// Task: QTA-015 - Integration tests for facet quota gate pipeline
// Description: End-to-end tests for facet drift detection and quota enforcement
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Facet;
using StellaOps.Policy.Confidence.Models;
using StellaOps.Policy.Gates;
using StellaOps.Policy.TrustLattice;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.Gates;
/// <summary>
/// Integration tests for the facet quota gate pipeline.
/// Tests end-to-end flow from drift reports through gate evaluation.
/// </summary>
[Trait("Category", "Integration")]
public sealed class FacetQuotaGateIntegrationTests
{
private readonly InMemoryFacetSealStore _sealStore;
private readonly Mock<IFacetDriftDetector> _driftDetector;
private readonly FacetSealer _sealer;
public FacetQuotaGateIntegrationTests()
{
_sealStore = new InMemoryFacetSealStore();
_driftDetector = new Mock<IFacetDriftDetector>();
_sealer = new FacetSealer();
}
#region Full Pipeline Tests
[Fact]
public async Task FullPipeline_FirstScan_NoBaseline_PassesWithWarning()
{
// Arrange: No baseline seal exists
var options = new FacetQuotaGateOptions
{
Enabled = true,
NoSealAction = NoSealAction.Warn
};
var gate = CreateGate(options);
var context = new PolicyGateContext { Environment = "production" };
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
result.Passed.Should().BeTrue();
result.Reason.Should().Be("no_baseline_seal");
result.Details.Should().ContainKey("action");
result.Details["action"].Should().Be("warn");
}
[Fact]
public async Task FullPipeline_WithBaseline_NoDrift_Passes()
{
// Arrange: Create baseline seal
var imageDigest = "sha256:abc123";
var baselineSeal = CreateSeal(imageDigest, 100);
await _sealStore.SaveAsync(baselineSeal);
// Setup drift detector to return no drift
var driftReport = CreateDriftReport(imageDigest, baselineSeal.CombinedMerkleRoot, QuotaVerdict.Ok);
SetupDriftDetector(driftReport);
var options = new FacetQuotaGateOptions { Enabled = true };
var gate = CreateGate(options);
var context = CreateContextWithDriftReport(driftReport);
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
result.Passed.Should().BeTrue();
result.Reason.Should().Be("quota_ok");
}
[Fact]
public async Task FullPipeline_ExceedWarningThreshold_PassesWithWarning()
{
// Arrange
var imageDigest = "sha256:def456";
var baselineSeal = CreateSeal(imageDigest, 100);
await _sealStore.SaveAsync(baselineSeal);
var driftReport = CreateDriftReport(imageDigest, baselineSeal.CombinedMerkleRoot, QuotaVerdict.Warning);
SetupDriftDetector(driftReport);
var options = new FacetQuotaGateOptions
{
Enabled = true,
DefaultMaxChurnPercent = 10.0m
};
var gate = CreateGate(options);
var context = CreateContextWithDriftReport(driftReport);
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
result.Passed.Should().BeTrue();
result.Reason.Should().Be("quota_warning");
result.Details.Should().ContainKey("breachedFacets");
}
[Fact]
public async Task FullPipeline_ExceedBlockThreshold_Blocks()
{
// Arrange
var imageDigest = "sha256:ghi789";
var baselineSeal = CreateSeal(imageDigest, 100);
await _sealStore.SaveAsync(baselineSeal);
var driftReport = CreateDriftReport(imageDigest, baselineSeal.CombinedMerkleRoot, QuotaVerdict.Blocked);
SetupDriftDetector(driftReport);
var options = new FacetQuotaGateOptions
{
Enabled = true,
DefaultAction = QuotaExceededAction.Block
};
var gate = CreateGate(options);
var context = CreateContextWithDriftReport(driftReport);
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
result.Passed.Should().BeFalse();
result.Reason.Should().Be("quota_exceeded");
}
[Fact]
public async Task FullPipeline_RequiresVex_BlocksUntilVexProvided()
{
// Arrange
var imageDigest = "sha256:jkl012";
var baselineSeal = CreateSeal(imageDigest, 100);
await _sealStore.SaveAsync(baselineSeal);
var driftReport = CreateDriftReport(imageDigest, baselineSeal.CombinedMerkleRoot, QuotaVerdict.RequiresVex);
SetupDriftDetector(driftReport);
var options = new FacetQuotaGateOptions
{
Enabled = true,
DefaultAction = QuotaExceededAction.RequireVex
};
var gate = CreateGate(options);
var context = CreateContextWithDriftReport(driftReport);
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
result.Passed.Should().BeFalse();
result.Reason.Should().Be("requires_vex_authorization");
result.Details.Should().ContainKey("vexRequired");
((bool)result.Details["vexRequired"]).Should().BeTrue();
}
#endregion
#region Multi-Facet Tests
[Fact]
public async Task MultiFacet_MixedVerdicts_ReportsWorstCase()
{
// Arrange: Multiple facets with different verdicts
var imageDigest = "sha256:multi123";
var baselineSeal = CreateSeal(imageDigest, 100);
await _sealStore.SaveAsync(baselineSeal);
var facetDrifts = new[]
{
CreateFacetDrift("os-packages", QuotaVerdict.Ok),
CreateFacetDrift("app-dependencies", QuotaVerdict.Warning),
CreateFacetDrift("config-files", QuotaVerdict.Blocked)
};
var driftReport = new FacetDriftReport
{
ImageDigest = imageDigest,
BaselineSealId = baselineSeal.CombinedMerkleRoot,
AnalyzedAt = DateTimeOffset.UtcNow,
FacetDrifts = [.. facetDrifts],
OverallVerdict = QuotaVerdict.Blocked // Worst case
};
SetupDriftDetector(driftReport);
var options = new FacetQuotaGateOptions { Enabled = true };
var gate = CreateGate(options);
var context = CreateContextWithDriftReport(driftReport);
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
result.Passed.Should().BeFalse();
result.Reason.Should().Be("quota_exceeded");
}
[Fact]
public async Task MultiFacet_AllWithinQuota_Passes()
{
// Arrange
var imageDigest = "sha256:allok456";
var baselineSeal = CreateSeal(imageDigest, 100);
await _sealStore.SaveAsync(baselineSeal);
var facetDrifts = new[]
{
CreateFacetDrift("os-packages", QuotaVerdict.Ok),
CreateFacetDrift("app-dependencies", QuotaVerdict.Ok),
CreateFacetDrift("config-files", QuotaVerdict.Ok)
};
var driftReport = new FacetDriftReport
{
ImageDigest = imageDigest,
BaselineSealId = baselineSeal.CombinedMerkleRoot,
AnalyzedAt = DateTimeOffset.UtcNow,
FacetDrifts = [.. facetDrifts],
OverallVerdict = QuotaVerdict.Ok
};
SetupDriftDetector(driftReport);
var options = new FacetQuotaGateOptions { Enabled = true };
var gate = CreateGate(options);
var context = CreateContextWithDriftReport(driftReport);
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
result.Passed.Should().BeTrue();
}
#endregion
#region Seal Store Integration
[Fact]
public async Task SealStore_SaveAndRetrieve_WorksCorrectly()
{
// Arrange
var imageDigest = "sha256:store123";
var seal = CreateSeal(imageDigest, 50);
// Act
await _sealStore.SaveAsync(seal);
var retrieved = await _sealStore.GetLatestSealAsync(imageDigest);
// Assert
retrieved.Should().NotBeNull();
retrieved!.ImageDigest.Should().Be(imageDigest);
retrieved.CombinedMerkleRoot.Should().Be(seal.CombinedMerkleRoot);
}
[Fact]
public async Task SealStore_MultipleSeals_ReturnsLatest()
{
// Arrange
var imageDigest = "sha256:multi789";
var seal1 = CreateSealWithTimestamp(imageDigest, 50, DateTimeOffset.UtcNow.AddHours(-2));
var seal2 = CreateSealWithTimestamp(imageDigest, 55, DateTimeOffset.UtcNow.AddHours(-1));
var seal3 = CreateSealWithTimestamp(imageDigest, 60, DateTimeOffset.UtcNow);
await _sealStore.SaveAsync(seal1);
await _sealStore.SaveAsync(seal2);
await _sealStore.SaveAsync(seal3);
// Act
var latest = await _sealStore.GetLatestSealAsync(imageDigest);
// Assert
latest.Should().NotBeNull();
latest!.CreatedAt.Should().Be(seal3.CreatedAt);
}
[Fact]
public async Task SealStore_History_ReturnsInDescendingOrder()
{
// Arrange
var imageDigest = "sha256:history123";
var seal1 = CreateSealWithTimestamp(imageDigest, 50, DateTimeOffset.UtcNow.AddHours(-2));
var seal2 = CreateSealWithTimestamp(imageDigest, 55, DateTimeOffset.UtcNow.AddHours(-1));
var seal3 = CreateSealWithTimestamp(imageDigest, 60, DateTimeOffset.UtcNow);
await _sealStore.SaveAsync(seal1);
await _sealStore.SaveAsync(seal2);
await _sealStore.SaveAsync(seal3);
// Act
var history = await _sealStore.GetHistoryAsync(imageDigest, limit: 10);
// Assert
history.Should().HaveCount(3);
history[0].CreatedAt.Should().Be(seal3.CreatedAt);
history[1].CreatedAt.Should().Be(seal2.CreatedAt);
history[2].CreatedAt.Should().Be(seal1.CreatedAt);
}
#endregion
#region Configuration Tests
[Fact]
public async Task Configuration_PerFacetOverride_AppliesCorrectly()
{
// Arrange: os-packages has higher threshold
var imageDigest = "sha256:override123";
var baselineSeal = CreateSeal(imageDigest, 100);
await _sealStore.SaveAsync(baselineSeal);
var driftReport = CreateDriftReportWithChurn(imageDigest, baselineSeal.CombinedMerkleRoot, "os-packages", 25m);
var options = new FacetQuotaGateOptions
{
Enabled = true,
DefaultMaxChurnPercent = 10.0m,
FacetOverrides = new Dictionary<string, FacetQuotaOverride>
{
["os-packages"] = new FacetQuotaOverride
{
MaxChurnPercent = 30m, // Higher threshold for OS packages
Action = QuotaExceededAction.Warn
}
}
};
var gate = CreateGate(options);
var context = CreateContextWithDriftReport(driftReport);
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert: 25% churn is within the 30% override threshold
result.Passed.Should().BeTrue();
}
[Fact]
public async Task Configuration_DisabledGate_BypassesAllChecks()
{
// Arrange
var options = new FacetQuotaGateOptions { Enabled = false };
var gate = CreateGate(options);
var context = new PolicyGateContext { Environment = "production" };
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
result.Passed.Should().BeTrue();
result.Reason.Should().Be("Gate disabled");
}
#endregion
#region Helper Methods
private FacetQuotaGate CreateGate(FacetQuotaGateOptions options)
{
return new FacetQuotaGate(options, _driftDetector.Object, NullLogger<FacetQuotaGate>.Instance);
}
private void SetupDriftDetector(FacetDriftReport report)
{
_driftDetector
.Setup(d => d.DetectDriftAsync(It.IsAny<FacetSeal>(), It.IsAny<FacetSeal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(report);
}
private static PolicyGateContext CreateContextWithDriftReport(FacetDriftReport report)
{
var json = JsonSerializer.Serialize(report);
return new PolicyGateContext
{
Environment = "production",
Metadata = new Dictionary<string, string>
{
["FacetDriftReport"] = json
}
};
}
private static MergeResult CreateMergeResult(VexStatus status)
{
var claim = new ScoredClaim
{
SourceId = "test",
Status = status,
OriginalScore = 1.0,
AdjustedScore = 1.0,
ScopeSpecificity = 1,
Accepted = true,
Reason = "test"
};
return new MergeResult
{
Status = status,
Confidence = 0.9,
HasConflicts = false,
AllClaims = [claim],
WinningClaim = claim,
Conflicts = []
};
}
private FacetSeal CreateSeal(string imageDigest, int fileCount)
{
return CreateSealWithTimestamp(imageDigest, fileCount, DateTimeOffset.UtcNow);
}
private FacetSeal CreateSealWithTimestamp(string imageDigest, int fileCount, DateTimeOffset createdAt)
{
var files = Enumerable.Range(0, fileCount)
.Select(i => new FacetFileEntry($"/file{i}.txt", $"sha256:{i:x8}", 100, null))
.ToImmutableArray();
var facetEntry = new FacetEntry(
FacetId: "test-facet",
Files: files,
MerkleRoot: $"sha256:facet{fileCount:x8}");
return new FacetSeal
{
ImageDigest = imageDigest,
SchemaVersion = "1.0.0",
CreatedAt = createdAt,
Facets = [facetEntry],
CombinedMerkleRoot = $"sha256:combined{imageDigest.GetHashCode():x8}{createdAt.Ticks:x8}"
};
}
private static FacetDriftReport CreateDriftReport(string imageDigest, string baselineSealId, QuotaVerdict verdict)
{
return new FacetDriftReport
{
ImageDigest = imageDigest,
BaselineSealId = baselineSealId,
AnalyzedAt = DateTimeOffset.UtcNow,
FacetDrifts = [CreateFacetDrift("test-facet", verdict)],
OverallVerdict = verdict
};
}
private static FacetDriftReport CreateDriftReportWithChurn(
string imageDigest,
string baselineSealId,
string facetId,
decimal churnPercent)
{
var addedCount = (int)(churnPercent * 100 / 100); // For 100 baseline files
var addedFiles = Enumerable.Range(0, addedCount)
.Select(i => new FacetFileEntry($"/added{i}.txt", $"sha256:added{i}", 100, null))
.ToImmutableArray();
var verdict = churnPercent switch
{
< 10 => QuotaVerdict.Ok,
< 20 => QuotaVerdict.Warning,
_ => QuotaVerdict.Blocked
};
var facetDrift = new FacetDrift
{
FacetId = facetId,
Added = addedFiles,
Removed = [],
Modified = [],
DriftScore = churnPercent,
QuotaVerdict = verdict,
BaselineFileCount = 100
};
return new FacetDriftReport
{
ImageDigest = imageDigest,
BaselineSealId = baselineSealId,
AnalyzedAt = DateTimeOffset.UtcNow,
FacetDrifts = [facetDrift],
OverallVerdict = verdict
};
}
private static FacetDrift CreateFacetDrift(string facetId, QuotaVerdict verdict)
{
var addedCount = verdict switch
{
QuotaVerdict.Warning => 15,
QuotaVerdict.Blocked => 35,
QuotaVerdict.RequiresVex => 50,
_ => 0
};
var addedFiles = Enumerable.Range(0, addedCount)
.Select(i => new FacetFileEntry($"/added{i}.txt", $"sha256:added{i}", 100, null))
.ToImmutableArray();
return new FacetDrift
{
FacetId = facetId,
Added = addedFiles,
Removed = [],
Modified = [],
DriftScore = addedCount,
QuotaVerdict = verdict,
BaselineFileCount = 100
};
}
#endregion
}

View File

@@ -0,0 +1,276 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Policy;
using StellaOps.Policy.Determinization;
using StellaOps.Policy.Determinization.Models;
using StellaOps.Policy.Engine.Policies;
namespace StellaOps.Policy.Engine.Tests.Policies;
public class DeterminizationPolicyTests
{
private readonly DeterminizationPolicy _policy;
public DeterminizationPolicyTests()
{
var options = Options.Create(new DeterminizationOptions());
_policy = new DeterminizationPolicy(options, NullLogger<DeterminizationPolicy>.Instance);
}
[Fact]
public void Evaluate_RuntimeEvidenceLoaded_ReturnsEscalated()
{
// Arrange
var context = CreateContext(
runtime: new SignalState<RuntimeEvidence>
{
HasValue = true,
Value = new RuntimeEvidence { ObservedLoaded = true }
});
// Act
var result = _policy.Evaluate(context);
// Assert
result.Status.Should().Be(PolicyVerdictStatus.Escalated);
result.MatchedRule.Should().Be("RuntimeEscalation");
result.Reason.Should().Contain("Runtime evidence shows vulnerable code loaded");
}
[Fact]
public void Evaluate_HighEpss_ReturnsQuarantined()
{
// Arrange
var context = CreateContext(
epss: new SignalState<EpssEvidence>
{
HasValue = true,
Value = new EpssEvidence { Score = 0.8 }
},
environment: DeploymentEnvironment.Production);
// Act
var result = _policy.Evaluate(context);
// Assert
result.Status.Should().Be(PolicyVerdictStatus.Blocked);
result.MatchedRule.Should().Be("EpssQuarantine");
result.Reason.Should().Contain("EPSS score");
}
[Fact]
public void Evaluate_ReachableCode_ReturnsQuarantined()
{
// Arrange
var context = CreateContext(
reachability: new SignalState<ReachabilityEvidence>
{
HasValue = true,
Value = new ReachabilityEvidence { IsReachable = true, Confidence = 0.9 }
});
// Act
var result = _policy.Evaluate(context);
// Assert
result.Status.Should().Be(PolicyVerdictStatus.Blocked);
result.MatchedRule.Should().Be("ReachabilityQuarantine");
result.Reason.Should().Contain("reachable");
}
[Fact]
public void Evaluate_HighEntropyInProduction_ReturnsQuarantined()
{
// Arrange
var context = CreateContext(
entropy: 0.5,
environment: DeploymentEnvironment.Production);
// Act
var result = _policy.Evaluate(context);
// Assert
result.Status.Should().Be(PolicyVerdictStatus.Blocked);
result.MatchedRule.Should().Be("ProductionEntropyBlock");
result.Reason.Should().Contain("High uncertainty");
}
[Fact]
public void Evaluate_StaleEvidence_ReturnsDeferred()
{
// Arrange
var context = CreateContext(
isStale: true);
// Act
var result = _policy.Evaluate(context);
// Assert
result.Status.Should().Be(PolicyVerdictStatus.Deferred);
result.MatchedRule.Should().Be("StaleEvidenceDefer");
result.Reason.Should().Contain("stale");
}
[Fact]
public void Evaluate_ModerateUncertaintyInDev_ReturnsGuardedPass()
{
// Arrange
var context = CreateContext(
entropy: 0.5,
trustScore: 0.3,
environment: DeploymentEnvironment.Development);
// Act
var result = _policy.Evaluate(context);
// Assert
result.Status.Should().Be(PolicyVerdictStatus.GuardedPass);
result.MatchedRule.Should().Be("GuardedAllowNonProd");
result.GuardRails.Should().NotBeNull();
result.GuardRails!.EnableMonitoring.Should().BeTrue();
}
[Fact]
public void Evaluate_UnreachableWithHighConfidence_ReturnsAllowed()
{
// Arrange
var context = CreateContext(
reachability: new SignalState<ReachabilityEvidence>
{
HasValue = true,
Value = new ReachabilityEvidence { IsReachable = false, Confidence = 0.9 }
},
trustScore: 0.8);
// Act
var result = _policy.Evaluate(context);
// Assert
result.Status.Should().Be(PolicyVerdictStatus.Pass);
result.MatchedRule.Should().Be("UnreachableAllow");
result.Reason.Should().Contain("unreachable");
}
[Fact]
public void Evaluate_VexNotAffected_ReturnsAllowed()
{
// Arrange
var context = CreateContext(
vex: new SignalState<VexClaimSummary>
{
HasValue = true,
Value = new VexClaimSummary { IsNotAffected = true, IssuerTrust = 0.9 }
},
trustScore: 0.8);
// Act
var result = _policy.Evaluate(context);
// Assert
result.Status.Should().Be(PolicyVerdictStatus.Pass);
result.MatchedRule.Should().Be("VexNotAffectedAllow");
result.Reason.Should().Contain("not_affected");
}
[Fact]
public void Evaluate_SufficientEvidenceLowEntropy_ReturnsAllowed()
{
// Arrange
var context = CreateContext(
entropy: 0.2,
trustScore: 0.8,
environment: DeploymentEnvironment.Production);
// Act
var result = _policy.Evaluate(context);
// Assert
result.Status.Should().Be(PolicyVerdictStatus.Pass);
result.MatchedRule.Should().Be("SufficientEvidenceAllow");
result.Reason.Should().Contain("Sufficient evidence");
}
[Fact]
public void Evaluate_ModerateUncertaintyTier_ReturnsGuardedPass()
{
// Arrange
var context = CreateContext(
tier: UncertaintyTier.Moderate,
trustScore: 0.5,
entropy: 0.5);
// Act
var result = _policy.Evaluate(context);
// Assert
result.Status.Should().Be(PolicyVerdictStatus.GuardedPass);
result.MatchedRule.Should().Be("GuardedAllowModerateUncertainty");
result.GuardRails.Should().NotBeNull();
}
[Fact]
public void Evaluate_NoMatchingRule_ReturnsDeferred()
{
// Arrange
var context = CreateContext(
entropy: 0.9,
trustScore: 0.1,
environment: DeploymentEnvironment.Production);
// Act
var result = _policy.Evaluate(context);
// Assert
result.Status.Should().Be(PolicyVerdictStatus.Deferred);
result.MatchedRule.Should().Be("DefaultDefer");
result.Reason.Should().Contain("Insufficient evidence");
}
private static DeterminizationContext CreateContext(
SignalState<EpssEvidence>? epss = null,
SignalState<VexClaimSummary>? vex = null,
SignalState<ReachabilityEvidence>? reachability = null,
SignalState<RuntimeEvidence>? runtime = null,
double entropy = 0.0,
double trustScore = 0.0,
UncertaintyTier tier = UncertaintyTier.Minimal,
DeploymentEnvironment environment = DeploymentEnvironment.Development,
bool isStale = false)
{
var snapshot = new SignalSnapshot
{
Cve = "CVE-2024-0001",
Purl = "pkg:npm/test@1.0.0",
Epss = epss ?? SignalState<EpssEvidence>.NotQueried(),
Vex = vex ?? SignalState<VexClaimSummary>.NotQueried(),
Reachability = reachability ?? SignalState<ReachabilityEvidence>.NotQueried(),
Runtime = runtime ?? SignalState<RuntimeEvidence>.NotQueried(),
Backport = SignalState<BackportEvidence>.NotQueried(),
Sbom = SignalState<SbomLineageEvidence>.NotQueried(),
Cvss = SignalState<CvssEvidence>.NotQueried(),
SnapshotAt = DateTimeOffset.UtcNow
};
return new DeterminizationContext
{
SignalSnapshot = snapshot,
UncertaintyScore = new UncertaintyScore
{
Entropy = entropy,
Tier = tier,
Completeness = 1.0 - entropy,
MissingSignals = []
},
Decay = new ObservationDecay
{
LastSignalUpdate = DateTimeOffset.UtcNow.AddDays(-1),
AgeDays = 1,
DecayedMultiplier = isStale ? 0.3 : 0.9,
IsStale = isStale
},
TrustScore = trustScore,
Environment = environment
};
}
}

View File

@@ -0,0 +1,152 @@
using FluentAssertions;
using StellaOps.Policy.Determinization;
using StellaOps.Policy.Engine.Policies;
namespace StellaOps.Policy.Engine.Tests.Policies;
public class DeterminizationRuleSetTests
{
[Fact]
public void Default_RulesAreOrderedByPriority()
{
// Arrange
var options = new DeterminizationOptions();
// Act
var ruleSet = DeterminizationRuleSet.Default(options);
// Assert
ruleSet.Rules.Should().HaveCountGreaterThan(0);
var priorities = ruleSet.Rules.Select(r => r.Priority).ToList();
priorities.Should().BeInAscendingOrder("rules should be evaluable in priority order");
}
[Fact]
public void Default_RuntimeEscalationHasHighestPriority()
{
// Arrange
var options = new DeterminizationOptions();
// Act
var ruleSet = DeterminizationRuleSet.Default(options);
// Assert
var runtimeRule = ruleSet.Rules.First(r => r.Name == "RuntimeEscalation");
runtimeRule.Priority.Should().Be(10, "runtime escalation should have highest priority");
var allOtherRules = ruleSet.Rules.Where(r => r.Name != "RuntimeEscalation");
allOtherRules.Should().AllSatisfy(r => r.Priority.Should().BeGreaterThan(10));
}
[Fact]
public void Default_DefaultDeferHasLowestPriority()
{
// Arrange
var options = new DeterminizationOptions();
// Act
var ruleSet = DeterminizationRuleSet.Default(options);
// Assert
var defaultRule = ruleSet.Rules.First(r => r.Name == "DefaultDefer");
defaultRule.Priority.Should().Be(100, "default defer should be catch-all with lowest priority");
var allOtherRules = ruleSet.Rules.Where(r => r.Name != "DefaultDefer");
allOtherRules.Should().AllSatisfy(r => r.Priority.Should().BeLessThan(100));
}
[Fact]
public void Default_QuarantineRulesBeforeAllowRules()
{
// Arrange
var options = new DeterminizationOptions();
// Act
var ruleSet = DeterminizationRuleSet.Default(options);
// Assert
var epssQuarantine = ruleSet.Rules.First(r => r.Name == "EpssQuarantine");
var reachabilityQuarantine = ruleSet.Rules.First(r => r.Name == "ReachabilityQuarantine");
var productionBlock = ruleSet.Rules.First(r => r.Name == "ProductionEntropyBlock");
var unreachableAllow = ruleSet.Rules.First(r => r.Name == "UnreachableAllow");
var vexAllow = ruleSet.Rules.First(r => r.Name == "VexNotAffectedAllow");
var sufficientEvidenceAllow = ruleSet.Rules.First(r => r.Name == "SufficientEvidenceAllow");
epssQuarantine.Priority.Should().BeLessThan(unreachableAllow.Priority);
reachabilityQuarantine.Priority.Should().BeLessThan(vexAllow.Priority);
productionBlock.Priority.Should().BeLessThan(sufficientEvidenceAllow.Priority);
}
[Fact]
public void Default_AllRulesHaveUniquePriorities()
{
// Arrange
var options = new DeterminizationOptions();
// Act
var ruleSet = DeterminizationRuleSet.Default(options);
// Assert
var priorities = ruleSet.Rules.Select(r => r.Priority).ToList();
priorities.Should().OnlyHaveUniqueItems("each rule should have unique priority for deterministic ordering");
}
[Fact]
public void Default_AllRulesHaveNames()
{
// Arrange
var options = new DeterminizationOptions();
// Act
var ruleSet = DeterminizationRuleSet.Default(options);
// Assert
ruleSet.Rules.Should().AllSatisfy(r =>
{
r.Name.Should().NotBeNullOrWhiteSpace("all rules must have names for audit trail");
});
}
[Fact]
public void Default_Contains11Rules()
{
// Arrange
var options = new DeterminizationOptions();
// Act
var ruleSet = DeterminizationRuleSet.Default(options);
// Assert
ruleSet.Rules.Should().HaveCount(11, "rule set should contain all 11 specified rules");
}
[Fact]
public void Default_ContainsExpectedRules()
{
// Arrange
var options = new DeterminizationOptions();
var expectedRuleNames = new[]
{
"RuntimeEscalation",
"EpssQuarantine",
"ReachabilityQuarantine",
"ProductionEntropyBlock",
"StaleEvidenceDefer",
"GuardedAllowNonProd",
"UnreachableAllow",
"VexNotAffectedAllow",
"SufficientEvidenceAllow",
"GuardedAllowModerateUncertainty",
"DefaultDefer"
};
// Act
var ruleSet = DeterminizationRuleSet.Default(options);
// Assert
var actualNames = ruleSet.Rules.Select(r => r.Name).ToList();
actualNames.Should().BeEquivalentTo(expectedRuleNames);
}
}

View File

@@ -0,0 +1,233 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace StellaOps.Policy.Explainability.Tests;
public class VerdictRationaleRendererTests
{
private readonly VerdictRationaleRenderer _renderer;
public VerdictRationaleRendererTests()
{
_renderer = new VerdictRationaleRenderer(NullLogger<VerdictRationaleRenderer>.Instance);
}
[Fact]
public void Render_Should_CreateCompleteRationale()
{
// Arrange
var input = CreateTestInput();
// Act
var rationale = _renderer.Render(input);
// Assert
rationale.Should().NotBeNull();
rationale.RationaleId.Should().StartWith("rat:sha256:");
rationale.Evidence.Cve.Should().Be("CVE-2024-1234");
rationale.PolicyClause.ClauseId.Should().Be("S2.1");
rationale.Decision.Verdict.Should().Be("Affected");
}
[Fact]
public void Render_Should_BeContentAddressed()
{
// Arrange
var timestamp = new DateTimeOffset(2026, 1, 6, 12, 0, 0, TimeSpan.Zero);
var input1 = CreateTestInput(timestamp);
var input2 = CreateTestInput(timestamp); // Identical input with same timestamp
// Act
var rationale1 = _renderer.Render(input1);
var rationale2 = _renderer.Render(input2);
// Assert
rationale1.RationaleId.Should().Be(rationale2.RationaleId);
}
[Fact]
public void RenderPlainText_Should_ProduceFourLineFormat()
{
// Arrange
var input = CreateTestInput();
var rationale = _renderer.Render(input);
// Act
var text = _renderer.RenderPlainText(rationale);
// Assert
var lines = text.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
lines.Should().HaveCount(4);
lines[0].Should().Contain("CVE-2024-1234");
lines[1].Should().Contain("Policy S2.1");
lines[2].Should().Contain("Path witness");
lines[3].Should().Contain("Affected");
}
[Fact]
public void RenderMarkdown_Should_IncludeHeaders()
{
// Arrange
var input = CreateTestInput();
var rationale = _renderer.Render(input);
// Act
var markdown = _renderer.RenderMarkdown(rationale);
// Assert
markdown.Should().Contain("## Verdict Rationale");
markdown.Should().Contain("### Evidence");
markdown.Should().Contain("### Policy Clause");
markdown.Should().Contain("### Attestations");
markdown.Should().Contain("### Decision");
markdown.Should().Contain(rationale.RationaleId);
}
[Fact]
public void RenderJson_Should_ProduceValidJson()
{
// Arrange
var input = CreateTestInput();
var rationale = _renderer.Render(input);
// Act
var json = _renderer.RenderJson(rationale);
// Assert
json.Should().NotBeNullOrEmpty();
// RFC 8785 canonical JSON uses snake_case
json.Should().Contain("\"rationale_id\"");
json.Should().Contain("\"evidence\"");
json.Should().Contain("\"policy_clause\"");
json.Should().Contain("\"attestations\"");
json.Should().Contain("\"decision\"");
}
[Fact]
public void Evidence_Should_IncludeReachabilityDetails()
{
// Arrange
var input = CreateTestInput();
// Act
var rationale = _renderer.Render(input);
// Assert
rationale.Evidence.FormattedText.Should().Contain("foo_read");
rationale.Evidence.FormattedText.Should().Contain("/usr/bin/tool");
}
[Fact]
public void Evidence_Should_HandleMissingReachability()
{
// Arrange
var input = CreateTestInput() with { Reachability = null };
// Act
var rationale = _renderer.Render(input);
// Assert
rationale.Evidence.FormattedText.Should().NotContain("reachable");
}
[Fact]
public void Attestations_Should_HandleNoAttestations()
{
// Arrange
var input = CreateTestInput() with
{
PathWitness = null,
VexStatements = null,
Provenance = null
};
// Act
var rationale = _renderer.Render(input);
// Assert
rationale.Attestations.FormattedText.Should().Be("No attestations available.");
}
[Fact]
public void Decision_Should_IncludeMitigation()
{
// Arrange
var input = CreateTestInput();
// Act
var rationale = _renderer.Render(input);
// Assert
rationale.Decision.FormattedText.Should().Contain("upgrade or backport");
rationale.Decision.FormattedText.Should().Contain("KB-123");
}
private VerdictRationaleInput CreateTestInput(DateTimeOffset? generatedAt = null)
{
return new VerdictRationaleInput
{
VerdictRef = new VerdictReference
{
AttestationId = "att:sha256:abc123",
ArtifactDigest = "sha256:def456",
PolicyId = "policy-1",
Cve = "CVE-2024-1234",
ComponentPurl = "pkg:maven/org.example/lib@1.0.0"
},
Cve = "CVE-2024-1234",
Component = new ComponentIdentity
{
Purl = "pkg:maven/org.example/lib@1.0.0",
Name = "libxyz",
Version = "1.2.3",
Ecosystem = "maven"
},
Reachability = new ReachabilityDetail
{
VulnerableFunction = "foo_read",
EntryPoint = "/usr/bin/tool",
PathSummary = "main->parse->foo_read"
},
PolicyClauseId = "S2.1",
PolicyRuleDescription = "reachable+EPSS>=0.2 => triage=P1",
PolicyConditions = new[] { "reachable", "EPSS>=0.2" },
PathWitness = new AttestationReference
{
Id = "witness:sha256:path123",
Type = "PathWitness",
Digest = "sha256:path123",
Summary = "Build-ID match to vendor advisory"
},
VexStatements = new[]
{
new AttestationReference
{
Id = "vex:sha256:vex123",
Type = "VEX",
Digest = "sha256:vex123",
Summary = "affected"
}
},
Provenance = new AttestationReference
{
Id = "prov:sha256:prov123",
Type = "Provenance",
Digest = "sha256:prov123",
Summary = "SLSA L3"
},
Verdict = "Affected",
Score = 0.72,
Recommendation = "Mitigation recommended",
Mitigation = new MitigationGuidance
{
Action = "upgrade or backport",
Details = "KB-123"
},
GeneratedAt = generatedAt ?? DateTimeOffset.UtcNow,
VerdictDigest = "sha256:verdict123",
PolicyDigest = "sha256:policy123",
EvidenceDigest = "sha256:evidence123"
};
}
}

View File

@@ -0,0 +1,220 @@
// <copyright file="FacetQuotaGateTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
// Sprint: SPRINT_20260105_002_003_FACET (QTA-014)
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Facet;
using StellaOps.Policy.Confidence.Models;
using StellaOps.Policy.Gates;
using StellaOps.Policy.TrustLattice;
using Xunit;
namespace StellaOps.Policy.Tests.Gates;
/// <summary>
/// Unit tests for <see cref="FacetQuotaGate"/> evaluation scenarios.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FacetQuotaGateTests
{
private readonly Mock<IFacetDriftDetector> _driftDetectorMock;
private readonly FacetQuotaGateOptions _options;
private FacetQuotaGate _gate;
public FacetQuotaGateTests()
{
_driftDetectorMock = new Mock<IFacetDriftDetector>();
_options = new FacetQuotaGateOptions { Enabled = true };
_gate = CreateGate(_options);
}
private FacetQuotaGate CreateGate(FacetQuotaGateOptions options)
{
return new FacetQuotaGate(options, _driftDetectorMock.Object, NullLogger<FacetQuotaGate>.Instance);
}
[Fact]
public async Task EvaluateAsync_WhenDisabled_ReturnsPass()
{
// Arrange
var options = new FacetQuotaGateOptions { Enabled = false };
var gate = CreateGate(options);
var context = CreateContext();
var mergeResult = CreateMergeResult();
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
Assert.True(result.Passed);
Assert.Equal("Gate disabled", result.Reason);
}
[Fact]
public async Task EvaluateAsync_WhenNoSealAndNoSealActionIsPass_ReturnsPass()
{
// Arrange - no drift report in context means no seal
var options = new FacetQuotaGateOptions { Enabled = true, NoSealAction = NoSealAction.Pass };
var gate = CreateGate(options);
var context = CreateContext();
var mergeResult = CreateMergeResult();
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
Assert.True(result.Passed);
Assert.Contains("first scan", result.Reason);
}
[Fact]
public async Task EvaluateAsync_WhenNoSealAndNoSealActionIsWarn_ReturnsPassWithWarning()
{
// Arrange
var options = new FacetQuotaGateOptions { Enabled = true, NoSealAction = NoSealAction.Warn };
var gate = CreateGate(options);
var context = CreateContext();
var mergeResult = CreateMergeResult();
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
Assert.True(result.Passed);
Assert.Equal("no_baseline_seal", result.Reason);
Assert.True(result.Details.ContainsKey("action"));
}
[Fact]
public async Task EvaluateAsync_WhenNoSealAndNoSealActionIsBlock_ReturnsFail()
{
// Arrange
var options = new FacetQuotaGateOptions { Enabled = true, NoSealAction = NoSealAction.Block };
var gate = CreateGate(options);
var context = CreateContext();
var mergeResult = CreateMergeResult();
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
Assert.False(result.Passed);
Assert.Equal("no_baseline_seal", result.Reason);
}
[Fact]
public async Task EvaluateAsync_NullMergeResult_ThrowsArgumentNullException()
{
// Arrange
var context = CreateContext();
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(() =>
_gate.EvaluateAsync(null!, context));
}
[Fact]
public async Task EvaluateAsync_NullContext_ThrowsArgumentNullException()
{
// Arrange
var mergeResult = CreateMergeResult();
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(() =>
_gate.EvaluateAsync(mergeResult, null!));
}
private static PolicyGateContext CreateContext()
{
return new PolicyGateContext
{
Environment = "test"
};
}
private static PolicyGateContext CreateContextWithDriftReportJson(string driftReportJson)
{
return new PolicyGateContext
{
Environment = "test",
Metadata = new Dictionary<string, string>
{
["FacetDriftReport"] = driftReportJson
}
};
}
private static MergeResult CreateMergeResult()
{
var emptyClaim = new ScoredClaim
{
SourceId = "test",
Status = VexStatus.NotAffected,
OriginalScore = 1.0,
AdjustedScore = 1.0,
ScopeSpecificity = 1,
Accepted = true,
Reason = "test"
};
return new MergeResult
{
Status = VexStatus.NotAffected,
Confidence = 0.9,
HasConflicts = false,
AllClaims = [emptyClaim],
WinningClaim = emptyClaim,
Conflicts = []
};
}
private static FacetDriftReport CreateDriftReport(QuotaVerdict verdict)
{
return new FacetDriftReport
{
ImageDigest = "sha256:abc123",
BaselineSealId = "seal-123",
AnalyzedAt = DateTimeOffset.UtcNow,
FacetDrifts = [CreateFacetDrift("test-facet", verdict)],
OverallVerdict = verdict
};
}
private static FacetDrift CreateFacetDrift(
string facetId,
QuotaVerdict verdict,
int baselineFileCount = 100)
{
// ChurnPercent is computed from TotalChanges / BaselineFileCount
// For different verdicts, we add files appropriately
var addedCount = verdict switch
{
QuotaVerdict.Warning => 10, // 10% churn
QuotaVerdict.Blocked => 30, // 30% churn
QuotaVerdict.RequiresVex => 50, // 50% churn
_ => 0
};
var addedFiles = Enumerable.Range(0, addedCount)
.Select(i => new FacetFileEntry(
$"/added/file{i}.txt",
$"sha256:added{i}",
100,
null))
.ToImmutableArray();
return new FacetDrift
{
FacetId = facetId,
Added = addedFiles,
Removed = [],
Modified = [],
DriftScore = addedCount,
QuotaVerdict = verdict,
BaselineFileCount = baselineFileCount
};
}
}

View File

@@ -17,9 +17,11 @@
</ItemGroup>
<ItemGroup>
<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.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Facet/StellaOps.Facet.csproj" />
</ItemGroup>
</Project>