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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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