save progress
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
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -32,5 +32,6 @@
|
||||
<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