Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user