Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
This commit is contained in:
@@ -118,10 +118,61 @@ Key notes:
|
||||
| **API** (`Api/`) | Minimal API endpoints, DTO validation, problem responses, idempotency. | Generated clients for CLI/UI. |
|
||||
| **Observability** (`Telemetry/`) | Metrics (`policy_run_seconds`, `rules_fired_total`), traces, structured logs. | Sampled rule-hit logs with redaction. |
|
||||
| **Offline Adapter** (`Offline/`) | Bundle export/import (policies, simulations, runs), sealed-mode enforcement. | Uses DSSE signing via Signer service; bundles include IR hash, input cursors, shadow flag, coverage artefacts. |
|
||||
| **VEX Decision Emitter** (`Vex/Emitter/`) | Build OpenVEX statements, attach reachability evidence hashes, request DSSE signing, and persist artifacts for Export Center / bench repo. | New (Sprint 401); integrates with Signer predicate `stella.ops/vexDecision@v1` and Attestor Rekor logging. |
|
||||
| **VEX Decision Emitter** (`Vex/Emitter/`) | Build OpenVEX statements, attach reachability evidence hashes, request DSSE signing, and persist artifacts for Export Center / bench repo. | New (Sprint 401); integrates with Signer predicate `stella.ops/vexDecision@v1` and Attestor Rekor logging. || **Determinization** (`Policy.Determinization/`) | Scores uncertainty/trust based on signal completeness and age; calculates entropy (0.0 = complete, 1.0 = no knowledge), confidence decay (exponential half-life), and aggregated trust scores; emits metrics for uncertainty/decay/trust; supports VEX-trust integration. | Library consumed by Signals and VEX subsystems; configuration via `Determinization` section. |
|
||||
|
||||
---
|
||||
|
||||
### 3.1 · Determinization Configuration
|
||||
|
||||
The Determinization subsystem calculates uncertainty scores based on signal completeness (entropy), confidence decay based on observation age (exponential half-life), and aggregated trust scores. Configuration options in `appsettings.json` under `Determinization`:
|
||||
|
||||
```json
|
||||
{
|
||||
"Determinization": {
|
||||
"SignalWeights": {
|
||||
"VexWeight": 0.35,
|
||||
"EpssWeight": 0.10,
|
||||
"ReachabilityWeight": 0.25,
|
||||
"RuntimeWeight": 0.15,
|
||||
"BackportWeight": 0.10,
|
||||
"SbomLineageWeight": 0.05
|
||||
},
|
||||
"PriorDistribution": "Conservative",
|
||||
"ConfidenceHalfLifeDays": 14.0,
|
||||
"ConfidenceFloor": 0.1,
|
||||
"ManualReviewEntropyThreshold": 0.60,
|
||||
"RefreshEntropyThreshold": 0.40,
|
||||
"StaleObservationDays": 30.0,
|
||||
"EnableDetailedLogging": false,
|
||||
"EnableAutoRefresh": true,
|
||||
"MaxSignalQueryRetries": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `SignalWeights` | Object | See above | Relative weights for each signal type in entropy calculation. Weights are normalized to sum to 1.0. VEX carries highest weight (0.35), followed by Reachability (0.25), Runtime (0.15), EPSS/Backport (0.10 each), and SBOM lineage (0.05). |
|
||||
| `PriorDistribution` | Enum | `Conservative` | Prior distribution for missing signals. Options: `Conservative` (pessimistic), `Neutral`, `Optimistic`. Affects uncertainty tier classification when signals are unavailable. |
|
||||
| `ConfidenceHalfLifeDays` | Double | `14.0` | Half-life period for confidence decay in days. Confidence decays exponentially: `exp(-ln(2) * age_days / half_life_days)`. |
|
||||
| `ConfidenceFloor` | Double | `0.1` | Minimum confidence value after decay (0.0-1.0). Prevents confidence from decaying to zero, maintaining baseline trust even for very old observations. |
|
||||
| `ManualReviewEntropyThreshold` | Double | `0.60` | Entropy threshold for triggering manual review (0.0-1.0). Findings with entropy ≥ this value require human intervention due to insufficient signal coverage. |
|
||||
| `RefreshEntropyThreshold` | Double | `0.40` | Entropy threshold for triggering signal refresh (0.0-1.0). Findings with entropy ≥ this value should attempt to gather more signals before verdict. |
|
||||
| `StaleObservationDays` | Double | `30.0` | Maximum age before an observation is considered stale (days). Used in conjunction with decay calculations and auto-refresh triggers. |
|
||||
| `EnableDetailedLogging` | Boolean | `false` | Enable verbose logging for entropy/decay/trust calculations. Useful for debugging but increases log volume significantly. |
|
||||
| `EnableAutoRefresh` | Boolean | `true` | Automatically trigger signal refresh when entropy exceeds `RefreshEntropyThreshold`. Requires integration with signal providers. |
|
||||
| `MaxSignalQueryRetries` | Integer | `3` | Maximum retry attempts for failed signal provider queries before marking signal as unavailable. |
|
||||
|
||||
**Metrics emitted:**
|
||||
|
||||
- `stellaops_determinization_uncertainty_entropy` (histogram, unit: ratio): Uncertainty entropy score per CVE/PURL pair. Tags: `cve`, `purl`.
|
||||
- `stellaops_determinization_decay_multiplier` (histogram, unit: ratio): Confidence decay multiplier based on observation age. Tags: `half_life_days`, `age_days`.
|
||||
|
||||
**Usage in policies:**
|
||||
|
||||
Determinization scores are exposed to SPL policies via the `signals.trust.*` and `signals.uncertainty.*` namespaces. Use `signals.uncertainty.entropy` to access entropy values and `signals.trust.score` for aggregated trust scores that combine VEX, reachability, runtime, and other signals with decay/weighting.
|
||||
---
|
||||
|
||||
## 4 · Data Model & Persistence
|
||||
|
||||
### 4.1 Collections
|
||||
|
||||
944
docs/modules/policy/determinization-architecture.md
Normal file
944
docs/modules/policy/determinization-architecture.md
Normal file
@@ -0,0 +1,944 @@
|
||||
# Policy Determinization Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
The **Determinization** subsystem handles CVEs that arrive without complete evidence (EPSS, VEX, reachability). Rather than blocking pipelines or silently ignoring unknowns, it treats them as **probabilistic observations** that can mature as evidence arrives.
|
||||
|
||||
**Design Principles:**
|
||||
1. **Uncertainty is first-class** - Missing signals contribute to entropy, not guesswork
|
||||
2. **Graceful degradation** - Pipelines continue with guardrails, not hard blocks
|
||||
3. **Automatic hardening** - Policies tighten as evidence accumulates
|
||||
4. **Full auditability** - Every decision traces back to evidence state
|
||||
|
||||
## Problem Statement
|
||||
|
||||
When a CVE is discovered against a component, several scenarios create uncertainty:
|
||||
|
||||
| Scenario | Current Behavior | Desired Behavior |
|
||||
|----------|------------------|------------------|
|
||||
| EPSS not yet published | Treat as unknown severity | Explicit `SignalState.NotQueried` with default prior |
|
||||
| VEX statement missing | Assume affected | Explicit uncertainty with configurable policy |
|
||||
| Reachability indeterminate | Conservative block | Allow with guardrails in non-prod |
|
||||
| Conflicting VEX sources | K4 Conflict state | Entropy penalty + human review trigger |
|
||||
| Stale evidence (>14 days) | No special handling | Decay-adjusted confidence + auto-review |
|
||||
|
||||
## Architecture
|
||||
|
||||
### Component Diagram
|
||||
|
||||
```
|
||||
+------------------------+
|
||||
| Policy Engine |
|
||||
| (Verdict Evaluation) |
|
||||
+------------------------+
|
||||
|
|
||||
v
|
||||
+----------------+ +-------------------+ +------------------------+
|
||||
| Feedser |--->| Signal Aggregator |-->| Determinization Gate |
|
||||
| (EPSS/VEX/KEV) | | (Null-aware) | | (Entropy Thresholds) |
|
||||
+----------------+ +-------------------+ +------------------------+
|
||||
| |
|
||||
v v
|
||||
+-------------------+ +-------------------+
|
||||
| Uncertainty Score | | GuardRails Policy |
|
||||
| Calculator | | (Allow/Quarantine)|
|
||||
+-------------------+ +-------------------+
|
||||
| |
|
||||
v v
|
||||
+-------------------+ +-------------------+
|
||||
| Decay Calculator | | Observation State |
|
||||
| (Half-life) | | (pending_determ) |
|
||||
+-------------------+ +-------------------+
|
||||
```
|
||||
|
||||
### Library Structure
|
||||
|
||||
```
|
||||
src/Policy/__Libraries/StellaOps.Policy.Determinization/
|
||||
├── Models/
|
||||
│ ├── ObservationState.cs # CVE observation lifecycle states
|
||||
│ ├── SignalState.cs # Null-aware signal wrapper
|
||||
│ ├── SignalSnapshot.cs # Point-in-time signal collection
|
||||
│ ├── UncertaintyScore.cs # Knowledge completeness entropy
|
||||
│ ├── ObservationDecay.cs # Per-CVE decay configuration
|
||||
│ ├── GuardRails.cs # Guardrail policy outcomes
|
||||
│ └── DeterminizationContext.cs # Evaluation context container
|
||||
├── Scoring/
|
||||
│ ├── IUncertaintyScoreCalculator.cs
|
||||
│ ├── UncertaintyScoreCalculator.cs # entropy = 1 - evidence_sum
|
||||
│ ├── IDecayedConfidenceCalculator.cs
|
||||
│ ├── DecayedConfidenceCalculator.cs # Half-life decay application
|
||||
│ ├── SignalWeights.cs # Configurable signal weights
|
||||
│ └── PriorDistribution.cs # Default priors for missing signals
|
||||
├── Policies/
|
||||
│ ├── IDeterminizationPolicy.cs
|
||||
│ ├── DeterminizationPolicy.cs # Allow/quarantine/escalate rules
|
||||
│ ├── GuardRailsPolicy.cs # Guardrails configuration
|
||||
│ ├── DeterminizationRuleSet.cs # Rule definitions
|
||||
│ └── EnvironmentThresholds.cs # Per-environment thresholds
|
||||
├── Gates/
|
||||
│ ├── IDeterminizationGate.cs
|
||||
│ ├── DeterminizationGate.cs # Policy engine gate
|
||||
│ └── DeterminizationGateOptions.cs
|
||||
├── Subscriptions/
|
||||
│ ├── ISignalUpdateSubscription.cs
|
||||
│ ├── SignalUpdateHandler.cs # Re-evaluation on new signals
|
||||
│ └── DeterminizationEventTypes.cs
|
||||
├── DeterminizationOptions.cs # Global options
|
||||
└── ServiceCollectionExtensions.cs # DI registration
|
||||
```
|
||||
|
||||
## Data Models
|
||||
|
||||
### ObservationState
|
||||
|
||||
Represents the lifecycle state of a CVE observation, orthogonal to VEX status:
|
||||
|
||||
```csharp
|
||||
/// <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
|
||||
}
|
||||
```
|
||||
|
||||
### SignalState<T>
|
||||
|
||||
Null-aware wrapper distinguishing "not queried" from "queried, value null":
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Wraps a signal value with query status metadata.
|
||||
/// Distinguishes between: not queried, queried with value, queried but absent, query failed.
|
||||
/// </summary>
|
||||
public sealed record SignalState<T>
|
||||
{
|
||||
/// <summary>Status of the signal query.</summary>
|
||||
public required SignalQueryStatus Status { get; init; }
|
||||
|
||||
/// <summary>Signal value if Status is Queried and value exists.</summary>
|
||||
public T? Value { get; init; }
|
||||
|
||||
/// <summary>When the signal was last queried (UTC).</summary>
|
||||
public DateTimeOffset? QueriedAt { get; init; }
|
||||
|
||||
/// <summary>Reason for failure if Status is Failed.</summary>
|
||||
public string? FailureReason { get; init; }
|
||||
|
||||
/// <summary>Source that provided the value (feed ID, issuer, etc.).</summary>
|
||||
public string? Source { get; init; }
|
||||
|
||||
/// <summary>Whether this signal contributes to uncertainty (true if not queried or failed).</summary>
|
||||
public bool ContributesToUncertainty =>
|
||||
Status is SignalQueryStatus.NotQueried or SignalQueryStatus.Failed;
|
||||
|
||||
/// <summary>Whether this signal has a usable value.</summary>
|
||||
public bool HasValue => Status == SignalQueryStatus.Queried && Value is not null;
|
||||
}
|
||||
|
||||
public enum SignalQueryStatus
|
||||
{
|
||||
/// <summary>Signal source not yet queried.</summary>
|
||||
NotQueried = 0,
|
||||
|
||||
/// <summary>Signal source queried; value may be present or absent.</summary>
|
||||
Queried = 1,
|
||||
|
||||
/// <summary>Signal query failed (timeout, network, parse error).</summary>
|
||||
Failed = 2
|
||||
}
|
||||
```
|
||||
|
||||
### SignalSnapshot
|
||||
|
||||
Point-in-time collection of all signals for a CVE observation:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Immutable snapshot of all signals for a CVE observation at a point in time.
|
||||
/// </summary>
|
||||
public sealed record SignalSnapshot
|
||||
{
|
||||
/// <summary>CVE identifier (e.g., CVE-2026-12345).</summary>
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>Subject component (PURL).</summary>
|
||||
public required string SubjectPurl { get; init; }
|
||||
|
||||
/// <summary>Snapshot capture time (UTC).</summary>
|
||||
public required DateTimeOffset CapturedAt { get; init; }
|
||||
|
||||
/// <summary>EPSS score signal.</summary>
|
||||
public required SignalState<EpssEvidence> Epss { get; init; }
|
||||
|
||||
/// <summary>VEX claim signal.</summary>
|
||||
public required SignalState<VexClaimSummary> Vex { get; init; }
|
||||
|
||||
/// <summary>Reachability determination signal.</summary>
|
||||
public required SignalState<ReachabilityEvidence> Reachability { get; init; }
|
||||
|
||||
/// <summary>Runtime observation signal (eBPF, dyld, ETW).</summary>
|
||||
public required SignalState<RuntimeEvidence> Runtime { get; init; }
|
||||
|
||||
/// <summary>Fix backport detection signal.</summary>
|
||||
public required SignalState<BackportEvidence> Backport { get; init; }
|
||||
|
||||
/// <summary>SBOM lineage signal.</summary>
|
||||
public required SignalState<SbomLineageEvidence> SbomLineage { get; init; }
|
||||
|
||||
/// <summary>Known Exploited Vulnerability flag.</summary>
|
||||
public required SignalState<bool> Kev { get; init; }
|
||||
|
||||
/// <summary>CVSS score signal.</summary>
|
||||
public required SignalState<CvssEvidence> Cvss { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
### UncertaintyScore
|
||||
|
||||
Knowledge completeness measurement (not code entropy):
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Measures knowledge completeness for a CVE observation.
|
||||
/// High entropy (close to 1.0) means many signals are missing.
|
||||
/// Low entropy (close to 0.0) means comprehensive evidence.
|
||||
/// </summary>
|
||||
public sealed record UncertaintyScore
|
||||
{
|
||||
/// <summary>Entropy value [0.0-1.0]. Higher = more uncertain.</summary>
|
||||
public required double Entropy { get; init; }
|
||||
|
||||
/// <summary>Completeness value [0.0-1.0]. Higher = more complete. (1 - Entropy)</summary>
|
||||
public double Completeness => 1.0 - Entropy;
|
||||
|
||||
/// <summary>Signals that are missing or failed.</summary>
|
||||
public required ImmutableArray<SignalGap> MissingSignals { get; init; }
|
||||
|
||||
/// <summary>Weighted sum of present signals.</summary>
|
||||
public required double WeightedEvidenceSum { get; init; }
|
||||
|
||||
/// <summary>Maximum possible weighted sum (all signals present).</summary>
|
||||
public required double MaxPossibleWeight { get; init; }
|
||||
|
||||
/// <summary>Tier classification based on entropy.</summary>
|
||||
public UncertaintyTier Tier => Entropy switch
|
||||
{
|
||||
<= 0.2 => UncertaintyTier.VeryLow, // Comprehensive evidence
|
||||
<= 0.4 => UncertaintyTier.Low, // Good evidence coverage
|
||||
<= 0.6 => UncertaintyTier.Medium, // Moderate gaps
|
||||
<= 0.8 => UncertaintyTier.High, // Significant gaps
|
||||
_ => UncertaintyTier.VeryHigh // Minimal evidence
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record SignalGap(
|
||||
string SignalName,
|
||||
double Weight,
|
||||
SignalQueryStatus Status,
|
||||
string? Reason);
|
||||
|
||||
public enum UncertaintyTier
|
||||
{
|
||||
VeryLow = 0, // Entropy <= 0.2
|
||||
Low = 1, // Entropy <= 0.4
|
||||
Medium = 2, // Entropy <= 0.6
|
||||
High = 3, // Entropy <= 0.8
|
||||
VeryHigh = 4 // Entropy > 0.8
|
||||
}
|
||||
```
|
||||
|
||||
### ObservationDecay
|
||||
|
||||
Time-based confidence decay configuration:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Tracks evidence freshness decay for a CVE observation.
|
||||
/// </summary>
|
||||
public sealed record ObservationDecay
|
||||
{
|
||||
/// <summary>Half-life for confidence decay. Default: 14 days per advisory.</summary>
|
||||
public required TimeSpan HalfLife { get; init; }
|
||||
|
||||
/// <summary>Minimum confidence floor (never decays below). Default: 0.35.</summary>
|
||||
public required double Floor { get; init; }
|
||||
|
||||
/// <summary>Last time any signal was updated (UTC).</summary>
|
||||
public required DateTimeOffset LastSignalUpdate { get; init; }
|
||||
|
||||
/// <summary>Current decayed confidence multiplier [Floor-1.0].</summary>
|
||||
public required double DecayedMultiplier { get; init; }
|
||||
|
||||
/// <summary>When next auto-review is scheduled (UTC).</summary>
|
||||
public DateTimeOffset? NextReviewAt { get; init; }
|
||||
|
||||
/// <summary>Whether decay has triggered stale state.</summary>
|
||||
public bool IsStale { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
### GuardRails
|
||||
|
||||
Policy outcome with monitoring requirements:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Guardrails applied when allowing uncertain observations.
|
||||
/// </summary>
|
||||
public sealed record GuardRails
|
||||
{
|
||||
/// <summary>Enable runtime monitoring for this observation.</summary>
|
||||
public required bool EnableRuntimeMonitoring { get; init; }
|
||||
|
||||
/// <summary>Interval for automatic re-review.</summary>
|
||||
public required TimeSpan ReviewInterval { get; init; }
|
||||
|
||||
/// <summary>EPSS threshold that triggers automatic escalation.</summary>
|
||||
public required double EpssEscalationThreshold { get; init; }
|
||||
|
||||
/// <summary>Reachability status that triggers escalation.</summary>
|
||||
public required ImmutableArray<string> EscalatingReachabilityStates { get; init; }
|
||||
|
||||
/// <summary>Maximum time in guarded state before forced review.</summary>
|
||||
public required TimeSpan MaxGuardedDuration { get; init; }
|
||||
|
||||
/// <summary>Alert channels for this observation.</summary>
|
||||
public ImmutableArray<string> AlertChannels { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>Additional context for audit trail.</summary>
|
||||
public string? PolicyRationale { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
## Scoring Algorithms
|
||||
|
||||
### Uncertainty Score Calculation
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Calculates knowledge completeness entropy from signal snapshot.
|
||||
/// Formula: entropy = 1 - (sum of weighted present signals / max possible weight)
|
||||
/// </summary>
|
||||
public sealed class UncertaintyScoreCalculator : IUncertaintyScoreCalculator
|
||||
{
|
||||
private readonly SignalWeights _weights;
|
||||
|
||||
public UncertaintyScore Calculate(SignalSnapshot snapshot)
|
||||
{
|
||||
var gaps = new List<SignalGap>();
|
||||
var weightedSum = 0.0;
|
||||
var maxWeight = _weights.TotalWeight;
|
||||
|
||||
// EPSS signal
|
||||
if (snapshot.Epss.HasValue)
|
||||
weightedSum += _weights.Epss;
|
||||
else
|
||||
gaps.Add(new SignalGap("EPSS", _weights.Epss, snapshot.Epss.Status, snapshot.Epss.FailureReason));
|
||||
|
||||
// VEX signal
|
||||
if (snapshot.Vex.HasValue)
|
||||
weightedSum += _weights.Vex;
|
||||
else
|
||||
gaps.Add(new SignalGap("VEX", _weights.Vex, snapshot.Vex.Status, snapshot.Vex.FailureReason));
|
||||
|
||||
// Reachability signal
|
||||
if (snapshot.Reachability.HasValue)
|
||||
weightedSum += _weights.Reachability;
|
||||
else
|
||||
gaps.Add(new SignalGap("Reachability", _weights.Reachability, snapshot.Reachability.Status, snapshot.Reachability.FailureReason));
|
||||
|
||||
// Runtime signal
|
||||
if (snapshot.Runtime.HasValue)
|
||||
weightedSum += _weights.Runtime;
|
||||
else
|
||||
gaps.Add(new SignalGap("Runtime", _weights.Runtime, snapshot.Runtime.Status, snapshot.Runtime.FailureReason));
|
||||
|
||||
// Backport signal
|
||||
if (snapshot.Backport.HasValue)
|
||||
weightedSum += _weights.Backport;
|
||||
else
|
||||
gaps.Add(new SignalGap("Backport", _weights.Backport, snapshot.Backport.Status, snapshot.Backport.FailureReason));
|
||||
|
||||
// SBOM Lineage signal
|
||||
if (snapshot.SbomLineage.HasValue)
|
||||
weightedSum += _weights.SbomLineage;
|
||||
else
|
||||
gaps.Add(new SignalGap("SBOMLineage", _weights.SbomLineage, snapshot.SbomLineage.Status, snapshot.SbomLineage.FailureReason));
|
||||
|
||||
var entropy = 1.0 - (weightedSum / maxWeight);
|
||||
|
||||
return new UncertaintyScore
|
||||
{
|
||||
Entropy = Math.Clamp(entropy, 0.0, 1.0),
|
||||
MissingSignals = gaps.ToImmutableArray(),
|
||||
WeightedEvidenceSum = weightedSum,
|
||||
MaxPossibleWeight = maxWeight
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Signal Weights (Configurable)
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Configurable weights for signal contribution to completeness.
|
||||
/// Weights should sum to 1.0 for normalized entropy.
|
||||
/// </summary>
|
||||
public sealed record SignalWeights
|
||||
{
|
||||
public double Vex { get; init; } = 0.25;
|
||||
public double Epss { get; init; } = 0.15;
|
||||
public double Reachability { get; init; } = 0.25;
|
||||
public double Runtime { get; init; } = 0.15;
|
||||
public double Backport { get; init; } = 0.10;
|
||||
public double SbomLineage { get; init; } = 0.10;
|
||||
|
||||
public double TotalWeight =>
|
||||
Vex + Epss + Reachability + Runtime + Backport + SbomLineage;
|
||||
|
||||
public SignalWeights Normalize()
|
||||
{
|
||||
var total = TotalWeight;
|
||||
return new SignalWeights
|
||||
{
|
||||
Vex = Vex / total,
|
||||
Epss = Epss / total,
|
||||
Reachability = Reachability / total,
|
||||
Runtime = Runtime / total,
|
||||
Backport = Backport / total,
|
||||
SbomLineage = SbomLineage / total
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Decay Calculation
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Applies exponential decay to confidence based on evidence staleness.
|
||||
/// Formula: decayed = max(floor, exp(-ln(2) * age_days / half_life_days))
|
||||
/// </summary>
|
||||
public sealed class DecayedConfidenceCalculator : IDecayedConfidenceCalculator
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ObservationDecay Calculate(
|
||||
DateTimeOffset lastSignalUpdate,
|
||||
TimeSpan halfLife,
|
||||
double floor = 0.35)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var ageDays = (now - lastSignalUpdate).TotalDays;
|
||||
|
||||
double decayedMultiplier;
|
||||
if (ageDays <= 0)
|
||||
{
|
||||
decayedMultiplier = 1.0;
|
||||
}
|
||||
else
|
||||
{
|
||||
var rawDecay = Math.Exp(-Math.Log(2) * ageDays / halfLife.TotalDays);
|
||||
decayedMultiplier = Math.Max(rawDecay, floor);
|
||||
}
|
||||
|
||||
// Calculate next review time (when decay crosses 50% threshold)
|
||||
var daysTo50Percent = halfLife.TotalDays;
|
||||
var nextReviewAt = lastSignalUpdate.AddDays(daysTo50Percent);
|
||||
|
||||
return new ObservationDecay
|
||||
{
|
||||
HalfLife = halfLife,
|
||||
Floor = floor,
|
||||
LastSignalUpdate = lastSignalUpdate,
|
||||
DecayedMultiplier = decayedMultiplier,
|
||||
NextReviewAt = nextReviewAt,
|
||||
IsStale = decayedMultiplier <= 0.5
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Policy Rules
|
||||
|
||||
### Determinization Policy
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Implements allow/quarantine/escalate logic per advisory specification.
|
||||
/// </summary>
|
||||
public sealed class DeterminizationPolicy : IDeterminizationPolicy
|
||||
{
|
||||
private readonly DeterminizationOptions _options;
|
||||
private readonly ILogger<DeterminizationPolicy> _logger;
|
||||
|
||||
public DeterminizationResult Evaluate(DeterminizationContext ctx)
|
||||
{
|
||||
var snapshot = ctx.SignalSnapshot;
|
||||
var uncertainty = ctx.UncertaintyScore;
|
||||
var decay = ctx.Decay;
|
||||
var env = ctx.Environment;
|
||||
|
||||
// Rule 1: Escalate if runtime evidence shows loaded
|
||||
if (snapshot.Runtime.HasValue &&
|
||||
snapshot.Runtime.Value!.ObservedLoaded)
|
||||
{
|
||||
return DeterminizationResult.Escalated(
|
||||
"Runtime evidence shows vulnerable code loaded",
|
||||
PolicyVerdictStatus.Escalated);
|
||||
}
|
||||
|
||||
// Rule 2: Quarantine if EPSS >= threshold or proven reachable
|
||||
if (snapshot.Epss.HasValue &&
|
||||
snapshot.Epss.Value!.Score >= _options.EpssQuarantineThreshold)
|
||||
{
|
||||
return DeterminizationResult.Quarantined(
|
||||
$"EPSS score {snapshot.Epss.Value.Score:P1} exceeds threshold {_options.EpssQuarantineThreshold:P1}",
|
||||
PolicyVerdictStatus.Blocked);
|
||||
}
|
||||
|
||||
if (snapshot.Reachability.HasValue &&
|
||||
snapshot.Reachability.Value!.Status == ReachabilityStatus.Reachable)
|
||||
{
|
||||
return DeterminizationResult.Quarantined(
|
||||
"Vulnerable code is reachable via call graph",
|
||||
PolicyVerdictStatus.Blocked);
|
||||
}
|
||||
|
||||
// Rule 3: Allow with guardrails if score < threshold AND entropy > threshold AND non-prod
|
||||
var trustScore = ctx.TrustScore;
|
||||
if (trustScore < _options.GuardedAllowScoreThreshold &&
|
||||
uncertainty.Entropy > _options.GuardedAllowEntropyThreshold &&
|
||||
env != DeploymentEnvironment.Production)
|
||||
{
|
||||
var guardrails = BuildGuardrails(ctx);
|
||||
return DeterminizationResult.GuardedAllow(
|
||||
$"Uncertain observation (entropy={uncertainty.Entropy:F2}) allowed with guardrails in {env}",
|
||||
PolicyVerdictStatus.GuardedPass,
|
||||
guardrails);
|
||||
}
|
||||
|
||||
// Rule 4: Block in production with high entropy
|
||||
if (env == DeploymentEnvironment.Production &&
|
||||
uncertainty.Entropy > _options.ProductionBlockEntropyThreshold)
|
||||
{
|
||||
return DeterminizationResult.Quarantined(
|
||||
$"High uncertainty (entropy={uncertainty.Entropy:F2}) not allowed in production",
|
||||
PolicyVerdictStatus.Blocked);
|
||||
}
|
||||
|
||||
// Rule 5: Defer if evidence is stale
|
||||
if (decay.IsStale)
|
||||
{
|
||||
return DeterminizationResult.Deferred(
|
||||
$"Evidence stale (last update: {decay.LastSignalUpdate:u}), requires refresh",
|
||||
PolicyVerdictStatus.Deferred);
|
||||
}
|
||||
|
||||
// Default: Allow (sufficient evidence or acceptable risk)
|
||||
return DeterminizationResult.Allowed(
|
||||
"Evidence sufficient for determination",
|
||||
PolicyVerdictStatus.Pass);
|
||||
}
|
||||
|
||||
private GuardRails BuildGuardrails(DeterminizationContext ctx) =>
|
||||
new GuardRails
|
||||
{
|
||||
EnableRuntimeMonitoring = true,
|
||||
ReviewInterval = TimeSpan.FromDays(_options.GuardedReviewIntervalDays),
|
||||
EpssEscalationThreshold = _options.EpssQuarantineThreshold,
|
||||
EscalatingReachabilityStates = ImmutableArray.Create("Reachable", "ObservedReachable"),
|
||||
MaxGuardedDuration = TimeSpan.FromDays(_options.MaxGuardedDurationDays),
|
||||
PolicyRationale = $"Auto-allowed with entropy={ctx.UncertaintyScore.Entropy:F2}, trust={ctx.TrustScore:F2}"
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Thresholds
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Per-environment threshold configuration.
|
||||
/// </summary>
|
||||
public sealed record EnvironmentThresholds
|
||||
{
|
||||
public DeploymentEnvironment Environment { get; init; }
|
||||
public double MinConfidenceForNotAffected { get; init; }
|
||||
public double MaxEntropyForAllow { get; init; }
|
||||
public double EpssBlockThreshold { get; init; }
|
||||
public bool RequireReachabilityForAllow { get; init; }
|
||||
}
|
||||
|
||||
public static class DefaultEnvironmentThresholds
|
||||
{
|
||||
public static EnvironmentThresholds Production => new()
|
||||
{
|
||||
Environment = DeploymentEnvironment.Production,
|
||||
MinConfidenceForNotAffected = 0.75,
|
||||
MaxEntropyForAllow = 0.3,
|
||||
EpssBlockThreshold = 0.3,
|
||||
RequireReachabilityForAllow = true
|
||||
};
|
||||
|
||||
public static EnvironmentThresholds Staging => new()
|
||||
{
|
||||
Environment = DeploymentEnvironment.Staging,
|
||||
MinConfidenceForNotAffected = 0.60,
|
||||
MaxEntropyForAllow = 0.5,
|
||||
EpssBlockThreshold = 0.4,
|
||||
RequireReachabilityForAllow = true
|
||||
};
|
||||
|
||||
public static EnvironmentThresholds Development => new()
|
||||
{
|
||||
Environment = DeploymentEnvironment.Development,
|
||||
MinConfidenceForNotAffected = 0.40,
|
||||
MaxEntropyForAllow = 0.7,
|
||||
EpssBlockThreshold = 0.6,
|
||||
RequireReachabilityForAllow = false
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Feedser Integration
|
||||
|
||||
Feedser attaches `SignalState<T>` to CVE observations:
|
||||
|
||||
```csharp
|
||||
// In Feedser: EpssSignalAttacher
|
||||
public async Task<SignalState<EpssEvidence>> AttachEpssAsync(string cveId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var evidence = await _epssClient.GetScoreAsync(cveId, ct);
|
||||
return new SignalState<EpssEvidence>
|
||||
{
|
||||
Status = SignalQueryStatus.Queried,
|
||||
Value = evidence,
|
||||
QueriedAt = _timeProvider.GetUtcNow(),
|
||||
Source = "first.org"
|
||||
};
|
||||
}
|
||||
catch (EpssNotFoundException)
|
||||
{
|
||||
return new SignalState<EpssEvidence>
|
||||
{
|
||||
Status = SignalQueryStatus.Queried,
|
||||
Value = null,
|
||||
QueriedAt = _timeProvider.GetUtcNow(),
|
||||
Source = "first.org"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new SignalState<EpssEvidence>
|
||||
{
|
||||
Status = SignalQueryStatus.Failed,
|
||||
Value = null,
|
||||
FailureReason = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Policy Engine Gate
|
||||
|
||||
```csharp
|
||||
// In Policy.Engine: DeterminizationGate
|
||||
public sealed class DeterminizationGate : IPolicyGate
|
||||
{
|
||||
private readonly IDeterminizationPolicy _policy;
|
||||
private readonly IUncertaintyScoreCalculator _uncertaintyCalculator;
|
||||
private readonly IDecayedConfidenceCalculator _decayCalculator;
|
||||
|
||||
public async Task<GateResult> EvaluateAsync(PolicyEvaluationContext ctx, CancellationToken ct)
|
||||
{
|
||||
var snapshot = await BuildSignalSnapshotAsync(ctx, ct);
|
||||
var uncertainty = _uncertaintyCalculator.Calculate(snapshot);
|
||||
var decay = _decayCalculator.Calculate(snapshot.CapturedAt, ctx.Options.DecayHalfLife);
|
||||
|
||||
var determCtx = new DeterminizationContext
|
||||
{
|
||||
SignalSnapshot = snapshot,
|
||||
UncertaintyScore = uncertainty,
|
||||
Decay = decay,
|
||||
TrustScore = ctx.TrustScore,
|
||||
Environment = ctx.Environment
|
||||
};
|
||||
|
||||
var result = _policy.Evaluate(determCtx);
|
||||
|
||||
return new GateResult
|
||||
{
|
||||
Passed = result.Status is PolicyVerdictStatus.Pass or PolicyVerdictStatus.GuardedPass,
|
||||
Status = result.Status,
|
||||
Reason = result.Reason,
|
||||
GuardRails = result.GuardRails,
|
||||
Metadata = new Dictionary<string, object>
|
||||
{
|
||||
["uncertainty_entropy"] = uncertainty.Entropy,
|
||||
["uncertainty_tier"] = uncertainty.Tier.ToString(),
|
||||
["decay_multiplier"] = decay.DecayedMultiplier,
|
||||
["missing_signals"] = uncertainty.MissingSignals.Select(g => g.SignalName).ToArray()
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Graph Integration
|
||||
|
||||
CVE nodes in the Graph module carry `ObservationState` and `UncertaintyScore`:
|
||||
|
||||
```csharp
|
||||
// Extended CVE node for Graph module
|
||||
public sealed record CveObservationNode
|
||||
{
|
||||
public required string CveId { get; init; }
|
||||
public required string SubjectPurl { get; init; }
|
||||
|
||||
// VEX status (orthogonal to observation state)
|
||||
public required VexClaimStatus? VexStatus { get; init; }
|
||||
|
||||
// Observation lifecycle state
|
||||
public required ObservationState ObservationState { get; init; }
|
||||
|
||||
// Knowledge completeness
|
||||
public required UncertaintyScore Uncertainty { get; init; }
|
||||
|
||||
// Evidence freshness
|
||||
public required ObservationDecay Decay { get; init; }
|
||||
|
||||
// Trust score (from confidence aggregation)
|
||||
public required double TrustScore { get; init; }
|
||||
|
||||
// Policy outcome
|
||||
public required PolicyVerdictStatus PolicyHint { get; init; }
|
||||
|
||||
// Guardrails if GuardedPass
|
||||
public GuardRails? GuardRails { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
## Event-Driven Re-evaluation
|
||||
|
||||
When new signals arrive, the system re-evaluates affected observations:
|
||||
|
||||
```csharp
|
||||
public sealed class SignalUpdateHandler : ISignalUpdateSubscription
|
||||
{
|
||||
private readonly IObservationRepository _observations;
|
||||
private readonly IDeterminizationPolicy _policy;
|
||||
private readonly IEventPublisher _events;
|
||||
|
||||
public async Task HandleAsync(SignalUpdatedEvent evt, CancellationToken ct)
|
||||
{
|
||||
// Find observations affected by this signal
|
||||
var affected = await _observations.FindByCveAndPurlAsync(evt.CveId, evt.Purl, ct);
|
||||
|
||||
foreach (var obs in affected)
|
||||
{
|
||||
// Rebuild signal snapshot
|
||||
var snapshot = await BuildCurrentSnapshotAsync(obs, ct);
|
||||
|
||||
// Recalculate uncertainty
|
||||
var uncertainty = _uncertaintyCalculator.Calculate(snapshot);
|
||||
|
||||
// Re-evaluate policy
|
||||
var result = _policy.Evaluate(new DeterminizationContext
|
||||
{
|
||||
SignalSnapshot = snapshot,
|
||||
UncertaintyScore = uncertainty,
|
||||
// ... other context
|
||||
});
|
||||
|
||||
// Transition state if needed
|
||||
var newState = DetermineNewState(obs.ObservationState, result, uncertainty);
|
||||
if (newState != obs.ObservationState)
|
||||
{
|
||||
await _observations.UpdateStateAsync(obs.Id, newState, ct);
|
||||
await _events.PublishAsync(new ObservationStateChangedEvent(
|
||||
obs.Id, obs.ObservationState, newState, result.Reason), ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ObservationState DetermineNewState(
|
||||
ObservationState current,
|
||||
DeterminizationResult result,
|
||||
UncertaintyScore uncertainty)
|
||||
{
|
||||
// Transition logic
|
||||
if (result.Status == PolicyVerdictStatus.Escalated)
|
||||
return ObservationState.ManualReviewRequired;
|
||||
|
||||
if (uncertainty.Tier == UncertaintyTier.VeryLow)
|
||||
return ObservationState.Determined;
|
||||
|
||||
if (current == ObservationState.PendingDeterminization &&
|
||||
uncertainty.Tier <= UncertaintyTier.Low)
|
||||
return ObservationState.Determined;
|
||||
|
||||
return current;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
```csharp
|
||||
public sealed class DeterminizationOptions
|
||||
{
|
||||
/// <summary>EPSS score that triggers quarantine (block). Default: 0.4</summary>
|
||||
public double EpssQuarantineThreshold { get; set; } = 0.4;
|
||||
|
||||
/// <summary>Trust score threshold for guarded allow. Default: 0.5</summary>
|
||||
public double GuardedAllowScoreThreshold { get; set; } = 0.5;
|
||||
|
||||
/// <summary>Entropy threshold for guarded allow. Default: 0.4</summary>
|
||||
public double GuardedAllowEntropyThreshold { get; set; } = 0.4;
|
||||
|
||||
/// <summary>Entropy threshold for production block. Default: 0.3</summary>
|
||||
public double ProductionBlockEntropyThreshold { get; set; } = 0.3;
|
||||
|
||||
/// <summary>Half-life for evidence decay in days. Default: 14</summary>
|
||||
public int DecayHalfLifeDays { get; set; } = 14;
|
||||
|
||||
/// <summary>Minimum confidence floor after decay. Default: 0.35</summary>
|
||||
public double DecayFloor { get; set; } = 0.35;
|
||||
|
||||
/// <summary>Review interval for guarded observations in days. Default: 7</summary>
|
||||
public int GuardedReviewIntervalDays { get; set; } = 7;
|
||||
|
||||
/// <summary>Maximum time in guarded state in days. Default: 30</summary>
|
||||
public int MaxGuardedDurationDays { get; set; } = 30;
|
||||
|
||||
/// <summary>Signal weights for uncertainty calculation.</summary>
|
||||
public SignalWeights SignalWeights { get; set; } = new();
|
||||
|
||||
/// <summary>Per-environment threshold overrides.</summary>
|
||||
public Dictionary<string, EnvironmentThresholds> EnvironmentThresholds { get; set; } = new();
|
||||
}
|
||||
```
|
||||
|
||||
## Verdict Status Extension
|
||||
|
||||
Extended `PolicyVerdictStatus` enum:
|
||||
|
||||
```csharp
|
||||
public enum PolicyVerdictStatus
|
||||
{
|
||||
Pass = 0, // Finding meets policy requirements
|
||||
GuardedPass = 1, // NEW: Allow with runtime monitoring enabled
|
||||
Blocked = 2, // Finding fails policy checks; must be remediated
|
||||
Ignored = 3, // Finding deliberately ignored via exception
|
||||
Warned = 4, // Finding passes but with warnings
|
||||
Deferred = 5, // Decision deferred; needs additional evidence
|
||||
Escalated = 6, // Decision escalated for human review
|
||||
RequiresVex = 7 // VEX statement required to make decision
|
||||
}
|
||||
```
|
||||
|
||||
## Metrics & Observability
|
||||
|
||||
```csharp
|
||||
public static class DeterminizationMetrics
|
||||
{
|
||||
// Counters
|
||||
public static readonly Counter<int> ObservationsCreated =
|
||||
Meter.CreateCounter<int>("stellaops_determinization_observations_created_total");
|
||||
|
||||
public static readonly Counter<int> StateTransitions =
|
||||
Meter.CreateCounter<int>("stellaops_determinization_state_transitions_total");
|
||||
|
||||
public static readonly Counter<int> PolicyEvaluations =
|
||||
Meter.CreateCounter<int>("stellaops_determinization_policy_evaluations_total");
|
||||
|
||||
// Histograms
|
||||
public static readonly Histogram<double> UncertaintyEntropy =
|
||||
Meter.CreateHistogram<double>("stellaops_determinization_uncertainty_entropy");
|
||||
|
||||
public static readonly Histogram<double> DecayMultiplier =
|
||||
Meter.CreateHistogram<double>("stellaops_determinization_decay_multiplier");
|
||||
|
||||
// Gauges
|
||||
public static readonly ObservableGauge<int> PendingObservations =
|
||||
Meter.CreateObservableGauge<int>("stellaops_determinization_pending_observations",
|
||||
() => /* query count */);
|
||||
|
||||
public static readonly ObservableGauge<int> StaleObservations =
|
||||
Meter.CreateObservableGauge<int>("stellaops_determinization_stale_observations",
|
||||
() => /* query count */);
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
| Test Category | Focus Area | Example |
|
||||
|---------------|------------|---------|
|
||||
| Unit | Uncertainty calculation | Missing 2 signals = correct entropy |
|
||||
| Unit | Decay calculation | 14 days = 50% multiplier |
|
||||
| Unit | Policy rules | EPSS 0.5 + dev = guarded allow |
|
||||
| Integration | Signal attachment | Feedser EPSS query → SignalState |
|
||||
| Integration | State transitions | New VEX → PendingDeterminization → Determined |
|
||||
| Determinism | Same input → same output | Canonical snapshot → reproducible entropy |
|
||||
| Property | Entropy bounds | Always [0.0, 1.0] |
|
||||
| Property | Decay monotonicity | Older → lower multiplier |
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **No Guessing:** Missing signals use explicit priors, never random values
|
||||
2. **Audit Trail:** Every state transition logged with evidence snapshot
|
||||
3. **Conservative Defaults:** Production blocks high entropy; only non-prod allows guardrails
|
||||
4. **Escalation Path:** Runtime evidence always escalates regardless of other signals
|
||||
5. **Tamper Detection:** Signal snapshots hashed for integrity verification
|
||||
|
||||
## References
|
||||
|
||||
- Product Advisory: "Unknown CVEs: graceful placeholders, not blockers"
|
||||
- Existing: `src/Policy/__Libraries/StellaOps.Policy.Unknowns/`
|
||||
- Existing: `src/Policy/__Libraries/StellaOps.Policy/Confidence/`
|
||||
- Existing: `src/Excititor/__Libraries/StellaOps.Excititor.Core/TrustVector/`
|
||||
- OpenVEX Specification: https://openvex.dev/
|
||||
- EPSS Model: https://www.first.org/epss/
|
||||
290
docs/modules/policy/guides/verdict-rationale.md
Normal file
290
docs/modules/policy/guides/verdict-rationale.md
Normal file
@@ -0,0 +1,290 @@
|
||||
# Verdict Rationale Template
|
||||
|
||||
> **Status:** Implemented (SPRINT_20260106_001_001_LB)
|
||||
> **Library:** `StellaOps.Policy.Explainability`
|
||||
> **API Endpoint:** `GET /api/v1/triage/findings/{findingId}/rationale`
|
||||
> **CLI Command:** `stella verdict rationale <finding-id>`
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
**Verdict Rationales** provide human-readable explanations for policy verdicts using a standardized 4-line template. Each rationale explains:
|
||||
|
||||
1. **Evidence:** What vulnerability was found and where
|
||||
2. **Policy Clause:** Which policy rule triggered the decision
|
||||
3. **Attestations:** What proofs support the verdict
|
||||
4. **Decision:** Final verdict with recommendation
|
||||
|
||||
Rationales are content-addressed (same inputs produce same rationale ID), enabling caching and deduplication.
|
||||
|
||||
---
|
||||
|
||||
## 4-Line Template
|
||||
|
||||
Every verdict rationale follows this structure:
|
||||
|
||||
```
|
||||
Line 1 - Evidence: CVE-2024-XXXX in `libxyz` 1.2.3; symbol `foo_read` reachable from `/usr/bin/tool`.
|
||||
Line 2 - Policy: Policy S2.1: reachable+EPSS>=0.2 => triage=P1.
|
||||
Line 3 - Attestations: Build-ID match to vendor advisory; call-path: `main->parse->foo_read`.
|
||||
Line 4 - Decision: Affected (score 0.72). Mitigation recommended: upgrade or backport KB-123.
|
||||
```
|
||||
|
||||
### Template Components
|
||||
|
||||
| Line | Purpose | Content |
|
||||
|------|---------|---------|
|
||||
| **Evidence** | What was found | CVE ID, component PURL, version, reachability info |
|
||||
| **Policy Clause** | Why decision was made | Policy rule ID, expression, triage priority |
|
||||
| **Attestations** | Supporting proofs | Build-ID matches, call paths, VEX statements, provenance |
|
||||
| **Decision** | What to do | Verdict status, risk score, recommendation, mitigation |
|
||||
|
||||
---
|
||||
|
||||
## API Usage
|
||||
|
||||
### Get Rationale (JSON)
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
"https://scanner.example.com/api/v1/triage/findings/12345/rationale?format=json"
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"finding_id": "12345",
|
||||
"rationale_id": "rationale:sha256:abc123...",
|
||||
"schema_version": "1.0",
|
||||
"evidence": {
|
||||
"cve": "CVE-2024-1234",
|
||||
"component_purl": "pkg:npm/lodash@4.17.20",
|
||||
"component_version": "4.17.20",
|
||||
"vulnerable_function": "template",
|
||||
"entry_point": "/app/src/index.js",
|
||||
"text": "CVE-2024-1234 in `pkg:npm/lodash@4.17.20` 4.17.20; symbol `template` reachable from `/app/src/index.js`."
|
||||
},
|
||||
"policy_clause": {
|
||||
"clause_id": "S2.1",
|
||||
"rule_description": "High severity with reachability",
|
||||
"conditions": ["severity>=high", "reachable=true"],
|
||||
"text": "Policy S2.1: severity>=high AND reachable=true => triage=P1."
|
||||
},
|
||||
"attestations": {
|
||||
"path_witness": {
|
||||
"id": "witness-789",
|
||||
"type": "path-witness",
|
||||
"digest": "sha256:def456...",
|
||||
"summary": "Path witness from scanner"
|
||||
},
|
||||
"vex_statements": [
|
||||
{
|
||||
"id": "vex-001",
|
||||
"type": "vex",
|
||||
"digest": "sha256:ghi789...",
|
||||
"summary": "Affected: from vendor.example.com"
|
||||
}
|
||||
],
|
||||
"provenance": null,
|
||||
"text": "Path witness from scanner; VEX statement: Affected from vendor.example.com."
|
||||
},
|
||||
"decision": {
|
||||
"verdict": "Affected",
|
||||
"score": 0.72,
|
||||
"recommendation": "Upgrade to version 4.17.21",
|
||||
"mitigation": {
|
||||
"action": "upgrade",
|
||||
"details": "Upgrade to 4.17.21 or later"
|
||||
},
|
||||
"text": "Affected (score 0.72). Mitigation recommended: Upgrade to version 4.17.21."
|
||||
},
|
||||
"generated_at": "2026-01-07T12:00:00Z",
|
||||
"input_digests": {
|
||||
"verdict_digest": "sha256:abc123...",
|
||||
"policy_digest": "sha256:def456...",
|
||||
"evidence_digest": "sha256:ghi789..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Get Rationale (Plain Text)
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
"https://scanner.example.com/api/v1/triage/findings/12345/rationale?format=plaintext"
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"finding_id": "12345",
|
||||
"rationale_id": "rationale:sha256:abc123...",
|
||||
"format": "plaintext",
|
||||
"content": "CVE-2024-1234 in `pkg:npm/lodash@4.17.20` 4.17.20; symbol `template` reachable from `/app/src/index.js`.\nPolicy S2.1: severity>=high AND reachable=true => triage=P1.\nPath witness from scanner; VEX statement: Affected from vendor.example.com.\nAffected (score 0.72). Mitigation recommended: Upgrade to version 4.17.21."
|
||||
}
|
||||
```
|
||||
|
||||
### Get Rationale (Markdown)
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
"https://scanner.example.com/api/v1/triage/findings/12345/rationale?format=markdown"
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"finding_id": "12345",
|
||||
"rationale_id": "rationale:sha256:abc123...",
|
||||
"format": "markdown",
|
||||
"content": "**Evidence:** CVE-2024-1234 in `pkg:npm/lodash@4.17.20` 4.17.20; symbol `template` reachable from `/app/src/index.js`.\n\n**Policy:** Policy S2.1: severity>=high AND reachable=true => triage=P1.\n\n**Attestations:** Path witness from scanner; VEX statement: Affected from vendor.example.com.\n\n**Decision:** Affected (score 0.72). Mitigation recommended: Upgrade to version 4.17.21."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI Usage
|
||||
|
||||
### Table Output (Default)
|
||||
|
||||
```bash
|
||||
stella verdict rationale 12345
|
||||
```
|
||||
|
||||
```
|
||||
Finding: 12345
|
||||
Rationale ID: rationale:sha256:abc123...
|
||||
Generated: 2026-01-07T12:00:00Z
|
||||
|
||||
+--------------------------------------+
|
||||
| 1. Evidence |
|
||||
+--------------------------------------+
|
||||
| CVE-2024-1234 in `pkg:npm/lodash... |
|
||||
+--------------------------------------+
|
||||
|
||||
+--------------------------------------+
|
||||
| 2. Policy Clause |
|
||||
+--------------------------------------+
|
||||
| Policy S2.1: severity>=high AND... |
|
||||
+--------------------------------------+
|
||||
|
||||
+--------------------------------------+
|
||||
| 3. Attestations |
|
||||
+--------------------------------------+
|
||||
| Path witness from scanner; VEX... |
|
||||
+--------------------------------------+
|
||||
|
||||
+--------------------------------------+
|
||||
| 4. Decision |
|
||||
+--------------------------------------+
|
||||
| Affected (score 0.72). Mitigation... |
|
||||
+--------------------------------------+
|
||||
```
|
||||
|
||||
### JSON Output
|
||||
|
||||
```bash
|
||||
stella verdict rationale 12345 --output json
|
||||
```
|
||||
|
||||
### Markdown Output
|
||||
|
||||
```bash
|
||||
stella verdict rationale 12345 --output markdown
|
||||
```
|
||||
|
||||
### Plain Text Output
|
||||
|
||||
```bash
|
||||
stella verdict rationale 12345 --output text
|
||||
```
|
||||
|
||||
### With Tenant
|
||||
|
||||
```bash
|
||||
stella verdict rationale 12345 --tenant acme-corp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration
|
||||
|
||||
### Service Registration
|
||||
|
||||
```csharp
|
||||
// In Program.cs or service configuration
|
||||
services.AddVerdictExplainability();
|
||||
services.AddScoped<IFindingRationaleService, FindingRationaleService>();
|
||||
```
|
||||
|
||||
### Programmatic Usage
|
||||
|
||||
```csharp
|
||||
// Inject IVerdictRationaleRenderer
|
||||
public class MyService
|
||||
{
|
||||
private readonly IVerdictRationaleRenderer _renderer;
|
||||
|
||||
public MyService(IVerdictRationaleRenderer renderer)
|
||||
{
|
||||
_renderer = renderer;
|
||||
}
|
||||
|
||||
public string GetExplanation(VerdictRationaleInput input)
|
||||
{
|
||||
var rationale = _renderer.Render(input);
|
||||
return _renderer.RenderPlainText(rationale);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Input Requirements
|
||||
|
||||
The `VerdictRationaleInput` requires:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `VerdictRef` | `VerdictReference` | Yes | Reference to verdict attestation |
|
||||
| `Cve` | `string` | Yes | CVE identifier |
|
||||
| `Component` | `ComponentIdentity` | Yes | Component PURL, name, version |
|
||||
| `Reachability` | `ReachabilityDetail` | No | Vulnerable function, entry point |
|
||||
| `PolicyClauseId` | `string` | Yes | Policy clause that triggered verdict |
|
||||
| `PolicyRuleDescription` | `string` | Yes | Human-readable rule description |
|
||||
| `PolicyConditions` | `List<string>` | No | Matched conditions |
|
||||
| `PathWitness` | `AttestationReference` | No | Path witness attestation |
|
||||
| `VexStatements` | `List<AttestationReference>` | No | VEX statement references |
|
||||
| `Provenance` | `AttestationReference` | No | Provenance attestation |
|
||||
| `Verdict` | `string` | Yes | Final verdict status |
|
||||
| `Score` | `double?` | No | Risk score (0-1) |
|
||||
| `Recommendation` | `string` | Yes | Recommended action |
|
||||
| `Mitigation` | `MitigationGuidance` | No | Specific mitigation guidance |
|
||||
|
||||
---
|
||||
|
||||
## Determinism
|
||||
|
||||
Rationales are **content-addressed**: the same inputs always produce the same `rationale_id`. This enables:
|
||||
|
||||
- **Caching:** Store and retrieve rationales by ID
|
||||
- **Deduplication:** Avoid regenerating identical rationales
|
||||
- **Verification:** Confirm rationale wasn't modified after generation
|
||||
|
||||
The rationale ID is computed as:
|
||||
```
|
||||
sha256(canonical_json(verdict_id + witness_id + score_factors))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documents
|
||||
|
||||
- [Verdict Attestations](verdict-attestations.md) - Cryptographic verdict proofs
|
||||
- [Policy DSL](dsl.md) - Policy rule syntax
|
||||
- [Scoring Profiles](scoring-profiles.md) - Risk score computation
|
||||
- [VEX Trust Model](vex-trust-model.md) - VEX statement handling
|
||||
Reference in New Issue
Block a user