Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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}"
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 < 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 >= 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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()}";
|
||||
}
|
||||
}
|
||||
229
src/Policy/__Libraries/StellaOps.Policy/Gates/FacetQuotaGate.cs
Normal file
229
src/Policy/__Libraries/StellaOps.Policy/Gates/FacetQuotaGate.cs
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user