save progress

This commit is contained in:
StellaOps Bot
2026-01-06 09:42:02 +02:00
parent 94d68bee8b
commit 37e11918e0
443 changed files with 85863 additions and 897 deletions

View File

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

View File

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

View File

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

View File

@@ -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>