Refactor code structure for improved readability and maintainability; optimize performance in key functions.

This commit is contained in:
master
2025-12-22 19:06:31 +02:00
parent dfaa2079aa
commit 4602ccc3a3
1444 changed files with 109919 additions and 8058 deletions

View File

@@ -1,6 +1,7 @@
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Policy;
using StellaOps.Policy.Confidence.Models;
using StellaOps.Policy.Engine.Caching;
using StellaOps.Policy.Engine.Evaluation;
using StellaOps.Policy.Engine.Services;
@@ -86,6 +87,7 @@ internal sealed record BatchEvaluationResultDto(
IReadOnlyDictionary<string, string> Annotations,
IReadOnlyList<string> Warnings,
PolicyExceptionApplication? AppliedException,
ConfidenceScore? Confidence,
string CorrelationId,
bool Cached,
CacheSource CacheSource,

View File

@@ -0,0 +1,150 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Exceptions.Services;
namespace StellaOps.Policy.Engine.BuildGate;
/// <summary>
/// Build gate that checks recheck policies before allowing deployment.
/// </summary>
public sealed class ExceptionRecheckGate : IBuildGate
{
private readonly IExceptionEvaluator _exceptionEvaluator;
private readonly IRecheckEvaluationService _recheckService;
private readonly ILogger<ExceptionRecheckGate> _logger;
public ExceptionRecheckGate(
IExceptionEvaluator exceptionEvaluator,
IRecheckEvaluationService recheckService,
ILogger<ExceptionRecheckGate> logger)
{
_exceptionEvaluator = exceptionEvaluator ?? throw new ArgumentNullException(nameof(exceptionEvaluator));
_recheckService = recheckService ?? throw new ArgumentNullException(nameof(recheckService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string GateName => "exception-recheck";
public int Priority => 100;
public async Task<BuildGateResult> EvaluateAsync(
BuildGateContext context,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(context);
_logger.LogInformation(
"Evaluating exception recheck gate for artifact {Artifact}",
context.ArtifactDigest);
var evaluation = await _exceptionEvaluator.EvaluateAsync(new FindingContext
{
ArtifactDigest = context.ArtifactDigest,
Environment = context.Environment,
TenantId = context.TenantId
}, ct).ConfigureAwait(false);
var blockers = new List<string>();
var warnings = new List<string>();
foreach (var exception in evaluation.MatchingExceptions)
{
if (exception.RecheckPolicy is null)
{
continue;
}
var evalContext = new RecheckEvaluationContext
{
ArtifactDigest = context.ArtifactDigest,
Environment = context.Environment,
EvaluatedAt = context.EvaluatedAt,
ReachGraphChanged = context.ReachGraphChanged,
EpssScore = context.EpssScore,
CvssScore = context.CvssScore,
UnknownsCount = context.UnknownsCount,
NewCveInPackage = context.NewCveInPackage,
KevFlagged = context.KevFlagged,
VexStatusChanged = context.VexStatusChanged,
PackageVersionChanged = context.PackageVersionChanged
};
var result = await _recheckService.EvaluateAsync(exception, evalContext, ct).ConfigureAwait(false);
if (!result.IsTriggered)
{
continue;
}
foreach (var triggered in result.TriggeredConditions)
{
var message = $"Exception {exception.ExceptionId}: {triggered.Description} ({triggered.Action})";
if (triggered.Action is RecheckAction.Block or RecheckAction.Revoke or RecheckAction.RequireReapproval)
{
blockers.Add(message);
}
else if (triggered.Action == RecheckAction.Warn)
{
warnings.Add(message);
}
}
}
if (blockers.Count > 0)
{
return new BuildGateResult
{
Passed = false,
GateName = GateName,
Message = $"Recheck policy blocking: {string.Join("; ", blockers)}",
Blockers = blockers.ToImmutableArray(),
Warnings = warnings.ToImmutableArray()
};
}
return new BuildGateResult
{
Passed = true,
GateName = GateName,
Message = warnings.Count > 0
? $"Passed with {warnings.Count} warning(s)"
: "All exception recheck policies satisfied",
Blockers = [],
Warnings = warnings.ToImmutableArray()
};
}
}
public interface IBuildGate
{
string GateName { get; }
int Priority { get; }
Task<BuildGateResult> EvaluateAsync(BuildGateContext context, CancellationToken ct = default);
}
public sealed record BuildGateContext
{
public required string ArtifactDigest { get; init; }
public required string Environment { get; init; }
public string? Branch { get; init; }
public string? PipelineId { get; init; }
public Guid? TenantId { get; init; }
public DateTimeOffset EvaluatedAt { get; init; } = DateTimeOffset.UtcNow;
public bool ReachGraphChanged { get; init; }
public decimal? EpssScore { get; init; }
public decimal? CvssScore { get; init; }
public int? UnknownsCount { get; init; }
public bool NewCveInPackage { get; init; }
public bool KevFlagged { get; init; }
public bool VexStatusChanged { get; init; }
public bool PackageVersionChanged { get; init; }
}
public sealed record BuildGateResult
{
public required bool Passed { get; init; }
public required string GateName { get; init; }
public required string Message { get; init; }
public required ImmutableArray<string> Blockers { get; init; }
public required ImmutableArray<string> Warnings { get; init; }
}

View File

@@ -1,4 +1,5 @@
using System.Collections.Immutable;
using StellaOps.Policy.Confidence.Models;
using StellaOps.Policy.Engine.Evaluation;
namespace StellaOps.Policy.Engine.Caching;
@@ -93,7 +94,8 @@ public sealed record PolicyEvaluationCacheEntry(
string? ExceptionId,
string CorrelationId,
DateTimeOffset EvaluatedAt,
DateTimeOffset ExpiresAt);
DateTimeOffset ExpiresAt,
ConfidenceScore? Confidence);
/// <summary>
/// Result of a cache lookup.

View File

@@ -1,7 +1,10 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Http;
using StellaOps.Policy.Confidence.Configuration;
using StellaOps.Policy.Confidence.Services;
using StellaOps.Policy.Engine.Attestation;
using StellaOps.Policy.Engine.BuildGate;
using StellaOps.Policy.Engine.Caching;
using StellaOps.Policy.Engine.EffectiveDecisionMap;
using StellaOps.Policy.Engine.Events;
@@ -13,6 +16,8 @@ using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Vex;
using StellaOps.Policy.Engine.WhatIfSimulation;
using StellaOps.Policy.Engine.Workers;
using StellaOps.Policy.Unknowns.Configuration;
using StellaOps.Policy.Unknowns.Services;
using StackExchange.Redis;
namespace StellaOps.Policy.Engine.DependencyInjection;
@@ -33,6 +38,13 @@ public static class PolicyEngineServiceCollectionExtensions
// Core compilation and evaluation services
services.TryAddSingleton<PolicyCompilationService>();
services.TryAddSingleton<PolicyEvaluator>();
services.AddOptions<ConfidenceWeightOptions>()
.BindConfiguration(ConfidenceWeightOptions.SectionName);
services.TryAddSingleton<IConfidenceCalculator, ConfidenceCalculator>();
services.AddOptions<UnknownBudgetOptions>()
.BindConfiguration(UnknownBudgetOptions.SectionName);
services.TryAddSingleton<IUnknownBudgetService, UnknownBudgetService>();
// Cache - uses IDistributedCacheFactory for transport flexibility
services.TryAddSingleton<IPolicyEvaluationCache, MessagingPolicyEvaluationCache>();
@@ -201,6 +213,15 @@ public static class PolicyEngineServiceCollectionExtensions
return services.AddPolicyDecisionAttestation();
}
/// <summary>
/// Adds build gate evaluators for exception recheck policies.
/// </summary>
public static IServiceCollection AddExceptionRecheckGate(this IServiceCollection services)
{
services.TryAddSingleton<IBuildGate, ExceptionRecheckGate>();
return services;
}
/// <summary>
/// Adds Redis connection for effective decision map and evaluation cache.
/// </summary>
@@ -340,4 +361,4 @@ public static class PolicyEngineServiceCollectionExtensions
return services;
}
}
}

View File

@@ -104,6 +104,7 @@ internal static class BatchEvaluationEndpoint
response.Annotations,
response.Warnings,
response.AppliedException,
response.Confidence,
response.CorrelationId,
response.Cached,
response.CacheSource,

View File

@@ -54,6 +54,7 @@ internal static class UnknownsEndpoints
[FromQuery] int limit = 100,
[FromQuery] int offset = 0,
IUnknownsRepository repository = null!,
IRemediationHintsRegistry hintsRegistry = null!,
CancellationToken ct = default)
{
var tenantId = ResolveTenantId(httpContext);
@@ -76,18 +77,9 @@ internal static class UnknownsEndpoints
unknowns = hot.Concat(warm).Concat(cold).Take(limit).ToList().AsReadOnly();
}
var items = unknowns.Select(u => new UnknownDto(
u.Id,
u.PackageId,
u.PackageVersion,
u.Band.ToString().ToLowerInvariant(),
u.Score,
u.UncertaintyFactor,
u.ExploitPressure,
u.FirstSeenAt,
u.LastEvaluatedAt,
u.ResolutionReason,
u.ResolvedAt)).ToList();
var items = unknowns
.Select(u => ToDto(u, hintsRegistry))
.ToList();
return TypedResults.Ok(new UnknownsListResponse(items, items.Count));
}
@@ -115,6 +107,7 @@ internal static class UnknownsEndpoints
HttpContext httpContext,
Guid id,
IUnknownsRepository repository = null!,
IRemediationHintsRegistry hintsRegistry = null!,
CancellationToken ct = default)
{
var tenantId = ResolveTenantId(httpContext);
@@ -126,7 +119,7 @@ internal static class UnknownsEndpoints
if (unknown is null)
return TypedResults.Problem($"Unknown with ID {id} not found.", statusCode: StatusCodes.Status404NotFound);
return TypedResults.Ok(new UnknownResponse(ToDto(unknown)));
return TypedResults.Ok(new UnknownResponse(ToDto(unknown, hintsRegistry)));
}
private static async Task<Results<Ok<UnknownResponse>, ProblemHttpResult>> Escalate(
@@ -135,6 +128,7 @@ internal static class UnknownsEndpoints
[FromBody] EscalateUnknownRequest request,
IUnknownsRepository repository = null!,
IUnknownRanker ranker = null!,
IRemediationHintsRegistry hintsRegistry = null!,
CancellationToken ct = default)
{
var tenantId = ResolveTenantId(httpContext);
@@ -164,7 +158,7 @@ internal static class UnknownsEndpoints
// TODO: T6 - Trigger rescan job via Scheduler integration
// await scheduler.CreateRescanJobAsync(unknown.PackageId, unknown.PackageVersion, ct);
return TypedResults.Ok(new UnknownResponse(ToDto(unknown)));
return TypedResults.Ok(new UnknownResponse(ToDto(unknown, hintsRegistry)));
}
private static async Task<Results<Ok<UnknownResponse>, ProblemHttpResult>> Resolve(
@@ -172,6 +166,7 @@ internal static class UnknownsEndpoints
Guid id,
[FromBody] ResolveUnknownRequest request,
IUnknownsRepository repository = null!,
IRemediationHintsRegistry hintsRegistry = null!,
CancellationToken ct = default)
{
var tenantId = ResolveTenantId(httpContext);
@@ -188,7 +183,7 @@ internal static class UnknownsEndpoints
var unknown = await repository.GetByIdAsync(tenantId, id, ct);
return TypedResults.Ok(new UnknownResponse(ToDto(unknown!)));
return TypedResults.Ok(new UnknownResponse(ToDto(unknown!, hintsRegistry)));
}
private static Guid ResolveTenantId(HttpContext context)
@@ -211,18 +206,42 @@ internal static class UnknownsEndpoints
return Guid.Empty;
}
private static UnknownDto ToDto(Unknown u) => new(
u.Id,
u.PackageId,
u.PackageVersion,
u.Band.ToString().ToLowerInvariant(),
u.Score,
u.UncertaintyFactor,
u.ExploitPressure,
u.FirstSeenAt,
u.LastEvaluatedAt,
u.ResolutionReason,
u.ResolvedAt);
private static UnknownDto ToDto(Unknown u, IRemediationHintsRegistry hintsRegistry)
{
var hint = hintsRegistry.GetHint(u.ReasonCode);
var shortCode = ShortCodes.TryGetValue(u.ReasonCode, out var code) ? code : "U-RCH";
return new UnknownDto(
u.Id,
u.PackageId,
u.PackageVersion,
u.Band.ToString().ToLowerInvariant(),
u.Score,
u.UncertaintyFactor,
u.ExploitPressure,
u.FirstSeenAt,
u.LastEvaluatedAt,
u.ResolutionReason,
u.ResolvedAt,
u.ReasonCode.ToString(),
shortCode,
u.RemediationHint ?? hint.ShortHint,
hint.DetailedHint,
hint.AutomationRef,
u.EvidenceRefs.Select(e => new EvidenceRefDto(e.Type, e.Uri, e.Digest)).ToList());
}
private static readonly IReadOnlyDictionary<UnknownReasonCode, string> ShortCodes =
new Dictionary<UnknownReasonCode, string>
{
[UnknownReasonCode.Reachability] = "U-RCH",
[UnknownReasonCode.Identity] = "U-ID",
[UnknownReasonCode.Provenance] = "U-PROV",
[UnknownReasonCode.VexConflict] = "U-VEX",
[UnknownReasonCode.FeedGap] = "U-FEED",
[UnknownReasonCode.ConfigUnknown] = "U-CONFIG",
[UnknownReasonCode.AnalyzerLimit] = "U-ANALYZER"
};
}
#region DTOs
@@ -239,7 +258,18 @@ public sealed record UnknownDto(
DateTimeOffset FirstSeenAt,
DateTimeOffset LastEvaluatedAt,
string? ResolutionReason,
DateTimeOffset? ResolvedAt);
DateTimeOffset? ResolvedAt,
string ReasonCode,
string ReasonCodeShort,
string? RemediationHint,
string? DetailedHint,
string? AutomationCommand,
IReadOnlyList<EvidenceRefDto> EvidenceRefs);
public sealed record EvidenceRefDto(
string Type,
string Uri,
string? Digest);
/// <summary>Response containing a list of unknowns.</summary>
public sealed record UnknownsListResponse(IReadOnlyList<UnknownDto> Items, int TotalCount);

View File

@@ -3,6 +3,9 @@ using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Policy;
using StellaOps.Policy.Confidence.Models;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Unknowns.Models;
using StellaOps.PolicyDsl;
namespace StellaOps.Policy.Engine.Evaluation;
@@ -18,9 +21,13 @@ internal sealed record PolicyEvaluationContext(
PolicyEvaluationVexEvidence Vex,
PolicyEvaluationSbom Sbom,
PolicyEvaluationExceptions Exceptions,
ImmutableArray<Unknown> Unknowns,
ImmutableArray<ExceptionObject> ExceptionObjects,
PolicyEvaluationReachability Reachability,
PolicyEvaluationEntropy Entropy,
DateTimeOffset? EvaluationTimestamp = null)
DateTimeOffset? EvaluationTimestamp = null,
string? PolicyDigest = null,
bool? ProvenanceAttested = null)
{
/// <summary>
/// Gets the evaluation timestamp for deterministic time-based operations.
@@ -39,8 +46,25 @@ internal sealed record PolicyEvaluationContext(
PolicyEvaluationVexEvidence vex,
PolicyEvaluationSbom sbom,
PolicyEvaluationExceptions exceptions,
DateTimeOffset? evaluationTimestamp = null)
: this(severity, environment, advisory, vex, sbom, exceptions, PolicyEvaluationReachability.Unknown, PolicyEvaluationEntropy.Unknown, evaluationTimestamp)
ImmutableArray<Unknown>? unknowns = null,
ImmutableArray<ExceptionObject>? exceptionObjects = null,
DateTimeOffset? evaluationTimestamp = null,
string? policyDigest = null,
bool? provenanceAttested = null)
: this(
severity,
environment,
advisory,
vex,
sbom,
exceptions,
unknowns ?? ImmutableArray<Unknown>.Empty,
exceptionObjects ?? ImmutableArray<ExceptionObject>.Empty,
PolicyEvaluationReachability.Unknown,
PolicyEvaluationEntropy.Unknown,
evaluationTimestamp,
policyDigest,
provenanceAttested)
{
}
}
@@ -100,7 +124,11 @@ internal sealed record PolicyEvaluationResult(
int? Priority,
ImmutableDictionary<string, string> Annotations,
ImmutableArray<string> Warnings,
PolicyExceptionApplication? AppliedException)
PolicyExceptionApplication? AppliedException,
ConfidenceScore? Confidence,
PolicyFailureReason? FailureReason = null,
string? FailureMessage = null,
BudgetStatusSummary? UnknownBudgetStatus = null)
{
public static PolicyEvaluationResult CreateDefault(string? severity) => new(
Matched: false,
@@ -110,7 +138,13 @@ internal sealed record PolicyEvaluationResult(
Priority: null,
Annotations: ImmutableDictionary<string, string>.Empty,
Warnings: ImmutableArray<string>.Empty,
AppliedException: null);
AppliedException: null,
Confidence: null);
}
internal enum PolicyFailureReason
{
UnknownBudgetExceeded
}
internal sealed record PolicyEvaluationExceptions(

View File

@@ -3,7 +3,15 @@ using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
using StellaOps.Policy;
using StellaOps.Policy.Confidence.Configuration;
using StellaOps.Policy.Confidence.Models;
using StellaOps.Policy.Confidence.Services;
using StellaOps.Policy.Unknowns.Models;
using StellaOps.Policy.Unknowns.Services;
using StellaOps.PolicyDsl;
namespace StellaOps.Policy.Engine.Evaluation;
@@ -13,6 +21,19 @@ namespace StellaOps.Policy.Engine.Evaluation;
/// </summary>
internal sealed class PolicyEvaluator
{
private readonly IConfidenceCalculator _confidenceCalculator;
private readonly IUnknownBudgetService? _budgetService;
public PolicyEvaluator(
IConfidenceCalculator? confidenceCalculator = null,
IUnknownBudgetService? budgetService = null)
{
_confidenceCalculator = confidenceCalculator
?? new ConfidenceCalculator(
new StaticOptionsMonitor<ConfidenceWeightOptions>(new ConfidenceWeightOptions()));
_budgetService = budgetService;
}
public PolicyEvaluationResult Evaluate(PolicyEvaluationRequest request)
{
if (request is null)
@@ -59,13 +80,18 @@ internal sealed class PolicyEvaluator
Priority: rule.Priority,
Annotations: runtime.Annotations.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase),
Warnings: runtime.Warnings.ToImmutableArray(),
AppliedException: null);
AppliedException: null,
Confidence: null);
return ApplyExceptions(request, baseResult);
var result = ApplyExceptions(request, baseResult);
var budgeted = ApplyUnknownBudget(request.Context, result);
return ApplyConfidence(request.Context, budgeted);
}
var defaultResult = PolicyEvaluationResult.CreateDefault(request.Context.Severity.Normalized);
return ApplyExceptions(request, defaultResult);
var defaultWithExceptions = ApplyExceptions(request, defaultResult);
var budgetedDefault = ApplyUnknownBudget(request.Context, defaultWithExceptions);
return ApplyConfidence(request.Context, budgetedDefault);
}
private static void ApplyAction(
@@ -417,4 +443,314 @@ internal sealed class PolicyEvaluator
AppliedException = application,
};
}
private PolicyEvaluationResult ApplyUnknownBudget(PolicyEvaluationContext context, PolicyEvaluationResult baseResult)
{
if (_budgetService is null || context.Unknowns.IsDefaultOrEmpty)
{
return baseResult;
}
var environment = ResolveEnvironmentName(context.Environment);
var budgetResult = _budgetService.CheckBudgetWithEscalation(
environment,
context.Unknowns,
context.ExceptionObjects);
var status = _budgetService.GetBudgetStatus(environment, context.Unknowns);
var annotations = baseResult.Annotations.ToBuilder();
annotations["unknownBudget.environment"] = environment;
annotations["unknownBudget.total"] = budgetResult.TotalUnknowns.ToString(CultureInfo.InvariantCulture);
annotations["unknownBudget.action"] = budgetResult.RecommendedAction.ToString();
if (budgetResult.TotalLimit.HasValue)
{
annotations["unknownBudget.totalLimit"] = budgetResult.TotalLimit.Value.ToString(CultureInfo.InvariantCulture);
}
annotations["unknownBudget.exceeded"] = (!budgetResult.IsWithinBudget).ToString();
if (!string.IsNullOrWhiteSpace(budgetResult.Message))
{
annotations["unknownBudget.message"] = budgetResult.Message!;
}
var warnings = baseResult.Warnings;
if (!budgetResult.IsWithinBudget
&& budgetResult.RecommendedAction is BudgetAction.Warn or BudgetAction.WarnUnlessException
&& !string.IsNullOrWhiteSpace(budgetResult.Message))
{
warnings = warnings.Add(budgetResult.Message!);
}
var result = baseResult with
{
Annotations = annotations.ToImmutable(),
Warnings = warnings,
UnknownBudgetStatus = status
};
if (_budgetService.ShouldBlock(budgetResult))
{
result = result with
{
Status = "blocked",
FailureReason = PolicyFailureReason.UnknownBudgetExceeded,
FailureMessage = budgetResult.Message ?? "Unknown budget exceeded"
};
}
return result;
}
private static string ResolveEnvironmentName(PolicyEvaluationEnvironment environment)
{
var name = environment.Get("name") ?? environment.Get("environment") ?? environment.Get("env");
return string.IsNullOrWhiteSpace(name) ? "default" : name.Trim();
}
private PolicyEvaluationResult ApplyConfidence(PolicyEvaluationContext context, PolicyEvaluationResult baseResult)
{
var input = BuildConfidenceInput(context, baseResult);
var confidence = _confidenceCalculator.Calculate(input);
return baseResult with { Confidence = confidence };
}
private static ConfidenceInput BuildConfidenceInput(PolicyEvaluationContext context, PolicyEvaluationResult result)
{
return new ConfidenceInput
{
Reachability = BuildReachabilityEvidence(context.Reachability),
Runtime = BuildRuntimeEvidence(context),
Vex = BuildVexEvidence(context),
Provenance = BuildProvenanceEvidence(context),
Policy = BuildPolicyEvidence(context, result),
Status = result.Status,
EvaluationTimestamp = context.Now
};
}
private static ReachabilityEvidence? BuildReachabilityEvidence(PolicyEvaluationReachability reachability)
{
if (reachability.IsUnknown && string.IsNullOrWhiteSpace(reachability.EvidenceRef))
{
return null;
}
var state = reachability.IsReachable
? (reachability.HasRuntimeEvidence ? ReachabilityState.ConfirmedReachable : ReachabilityState.StaticReachable)
: reachability.IsUnreachable
? (reachability.HasRuntimeEvidence ? ReachabilityState.ConfirmedUnreachable : ReachabilityState.StaticUnreachable)
: ReachabilityState.Unknown;
var digests = string.IsNullOrWhiteSpace(reachability.EvidenceRef)
? Array.Empty<string>()
: new[] { reachability.EvidenceRef! };
return new ReachabilityEvidence
{
State = state,
AnalysisConfidence = Clamp01(reachability.Confidence),
GraphDigests = digests
};
}
private static RuntimeEvidence? BuildRuntimeEvidence(PolicyEvaluationContext context)
{
if (!context.Reachability.HasRuntimeEvidence)
{
return null;
}
var posture = context.Reachability.IsReachable || context.Reachability.IsUnreachable
? RuntimePosture.Supports
: RuntimePosture.Unknown;
return new RuntimeEvidence
{
Posture = posture,
ObservationCount = 1,
LastObserved = context.Now,
SessionDigests = Array.Empty<string>()
};
}
private static VexEvidence? BuildVexEvidence(PolicyEvaluationContext context)
{
if (context.Vex.Statements.IsDefaultOrEmpty)
{
return null;
}
var issuer = string.IsNullOrWhiteSpace(context.Advisory.Source)
? "unknown"
: context.Advisory.Source;
var statements = context.Vex.Statements
.Select(statement =>
{
var timestamp = statement.Timestamp ?? DateTimeOffset.MinValue;
return new VexStatement
{
Status = MapVexStatus(statement.Status),
Issuer = issuer,
TrustScore = ComputeVexTrustScore(issuer, statement),
Timestamp = timestamp,
StatementDigest = ComputeVexDigest(issuer, statement, timestamp)
};
})
.ToList();
return new VexEvidence { Statements = statements };
}
private static ProvenanceEvidence? BuildProvenanceEvidence(PolicyEvaluationContext context)
{
var hasSbomComponents = !context.Sbom.Components.IsDefaultOrEmpty;
if (context.ProvenanceAttested is null && !hasSbomComponents)
{
return null;
}
var level = context.ProvenanceAttested == true ? ProvenanceLevel.Signed : ProvenanceLevel.Unsigned;
return new ProvenanceEvidence
{
Level = level,
SbomCompleteness = ComputeSbomCompleteness(context.Sbom),
AttestationDigests = Array.Empty<string>()
};
}
private static PolicyEvidence BuildPolicyEvidence(PolicyEvaluationContext context, PolicyEvaluationResult result)
{
var ruleName = result.RuleName ?? "default";
var matchStrength = result.Matched ? 0.9m : 0.6m;
return new PolicyEvidence
{
RuleName = ruleName,
MatchStrength = Clamp01(matchStrength),
EvaluationDigest = ComputePolicyEvaluationDigest(context.PolicyDigest, result)
};
}
private static VexStatus MapVexStatus(string status)
{
if (status.Equals("not_affected", StringComparison.OrdinalIgnoreCase))
{
return VexStatus.NotAffected;
}
if (status.Equals("fixed", StringComparison.OrdinalIgnoreCase))
{
return VexStatus.Fixed;
}
if (status.Equals("under_investigation", StringComparison.OrdinalIgnoreCase))
{
return VexStatus.UnderInvestigation;
}
if (status.Equals("affected", StringComparison.OrdinalIgnoreCase))
{
return VexStatus.Affected;
}
return VexStatus.UnderInvestigation;
}
private static decimal ComputeVexTrustScore(string issuer, PolicyEvaluationVexStatement statement)
{
var score = issuer.Contains("vendor", StringComparison.OrdinalIgnoreCase)
|| issuer.Contains("distro", StringComparison.OrdinalIgnoreCase)
? 0.85m
: 0.7m;
if (!string.IsNullOrWhiteSpace(statement.Justification))
{
score += 0.05m;
}
if (!string.IsNullOrWhiteSpace(statement.StatementId))
{
score += 0.05m;
}
return Clamp01(score);
}
private static string ComputeVexDigest(
string issuer,
PolicyEvaluationVexStatement statement,
DateTimeOffset timestamp)
{
var input = $"{issuer}|{statement.Status}|{statement.Justification}|{statement.StatementId}|{timestamp:O}";
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(Encoding.UTF8.GetBytes(input), hash);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private static decimal ComputeSbomCompleteness(PolicyEvaluationSbom sbom)
{
if (sbom.Components.IsDefaultOrEmpty)
{
return 0.4m;
}
var count = sbom.Components.Length;
return count switch
{
<= 5 => 0.6m,
<= 20 => 0.75m,
<= 100 => 0.85m,
_ => 0.9m
};
}
private static string ComputePolicyEvaluationDigest(string? policyDigest, PolicyEvaluationResult result)
{
var input = string.Join(
'|',
policyDigest ?? "unknown",
result.RuleName ?? "default",
result.Status,
result.Severity ?? "none",
result.Priority?.ToString(CultureInfo.InvariantCulture) ?? "none");
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(Encoding.UTF8.GetBytes(input), hash);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private static decimal Clamp01(decimal value)
{
if (value <= 0m)
{
return 0m;
}
if (value >= 1m)
{
return 1m;
}
return value;
}
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
{
private readonly T _value;
public StaticOptionsMonitor(T value) => _value = value;
public T CurrentValue => _value;
public T Get(string? name) => _value;
public IDisposable OnChange(Action<T, string> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static readonly NullDisposable Instance = new();
public void Dispose() { }
}
}
}

View File

@@ -8,19 +8,20 @@ namespace StellaOps.Policy.Engine.Services;
internal sealed partial class PolicyEvaluationService
{
private readonly PolicyEvaluator evaluator = new();
private readonly PolicyEvaluator _evaluator;
private readonly PathScopeMetrics _pathMetrics;
private readonly ILogger<PolicyEvaluationService> _logger;
public PolicyEvaluationService()
: this(new PathScopeMetrics(), NullLogger<PolicyEvaluationService>.Instance)
: this(new PathScopeMetrics(), NullLogger<PolicyEvaluationService>.Instance, new PolicyEvaluator())
{
}
public PolicyEvaluationService(PathScopeMetrics pathMetrics, ILogger<PolicyEvaluationService> logger)
public PolicyEvaluationService(PathScopeMetrics pathMetrics, ILogger<PolicyEvaluationService> logger, PolicyEvaluator evaluator)
{
_pathMetrics = pathMetrics ?? throw new ArgumentNullException(nameof(pathMetrics));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_evaluator = evaluator ?? throw new ArgumentNullException(nameof(evaluator));
}
internal Evaluation.PolicyEvaluationResult Evaluate(PolicyIrDocument document, Evaluation.PolicyEvaluationContext context)
@@ -36,7 +37,7 @@ internal sealed partial class PolicyEvaluationService
}
var request = new Evaluation.PolicyEvaluationRequest(document, context);
return evaluator.Evaluate(request);
return _evaluator.Evaluate(request);
}
// PathScopeSimulationService partial class relies on _pathMetrics.

View File

@@ -5,10 +5,13 @@ using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Confidence.Models;
using StellaOps.Policy.Engine.Caching;
using StellaOps.Policy.Engine.Domain;
using StellaOps.Policy.Engine.Evaluation;
using StellaOps.Policy.Engine.Telemetry;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Unknowns.Models;
using StellaOps.PolicyDsl;
namespace StellaOps.Policy.Engine.Services;
@@ -48,6 +51,7 @@ internal sealed record RuntimeEvaluationResponse(
ImmutableDictionary<string, string> Annotations,
ImmutableArray<string> Warnings,
PolicyExceptionApplication? AppliedException,
ConfidenceScore? Confidence,
string CorrelationId,
bool Cached,
CacheSource CacheSource,
@@ -174,9 +178,13 @@ internal sealed class PolicyRuntimeEvaluationService
effectiveRequest.Vex,
effectiveRequest.Sbom,
effectiveRequest.Exceptions,
ImmutableArray<Unknown>.Empty,
ImmutableArray<ExceptionObject>.Empty,
effectiveRequest.Reachability,
entropy,
evaluationTimestamp);
evaluationTimestamp,
policyDigest: bundle.Digest,
provenanceAttested: effectiveRequest.ProvenanceAttested);
var evalRequest = new Evaluation.PolicyEvaluationRequest(document, context);
var result = _evaluator.Evaluate(evalRequest);
@@ -195,7 +203,8 @@ internal sealed class PolicyRuntimeEvaluationService
result.AppliedException?.ExceptionId,
correlationId,
evaluationTimestamp,
expiresAt);
expiresAt,
result.Confidence);
await _cache.SetAsync(cacheKey, cacheEntry, cancellationToken).ConfigureAwait(false);
@@ -244,6 +253,7 @@ internal sealed class PolicyRuntimeEvaluationService
result.Annotations,
result.Warnings,
result.AppliedException,
result.Confidence,
correlationId,
Cached: false,
CacheSource: CacheSource.None,
@@ -354,9 +364,13 @@ internal sealed class PolicyRuntimeEvaluationService
request.Vex,
request.Sbom,
request.Exceptions,
ImmutableArray<Unknown>.Empty,
ImmutableArray<ExceptionObject>.Empty,
request.Reachability,
entropy,
evaluationTimestamp);
evaluationTimestamp,
policyDigest: bundle.Digest,
provenanceAttested: request.ProvenanceAttested);
var evalRequest = new Evaluation.PolicyEvaluationRequest(document, context);
var result = _evaluator.Evaluate(evalRequest);
@@ -375,7 +389,8 @@ internal sealed class PolicyRuntimeEvaluationService
result.AppliedException?.ExceptionId,
correlationId,
evaluationTimestamp,
expiresAt);
expiresAt,
result.Confidence);
entriesToCache[key] = cacheEntry;
cacheMisses++;
@@ -413,6 +428,7 @@ internal sealed class PolicyRuntimeEvaluationService
result.Annotations,
result.Warnings,
result.AppliedException,
result.Confidence,
correlationId,
Cached: false,
CacheSource: CacheSource.None,
@@ -473,6 +489,7 @@ internal sealed class PolicyRuntimeEvaluationService
entry.Annotations,
entry.Warnings,
appliedException,
entry.Confidence,
entry.CorrelationId,
Cached: true,
CacheSource: source,
@@ -496,8 +513,12 @@ internal sealed class PolicyRuntimeEvaluationService
severityScore = request.Severity.Score,
advisorySource = request.Advisory.Source,
vexCount = request.Vex.Statements.Length,
vexStatements = request.Vex.Statements.Select(s => $"{s.Status}:{s.Justification}").OrderBy(s => s).ToArray(),
vexStatements = request.Vex.Statements
.Select(s => $"{s.Status}:{s.Justification}:{s.StatementId}:{s.Timestamp:O}")
.OrderBy(s => s)
.ToArray(),
sbomTags = request.Sbom.Tags.OrderBy(t => t).ToArray(),
sbomComponentCount = request.Sbom.Components.IsDefaultOrEmpty ? 0 : request.Sbom.Components.Length,
exceptionCount = request.Exceptions.Instances.Length,
reachability = new
{
@@ -506,7 +527,8 @@ internal sealed class PolicyRuntimeEvaluationService
score = request.Reachability.Score,
hasRuntimeEvidence = request.Reachability.HasRuntimeEvidence,
source = request.Reachability.Source,
method = request.Reachability.Method
method = request.Reachability.Method,
evidenceRef = request.Reachability.EvidenceRef
},
entropy = new
{

View File

@@ -7,3 +7,6 @@ This file mirrors sprint work for the Policy Engine module.
| `POLICY-GATE-401-033` | `docs/implplan/SPRINT_0401_0001_0001_reachability_evidence_chain.md` | DONE (2025-12-13) | Implemented PolicyGateEvaluator (lattice/uncertainty/evidence completeness) and aligned tests/docs; see `src/Policy/StellaOps.Policy.Engine/Gates/PolicyGateEvaluator.cs` and `src/Policy/__Tests/StellaOps.Policy.Engine.Tests/Gates/PolicyGateEvaluatorTests.cs`. |
| `DET-3401-011` | `docs/implplan/SPRINT_3401_0001_0001_determinism_scoring_foundations.md` | DONE (2025-12-14) | Added `Explain` to `RiskScoringResult` and covered JSON serialization + null-coercion in `src/Policy/__Tests/StellaOps.Policy.Engine.Tests/Scoring/RiskScoringResultTests.cs`. |
| `PDA-3801-0001` | `docs/implplan/SPRINT_3801_0001_0001_policy_decision_attestation.md` | DONE (2025-12-19) | Implemented `PolicyDecisionAttestationService` + predicate model + DI wiring; covered signer/Rekor flows in `src/Policy/__Tests/StellaOps.Policy.Engine.Tests/Attestation/PolicyDecisionAttestationServiceTests.cs`. |
| `EXC-3900-0003-0002-T6` | `docs/implplan/SPRINT_3900_0003_0002_recheck_policy_evidence_hooks.md` | DONE (2025-12-22) | Added ExceptionRecheckGate and DI registration for build gate integration. |
| `UNK-4100-0001-T6` | `docs/implplan/SPRINT_4100_0001_0001_reason_coded_unknowns.md` | DONE (2025-12-22) | Extended unknowns API DTOs with reason codes, remediation hints, and evidence refs. |
| `UNK-4100-0001-0002` | `docs/implplan/SPRINT_4100_0001_0002_unknown_budgets.md` | DONE (2025-12-22) | Added unknown budget enforcement in policy evaluation, options binding, and budget service tests. |

View File

@@ -0,0 +1,35 @@
# StellaOps.Policy.Exceptions - Agent Charter
## Mission
- Deliver deterministic Exception Objects, recheck policies, and evidence hook validation that integrate with Policy Engine evaluation and audit trails.
- Keep exception persistence and evaluation reproducible and offline friendly.
## Roles
- Backend / Policy engineer (.NET 10, C# preview).
- QA engineer (unit and integration tests).
## Required Reading (treat as read before DOING)
- `docs/modules/policy/architecture.md`
- `docs/modules/platform/architecture-overview.md`
- `docs/product-advisories/archived/20-Dec-2025 - Moat Explanation - Exception management as auditable objects.md`
- `docs/product-advisories/22-Dec-2026 - UI Patterns for Triage and Replay.md`
- Current sprint file in `docs/implplan/SPRINT_3900_*.md`
## Working Directory & Boundaries
- Primary scope: `src/Policy/__Libraries/StellaOps.Policy.Exceptions/**`.
- Related migrations: `src/Policy/__Libraries/StellaOps.Policy.Storage.Postgres/Migrations`.
- Tests: `src/Policy/__Tests/StellaOps.Policy.Exceptions.Tests/**` and `src/Policy/__Tests/StellaOps.Policy.Storage.Postgres.Tests/**`.
- Avoid cross-module edits unless the sprint explicitly allows.
## Determinism & Offline Rules
- Use UTC timestamps and stable ordering; avoid random or wall-clock based identifiers.
- No external network calls; rely on injected services and local data sources only.
## Testing Expectations
- Add or update unit tests for models and services.
- Add or update integration tests for repository and migration changes.
- Ensure serialization and ordering are deterministic.
## Workflow
- Update task status to `DOING`/`DONE` in the sprint file and `src/Policy/__Libraries/StellaOps.Policy/TASKS.md`.
- Record design decisions in sprint `Decisions & Risks` and update docs when contracts change.

View File

@@ -0,0 +1,185 @@
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Policy.Exceptions.Models;
/// <summary>
/// Evidence hook requiring specific attestations before exception approval.
/// </summary>
public sealed record EvidenceHook
{
/// <summary>
/// Unique identifier for this hook.
/// </summary>
public required string HookId { get; init; }
/// <summary>
/// Type of evidence required.
/// </summary>
public required EvidenceType Type { get; init; }
/// <summary>
/// Human-readable description of the requirement.
/// </summary>
public required string Description { get; init; }
/// <summary>
/// Whether this evidence is mandatory for approval.
/// </summary>
public bool IsMandatory { get; init; } = true;
/// <summary>
/// Schema or predicate type for validation.
/// </summary>
public string? ValidationSchema { get; init; }
/// <summary>
/// Maximum age of evidence (for freshness validation).
/// </summary>
public TimeSpan? MaxAge { get; init; }
/// <summary>
/// Required trust score for evidence source.
/// </summary>
public decimal? MinTrustScore { get; init; }
}
/// <summary>
/// Types of evidence that can be required.
/// </summary>
public enum EvidenceType
{
/// <summary>Feature flag is disabled in target environment.</summary>
FeatureFlagDisabled,
/// <summary>Backport PR has been merged.</summary>
BackportMerged,
/// <summary>Compensating control attestation.</summary>
CompensatingControl,
/// <summary>Security review completed.</summary>
SecurityReview,
/// <summary>Runtime mitigation in place.</summary>
RuntimeMitigation,
/// <summary>WAF rule deployed.</summary>
WAFRuleDeployed,
/// <summary>Custom attestation type.</summary>
CustomAttestation
}
/// <summary>
/// Evidence submitted to satisfy a hook.
/// </summary>
public sealed record SubmittedEvidence
{
/// <summary>
/// Unique identifier for this evidence submission.
/// </summary>
public required string EvidenceId { get; init; }
/// <summary>
/// Hook this evidence satisfies.
/// </summary>
public required string HookId { get; init; }
/// <summary>
/// Type of evidence.
/// </summary>
public required EvidenceType Type { get; init; }
/// <summary>
/// Reference to the evidence (URL, attestation ID, etc.).
/// </summary>
public required string Reference { get; init; }
/// <summary>
/// Evidence content or payload.
/// </summary>
public string? Content { get; init; }
/// <summary>
/// DSSE envelope if signed.
/// </summary>
public string? DsseEnvelope { get; init; }
/// <summary>
/// Whether signature was verified.
/// </summary>
public bool SignatureVerified { get; init; }
/// <summary>
/// Trust score of evidence source.
/// </summary>
public decimal TrustScore { get; init; }
/// <summary>
/// When evidence was submitted.
/// </summary>
public required DateTimeOffset SubmittedAt { get; init; }
/// <summary>
/// Who submitted the evidence.
/// </summary>
public required string SubmittedBy { get; init; }
/// <summary>
/// Validation status.
/// </summary>
public required EvidenceValidationStatus ValidationStatus { get; init; }
/// <summary>
/// Validation error if any.
/// </summary>
public string? ValidationError { get; init; }
}
/// <summary>
/// Status of evidence validation.
/// </summary>
public enum EvidenceValidationStatus
{
Pending,
Valid,
Invalid,
Expired,
InsufficientTrust
}
/// <summary>
/// Registry of required evidence hooks for an exception type.
/// </summary>
public sealed record EvidenceRequirements
{
/// <summary>
/// Required evidence hooks.
/// </summary>
public required ImmutableArray<EvidenceHook> Hooks { get; init; }
/// <summary>
/// Evidence submitted so far.
/// </summary>
public ImmutableArray<SubmittedEvidence> SubmittedEvidence { get; init; } = [];
/// <summary>
/// Whether all mandatory evidence is satisfied.
/// </summary>
public bool IsSatisfied => Hooks
.Where(h => h.IsMandatory)
.All(h => SubmittedEvidence.Any(e =>
e.HookId == h.HookId &&
e.ValidationStatus == EvidenceValidationStatus.Valid));
/// <summary>
/// Missing mandatory evidence.
/// </summary>
public ImmutableArray<EvidenceHook> MissingEvidence => Hooks
.Where(h => h.IsMandatory)
.Where(h => !SubmittedEvidence.Any(e =>
e.HookId == h.HookId &&
e.ValidationStatus == EvidenceValidationStatus.Valid))
.ToImmutableArray();
}

View File

@@ -238,6 +238,11 @@ public sealed record ExceptionObject
/// </summary>
public ImmutableArray<string> EvidenceRefs { get; init; } = [];
/// <summary>
/// Evidence requirements and submissions tied to this exception.
/// </summary>
public EvidenceRequirements? EvidenceRequirements { get; init; }
/// <summary>
/// Compensating controls in place that mitigate the risk.
/// </summary>
@@ -254,6 +259,41 @@ public sealed record ExceptionObject
/// </summary>
public string? TicketRef { get; init; }
/// <summary>
/// Reference to the applied recheck policy configuration.
/// </summary>
public string? RecheckPolicyId { get; init; }
/// <summary>
/// Recheck policy that governs automatic re-evaluation.
/// If null, exception is only invalidated by expiry.
/// </summary>
public RecheckPolicy? RecheckPolicy { get; init; }
/// <summary>
/// Result of last recheck evaluation.
/// </summary>
public RecheckEvaluationResult? LastRecheckResult { get; init; }
/// <summary>
/// When recheck was last evaluated.
/// </summary>
public DateTimeOffset? LastRecheckAt { get; init; }
/// <summary>
/// Whether this exception is blocked by recheck policy.
/// </summary>
public bool IsBlockedByRecheck =>
LastRecheckResult?.IsTriggered == true &&
LastRecheckResult.RecommendedAction == RecheckAction.Block;
/// <summary>
/// Whether this exception requires re-approval.
/// </summary>
public bool RequiresReapproval =>
LastRecheckResult?.IsTriggered == true &&
LastRecheckResult.RecommendedAction == RecheckAction.RequireReapproval;
/// <summary>
/// Determines if this exception is currently effective.
/// </summary>

View File

@@ -0,0 +1,157 @@
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Policy.Exceptions.Models;
/// <summary>
/// Policy defining conditions that trigger exception re-evaluation.
/// When any condition is met, the exception may be invalidated or flagged.
/// </summary>
public sealed record RecheckPolicy
{
/// <summary>
/// Unique identifier for this policy configuration.
/// </summary>
public required string PolicyId { get; init; }
/// <summary>
/// Human-readable name for this policy.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Conditions that trigger recheck.
/// </summary>
public required ImmutableArray<RecheckCondition> Conditions { get; init; }
/// <summary>
/// Default action when any condition is triggered.
/// </summary>
public required RecheckAction DefaultAction { get; init; }
/// <summary>
/// Whether this policy is active.
/// </summary>
public bool IsActive { get; init; } = true;
/// <summary>
/// When this policy was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
}
/// <summary>
/// A single condition that triggers exception re-evaluation.
/// </summary>
public sealed record RecheckCondition
{
/// <summary>
/// Type of condition to check.
/// </summary>
public required RecheckConditionType Type { get; init; }
/// <summary>
/// Threshold value (interpretation depends on Type).
/// </summary>
public decimal? Threshold { get; init; }
/// <summary>
/// Environment scopes where this condition applies.
/// </summary>
public ImmutableArray<string> EnvironmentScope { get; init; } = [];
/// <summary>
/// Action to take when this specific condition is triggered.
/// If null, uses policy's DefaultAction.
/// </summary>
public RecheckAction? Action { get; init; }
/// <summary>
/// Human-readable description of this condition.
/// </summary>
public string? Description { get; init; }
}
/// <summary>
/// Types of recheck conditions.
/// </summary>
public enum RecheckConditionType
{
/// <summary>Reachability graph changes (new paths discovered).</summary>
ReachGraphChange,
/// <summary>EPSS score exceeds threshold.</summary>
EPSSAbove,
/// <summary>CVSS score exceeds threshold.</summary>
CVSSAbove,
/// <summary>Unknown budget exceeds threshold.</summary>
UnknownsAbove,
/// <summary>New CVE added to same package.</summary>
NewCVEInPackage,
/// <summary>KEV (Known Exploited Vulnerability) flag set.</summary>
KEVFlagged,
/// <summary>Exception nearing expiry (days before).</summary>
ExpiryWithin,
/// <summary>VEX status changes (e.g., from NotAffected to Affected).</summary>
VEXStatusChange,
/// <summary>Package version changes.</summary>
PackageVersionChange
}
/// <summary>
/// Action to take when a recheck condition is triggered.
/// </summary>
public enum RecheckAction
{
/// <summary>Log warning but allow exception to remain active.</summary>
Warn,
/// <summary>Require manual re-approval of exception.</summary>
RequireReapproval,
/// <summary>Automatically revoke the exception.</summary>
Revoke,
/// <summary>Block build/deployment pipeline.</summary>
Block
}
/// <summary>
/// Result of evaluating recheck conditions against an exception.
/// </summary>
public sealed record RecheckEvaluationResult
{
/// <summary>Whether any conditions were triggered.</summary>
public required bool IsTriggered { get; init; }
/// <summary>List of triggered conditions with details.</summary>
public required ImmutableArray<TriggeredCondition> TriggeredConditions { get; init; }
/// <summary>Recommended action based on triggered conditions.</summary>
public required RecheckAction? RecommendedAction { get; init; }
/// <summary>When this evaluation was performed.</summary>
public required DateTimeOffset EvaluatedAt { get; init; }
/// <summary>Human-readable summary.</summary>
public string Summary => IsTriggered
? $"{TriggeredConditions.Length} condition(s) triggered: {string.Join(", ", TriggeredConditions.Select(t => t.Type))}"
: "No conditions triggered";
}
/// <summary>
/// Details of a triggered recheck condition.
/// </summary>
public sealed record TriggeredCondition(
RecheckConditionType Type,
string Description,
decimal? CurrentValue,
decimal? ThresholdValue,
RecheckAction Action);

View File

@@ -59,7 +59,7 @@ public sealed class PostgresExceptionRepository : IExceptionRepository
environments, tenant_id, owner_id, requester_id, approver_ids,
created_at, updated_at, approved_at, expires_at,
reason_code, rationale, evidence_refs, compensating_controls,
metadata, ticket_ref
metadata, ticket_ref, recheck_policy_id, last_recheck_result, last_recheck_at
)
VALUES (
@id, @exception_id, @version, @status, @type,
@@ -67,7 +67,7 @@ public sealed class PostgresExceptionRepository : IExceptionRepository
@environments, @tenant_id, @owner_id, @requester_id, @approver_ids,
@created_at, @updated_at, @approved_at, @expires_at,
@reason_code, @rationale, @evidence_refs::jsonb, @compensating_controls::jsonb,
@metadata::jsonb, @ticket_ref
@metadata::jsonb, @ticket_ref, @recheck_policy_id, @last_recheck_result::jsonb, @last_recheck_at
)
RETURNING *
""";
@@ -160,7 +160,10 @@ public sealed class PostgresExceptionRepository : IExceptionRepository
evidence_refs = @evidence_refs::jsonb,
compensating_controls = @compensating_controls::jsonb,
metadata = @metadata::jsonb,
ticket_ref = @ticket_ref
ticket_ref = @ticket_ref,
recheck_policy_id = @recheck_policy_id,
last_recheck_result = @last_recheck_result::jsonb,
last_recheck_at = @last_recheck_at
WHERE exception_id = @exception_id AND version = @old_version
RETURNING *
""";
@@ -658,6 +661,13 @@ public sealed class PostgresExceptionRepository : IExceptionRepository
cmd.Parameters.AddWithValue("compensating_controls", JsonSerializer.Serialize(ex.CompensatingControls, JsonOptions));
cmd.Parameters.AddWithValue("metadata", JsonSerializer.Serialize(ex.Metadata, JsonOptions));
cmd.Parameters.AddWithValue("ticket_ref", (object?)ex.TicketRef ?? DBNull.Value);
cmd.Parameters.AddWithValue("recheck_policy_id", (object?)(ex.RecheckPolicyId ?? ex.RecheckPolicy?.PolicyId) ?? DBNull.Value);
cmd.Parameters.AddWithValue(
"last_recheck_result",
ex.LastRecheckResult is not null
? JsonSerializer.Serialize(ex.LastRecheckResult, JsonOptions)
: DBNull.Value);
cmd.Parameters.AddWithValue("last_recheck_at", (object?)ex.LastRecheckAt ?? DBNull.Value);
}
private static ExceptionObject MapException(NpgsqlDataReader reader)
@@ -675,6 +685,7 @@ public sealed class PostgresExceptionRepository : IExceptionRepository
var evidenceRefs = ParseJsonArray<string>(GetNullableString(reader, "evidence_refs") ?? "[]");
var compensatingControls = ParseJsonArray<string>(GetNullableString(reader, "compensating_controls") ?? "[]");
var metadata = ParseJsonDict(GetNullableString(reader, "metadata") ?? "{}");
var lastRecheckResult = ParseJsonObject<RecheckEvaluationResult>(GetNullableString(reader, "last_recheck_result"));
return new ExceptionObject
{
@@ -695,7 +706,10 @@ public sealed class PostgresExceptionRepository : IExceptionRepository
EvidenceRefs = evidenceRefs.ToImmutableArray(),
CompensatingControls = compensatingControls.ToImmutableArray(),
Metadata = metadata.ToImmutableDictionary(),
TicketRef = GetNullableString(reader, "ticket_ref")
TicketRef = GetNullableString(reader, "ticket_ref"),
RecheckPolicyId = GetNullableString(reader, "recheck_policy_id"),
LastRecheckResult = lastRecheckResult,
LastRecheckAt = GetNullableDateTimeOffset(reader, "last_recheck_at")
};
}
@@ -763,6 +777,23 @@ public sealed class PostgresExceptionRepository : IExceptionRepository
}
}
private static T? ParseJsonObject<T>(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return default;
}
try
{
return JsonSerializer.Deserialize<T>(json, JsonOptions);
}
catch
{
return default;
}
}
private static Dictionary<string, string> ParseJsonDict(string json)
{
try

View File

@@ -0,0 +1,214 @@
using System.Collections.Immutable;
using System.Linq;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Exceptions.Models;
namespace StellaOps.Policy.Exceptions.Services;
/// <summary>
/// Validates that all required evidence is present before exception approval.
/// </summary>
public sealed class EvidenceRequirementValidator : IEvidenceRequirementValidator
{
private readonly IEvidenceHookRegistry _hookRegistry;
private readonly IAttestationVerifier _attestationVerifier;
private readonly ITrustScoreService _trustScoreService;
private readonly IEvidenceSchemaValidator _schemaValidator;
private readonly ILogger<EvidenceRequirementValidator> _logger;
public EvidenceRequirementValidator(
IEvidenceHookRegistry hookRegistry,
IAttestationVerifier attestationVerifier,
ITrustScoreService trustScoreService,
IEvidenceSchemaValidator schemaValidator,
ILogger<EvidenceRequirementValidator> logger)
{
_hookRegistry = hookRegistry ?? throw new ArgumentNullException(nameof(hookRegistry));
_attestationVerifier = attestationVerifier ?? throw new ArgumentNullException(nameof(attestationVerifier));
_trustScoreService = trustScoreService ?? throw new ArgumentNullException(nameof(trustScoreService));
_schemaValidator = schemaValidator ?? throw new ArgumentNullException(nameof(schemaValidator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Validates that an exception can be approved based on evidence requirements.
/// </summary>
public async Task<EvidenceValidationResult> ValidateForApprovalAsync(
ExceptionObject exception,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(exception);
_logger.LogInformation(
"Validating evidence requirements for exception {ExceptionId}",
exception.ExceptionId);
var requiredHooks = await _hookRegistry
.GetRequiredHooksAsync(exception.Type, exception.Scope, ct)
.ConfigureAwait(false);
if (requiredHooks.Length == 0)
{
return new EvidenceValidationResult
{
IsValid = true,
MissingEvidence = [],
InvalidEvidence = [],
ValidEvidence = [],
Message = "No evidence requirements for this exception type"
};
}
var missingEvidence = new List<EvidenceHook>();
var invalidEvidence = new List<(EvidenceHook Hook, SubmittedEvidence Evidence, string Error)>();
var validEvidence = new List<SubmittedEvidence>();
var submitted = exception.EvidenceRequirements?.SubmittedEvidence ?? [];
foreach (var hook in requiredHooks.Where(h => h.IsMandatory))
{
var evidence = submitted.FirstOrDefault(e => e.HookId == hook.HookId);
if (evidence is null)
{
missingEvidence.Add(hook);
continue;
}
var validation = await ValidateEvidenceAsync(hook, evidence, ct).ConfigureAwait(false);
if (!validation.IsValid)
{
invalidEvidence.Add((hook, evidence, validation.Error ?? "Evidence validation failed"));
}
else
{
validEvidence.Add(evidence);
}
}
var isValid = missingEvidence.Count == 0 && invalidEvidence.Count == 0;
return new EvidenceValidationResult
{
IsValid = isValid,
MissingEvidence = missingEvidence.ToImmutableArray(),
InvalidEvidence = invalidEvidence.Select(e => new InvalidEvidenceEntry(
e.Hook.HookId, e.Evidence.EvidenceId, e.Error)).ToImmutableArray(),
ValidEvidence = validEvidence.ToImmutableArray(),
Message = isValid
? "All evidence requirements satisfied"
: BuildValidationMessage(missingEvidence, invalidEvidence)
};
}
private async Task<(bool IsValid, string? Error)> ValidateEvidenceAsync(
EvidenceHook hook,
SubmittedEvidence evidence,
CancellationToken ct)
{
if (hook.MaxAge.HasValue)
{
var age = DateTimeOffset.UtcNow - evidence.SubmittedAt;
if (age > hook.MaxAge.Value)
{
return (false, $"Evidence is stale (age: {age.TotalHours:F0}h, max: {hook.MaxAge.Value.TotalHours:F0}h)");
}
}
if (hook.MinTrustScore.HasValue)
{
var trustScore = await _trustScoreService.GetScoreAsync(evidence.Reference, ct).ConfigureAwait(false);
if (trustScore < hook.MinTrustScore.Value)
{
return (false, $"Evidence trust score {trustScore:P0} below minimum {hook.MinTrustScore:P0}");
}
}
if (!string.IsNullOrWhiteSpace(hook.ValidationSchema))
{
var schemaResult = await _schemaValidator
.ValidateAsync(hook.ValidationSchema, evidence.Content, ct)
.ConfigureAwait(false);
if (!schemaResult.IsValid)
{
return (false, schemaResult.Error ?? "Schema validation failed");
}
}
if (evidence.DsseEnvelope is not null)
{
var verification = await _attestationVerifier.VerifyAsync(evidence.DsseEnvelope, ct).ConfigureAwait(false);
if (!verification.IsValid)
{
return (false, $"Signature verification failed: {verification.Error}");
}
}
return (true, null);
}
private static string BuildValidationMessage(
IReadOnlyCollection<EvidenceHook> missing,
IReadOnlyCollection<(EvidenceHook Hook, SubmittedEvidence Evidence, string Error)> invalid)
{
var parts = new List<string>();
if (missing.Count > 0)
{
parts.Add($"Missing evidence: {string.Join(", ", missing.Select(h => h.Type))}");
}
if (invalid.Count > 0)
{
parts.Add($"Invalid evidence: {string.Join(", ", invalid.Select(e => $"{e.Hook.Type}: {e.Error}"))}");
}
return string.Join("; ", parts);
}
}
public interface IEvidenceRequirementValidator
{
Task<EvidenceValidationResult> ValidateForApprovalAsync(
ExceptionObject exception,
CancellationToken ct = default);
}
public interface IEvidenceHookRegistry
{
Task<ImmutableArray<EvidenceHook>> GetRequiredHooksAsync(
ExceptionType exceptionType,
ExceptionScope scope,
CancellationToken ct = default);
}
public interface IAttestationVerifier
{
Task<EvidenceVerificationResult> VerifyAsync(string dsseEnvelope, CancellationToken ct = default);
}
public sealed record EvidenceVerificationResult(bool IsValid, string? Error);
public interface ITrustScoreService
{
Task<decimal> GetScoreAsync(string reference, CancellationToken ct = default);
}
public interface IEvidenceSchemaValidator
{
Task<EvidenceSchemaValidationResult> ValidateAsync(
string schemaId,
string? content,
CancellationToken ct = default);
}
public sealed record EvidenceSchemaValidationResult(bool IsValid, string? Error);
public sealed record EvidenceValidationResult
{
public required bool IsValid { get; init; }
public required ImmutableArray<EvidenceHook> MissingEvidence { get; init; }
public required ImmutableArray<InvalidEvidenceEntry> InvalidEvidence { get; init; }
public ImmutableArray<SubmittedEvidence> ValidEvidence { get; init; } = [];
public required string Message { get; init; }
}
public sealed record InvalidEvidenceEntry(string HookId, string EvidenceId, string Error);

View File

@@ -0,0 +1,244 @@
using System.Collections.Immutable;
using StellaOps.Policy.Exceptions.Models;
namespace StellaOps.Policy.Exceptions.Services;
/// <summary>
/// Context for evaluating recheck conditions against current state.
/// </summary>
public sealed record RecheckEvaluationContext
{
/// <summary>Artifact digest under evaluation.</summary>
public string? ArtifactDigest { get; init; }
/// <summary>When this evaluation was performed.</summary>
public required DateTimeOffset EvaluatedAt { get; init; }
/// <summary>Environment name (prod, staging, dev).</summary>
public string? Environment { get; init; }
/// <summary>Reachability graph changed since approval.</summary>
public bool ReachGraphChanged { get; init; }
/// <summary>Current EPSS score for the finding.</summary>
public decimal? EpssScore { get; init; }
/// <summary>Current CVSS score for the finding.</summary>
public decimal? CvssScore { get; init; }
/// <summary>Current unknowns count for the artifact.</summary>
public int? UnknownsCount { get; init; }
/// <summary>New CVE discovered in the same package.</summary>
public bool NewCveInPackage { get; init; }
/// <summary>KEV flag set for the vulnerability.</summary>
public bool KevFlagged { get; init; }
/// <summary>VEX status changed since approval.</summary>
public bool VexStatusChanged { get; init; }
/// <summary>Package version changed since approval.</summary>
public bool PackageVersionChanged { get; init; }
}
/// <summary>
/// Evaluates recheck conditions against current vulnerability state.
/// </summary>
public interface IRecheckEvaluationService
{
/// <summary>
/// Evaluates recheck conditions for an exception.
/// </summary>
/// <param name="exception">Exception to evaluate.</param>
/// <param name="context">Current evaluation context.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Recheck evaluation result.</returns>
Task<RecheckEvaluationResult> EvaluateAsync(
ExceptionObject exception,
RecheckEvaluationContext context,
CancellationToken ct = default);
}
/// <summary>
/// Default implementation of <see cref="IRecheckEvaluationService"/>.
/// </summary>
public sealed class RecheckEvaluationService : IRecheckEvaluationService
{
private static readonly ImmutableDictionary<RecheckAction, int> ActionPriority =
new Dictionary<RecheckAction, int>
{
[RecheckAction.Warn] = 1,
[RecheckAction.RequireReapproval] = 2,
[RecheckAction.Revoke] = 3,
[RecheckAction.Block] = 4
}.ToImmutableDictionary();
/// <inheritdoc />
public Task<RecheckEvaluationResult> EvaluateAsync(
ExceptionObject exception,
RecheckEvaluationContext context,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(exception);
ArgumentNullException.ThrowIfNull(context);
var policy = exception.RecheckPolicy;
if (policy is null || !policy.IsActive)
{
return Task.FromResult(new RecheckEvaluationResult
{
IsTriggered = false,
TriggeredConditions = [],
RecommendedAction = null,
EvaluatedAt = context.EvaluatedAt
});
}
var triggered = new List<TriggeredCondition>();
foreach (var condition in policy.Conditions)
{
if (!AppliesToEnvironment(condition, context.Environment))
{
continue;
}
if (IsTriggered(condition, exception, context, out var triggeredCondition))
{
triggered.Add(triggeredCondition);
}
}
if (triggered.Count == 0)
{
return Task.FromResult(new RecheckEvaluationResult
{
IsTriggered = false,
TriggeredConditions = [],
RecommendedAction = null,
EvaluatedAt = context.EvaluatedAt
});
}
var recommended = triggered
.Select(t => t.Action)
.OrderByDescending(GetActionPriority)
.FirstOrDefault();
return Task.FromResult(new RecheckEvaluationResult
{
IsTriggered = true,
TriggeredConditions = triggered.ToImmutableArray(),
RecommendedAction = recommended,
EvaluatedAt = context.EvaluatedAt
});
}
private static bool AppliesToEnvironment(RecheckCondition condition, string? environment)
{
if (condition.EnvironmentScope.Length == 0)
{
return true;
}
if (string.IsNullOrWhiteSpace(environment))
{
return false;
}
return condition.EnvironmentScope.Contains(environment, StringComparer.OrdinalIgnoreCase);
}
private static bool IsTriggered(
RecheckCondition condition,
ExceptionObject exception,
RecheckEvaluationContext context,
out TriggeredCondition triggered)
{
triggered = default!;
var action = condition.Action ?? exception.RecheckPolicy?.DefaultAction ?? RecheckAction.Warn;
var description = condition.Description ?? $"{condition.Type} triggered";
switch (condition.Type)
{
case RecheckConditionType.ReachGraphChange:
if (context.ReachGraphChanged)
{
triggered = new TriggeredCondition(condition.Type, description, 1, null, action);
return true;
}
return false;
case RecheckConditionType.EPSSAbove:
if (condition.Threshold.HasValue && context.EpssScore.HasValue &&
context.EpssScore.Value >= condition.Threshold.Value)
{
triggered = new TriggeredCondition(condition.Type, description, context.EpssScore, condition.Threshold, action);
return true;
}
return false;
case RecheckConditionType.CVSSAbove:
if (condition.Threshold.HasValue && context.CvssScore.HasValue &&
context.CvssScore.Value >= condition.Threshold.Value)
{
triggered = new TriggeredCondition(condition.Type, description, context.CvssScore, condition.Threshold, action);
return true;
}
return false;
case RecheckConditionType.UnknownsAbove:
if (condition.Threshold.HasValue && context.UnknownsCount.HasValue &&
context.UnknownsCount.Value >= condition.Threshold.Value)
{
triggered = new TriggeredCondition(condition.Type, description, context.UnknownsCount.Value, condition.Threshold, action);
return true;
}
return false;
case RecheckConditionType.NewCVEInPackage:
if (context.NewCveInPackage)
{
triggered = new TriggeredCondition(condition.Type, description, 1, null, action);
return true;
}
return false;
case RecheckConditionType.KEVFlagged:
if (context.KevFlagged)
{
triggered = new TriggeredCondition(condition.Type, description, 1, null, action);
return true;
}
return false;
case RecheckConditionType.ExpiryWithin:
if (condition.Threshold.HasValue)
{
var daysUntilExpiry = (decimal)(exception.ExpiresAt - context.EvaluatedAt).TotalDays;
if (daysUntilExpiry <= condition.Threshold.Value)
{
triggered = new TriggeredCondition(condition.Type, description, daysUntilExpiry, condition.Threshold, action);
return true;
}
}
return false;
case RecheckConditionType.VEXStatusChange:
if (context.VexStatusChanged)
{
triggered = new TriggeredCondition(condition.Type, description, 1, null, action);
return true;
}
return false;
case RecheckConditionType.PackageVersionChange:
if (context.PackageVersionChanged)
{
triggered = new TriggeredCondition(condition.Type, description, 1, null, action);
return true;
}
return false;
default:
return false;
}
}
private static int GetActionPriority(RecheckAction action)
{
return ActionPriority.TryGetValue(action, out var priority) ? priority : 0;
}
}

View File

@@ -0,0 +1,34 @@
# StellaOps.Policy.Storage.Postgres - Agent Charter
## Mission
- Provide deterministic PostgreSQL persistence for Policy module data (packs, risk profiles, exceptions, unknowns).
- Keep migrations idempotent, RLS-safe, and replayable in air-gapped environments.
## Roles
- Backend / database engineer (.NET 10, C# preview, PostgreSQL).
- QA engineer (integration tests with Postgres fixtures).
## Required Reading (treat as read before DOING)
- `docs/modules/policy/architecture.md`
- `docs/modules/platform/architecture-overview.md`
- Current sprint file in `docs/implplan/SPRINT_*.md`
## Working Directory & Boundaries
- Primary scope: `src/Policy/__Libraries/StellaOps.Policy.Storage.Postgres/**`.
- Migrations: `src/Policy/__Libraries/StellaOps.Policy.Storage.Postgres/migrations/**`.
- Tests: `src/Policy/__Tests/StellaOps.Policy.Storage.Postgres.Tests/**`.
- Avoid cross-module edits unless the sprint explicitly allows.
## Determinism & Offline Rules
- Use UTC timestamps and stable ordering.
- Keep migrations deterministic (no volatile defaults or nondeterministic functions).
- No external network calls in repositories or tests.
## Testing Expectations
- Add/adjust integration tests for repository and migration changes.
- Use `PolicyPostgresFixture` and truncate tables between tests.
- Validate JSON serialization order and default values where applicable.
## Workflow
- Update task status to `DOING`/`DONE` in the sprint file.
- Record schema or contract changes in sprint `Decisions & Risks` and update docs when needed.

View File

@@ -0,0 +1,138 @@
-- Policy Schema Migration 010: Exception Recheck Policies and Evidence Hooks
-- Sprint: SPRINT_3900_0003_0002 - Recheck Policy and Evidence Hooks
-- Category: A (safe, can run at startup)
BEGIN;
-- =====================================================================
-- Step 1: Recheck policy registry
-- =====================================================================
CREATE TABLE IF NOT EXISTS policy.recheck_policies (
policy_id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
name TEXT NOT NULL,
conditions JSONB NOT NULL,
default_action TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_recheck_policies_tenant
ON policy.recheck_policies (tenant_id, is_active);
-- =====================================================================
-- Step 2: Evidence hook registry
-- =====================================================================
CREATE TABLE IF NOT EXISTS policy.evidence_hooks (
hook_id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
type TEXT NOT NULL,
description TEXT NOT NULL,
is_mandatory BOOLEAN NOT NULL DEFAULT TRUE,
validation_schema TEXT,
max_age_seconds BIGINT,
min_trust_score DECIMAL(5,4),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_evidence_hooks_tenant_type
ON policy.evidence_hooks (tenant_id, type);
-- =====================================================================
-- Step 3: Submitted evidence
-- =====================================================================
CREATE TABLE IF NOT EXISTS policy.submitted_evidence (
evidence_id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
exception_id TEXT NOT NULL REFERENCES policy.exceptions(exception_id),
hook_id TEXT NOT NULL REFERENCES policy.evidence_hooks(hook_id),
type TEXT NOT NULL,
reference TEXT NOT NULL,
content TEXT,
dsse_envelope TEXT,
signature_verified BOOLEAN NOT NULL DEFAULT FALSE,
trust_score DECIMAL(5,4) NOT NULL DEFAULT 0,
submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
submitted_by TEXT NOT NULL,
validation_status TEXT NOT NULL DEFAULT 'Pending',
validation_error TEXT
);
CREATE INDEX IF NOT EXISTS idx_submitted_evidence_exception
ON policy.submitted_evidence (tenant_id, exception_id);
CREATE INDEX IF NOT EXISTS idx_submitted_evidence_hook
ON policy.submitted_evidence (tenant_id, hook_id);
CREATE INDEX IF NOT EXISTS idx_submitted_evidence_status
ON policy.submitted_evidence (tenant_id, validation_status);
-- =====================================================================
-- Step 4: Extend exceptions table with recheck tracking columns
-- =====================================================================
ALTER TABLE policy.exceptions
ADD COLUMN IF NOT EXISTS recheck_policy_id TEXT REFERENCES policy.recheck_policies(policy_id);
ALTER TABLE policy.exceptions
ADD COLUMN IF NOT EXISTS last_recheck_result JSONB;
ALTER TABLE policy.exceptions
ADD COLUMN IF NOT EXISTS last_recheck_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_exceptions_recheck_policy
ON policy.exceptions (tenant_id, recheck_policy_id)
WHERE recheck_policy_id IS NOT NULL;
-- =====================================================================
-- Step 5: Enable RLS for new tables
-- =====================================================================
ALTER TABLE policy.recheck_policies ENABLE ROW LEVEL SECURITY;
ALTER TABLE policy.evidence_hooks ENABLE ROW LEVEL SECURITY;
ALTER TABLE policy.submitted_evidence ENABLE ROW LEVEL SECURITY;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE tablename = 'recheck_policies'
AND policyname = 'recheck_policies_tenant_isolation'
) THEN
CREATE POLICY recheck_policies_tenant_isolation ON policy.recheck_policies
USING (tenant_id = policy_app.require_current_tenant())
WITH CHECK (tenant_id = policy_app.require_current_tenant());
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE tablename = 'evidence_hooks'
AND policyname = 'evidence_hooks_tenant_isolation'
) THEN
CREATE POLICY evidence_hooks_tenant_isolation ON policy.evidence_hooks
USING (tenant_id = policy_app.require_current_tenant())
WITH CHECK (tenant_id = policy_app.require_current_tenant());
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE tablename = 'submitted_evidence'
AND policyname = 'submitted_evidence_tenant_isolation'
) THEN
CREATE POLICY submitted_evidence_tenant_isolation ON policy.submitted_evidence
USING (tenant_id = policy_app.require_current_tenant())
WITH CHECK (tenant_id = policy_app.require_current_tenant());
END IF;
END $$;
COMMIT;

View File

@@ -0,0 +1,29 @@
-- Policy Schema Migration 010: Unknowns Blast Radius + Containment Signals
-- Adds containment-related columns to policy.unknowns
-- Sprint: SPRINT_4000_0001_0002 - Unknowns BlastRadius and Containment Signals
-- Category: A (safe, can run at startup)
BEGIN;
ALTER TABLE policy.unknowns
ADD COLUMN IF NOT EXISTS blast_radius_dependents INT,
ADD COLUMN IF NOT EXISTS blast_radius_net_facing BOOLEAN,
ADD COLUMN IF NOT EXISTS blast_radius_privilege TEXT,
ADD COLUMN IF NOT EXISTS containment_seccomp TEXT,
ADD COLUMN IF NOT EXISTS containment_fs_mode TEXT,
ADD COLUMN IF NOT EXISTS containment_network_policy TEXT;
COMMENT ON COLUMN policy.unknowns.blast_radius_dependents IS
'Number of packages depending on this package';
COMMENT ON COLUMN policy.unknowns.blast_radius_net_facing IS
'Whether reachable from network entrypoints';
COMMENT ON COLUMN policy.unknowns.blast_radius_privilege IS
'Privilege level: root, user, none';
COMMENT ON COLUMN policy.unknowns.containment_seccomp IS
'Seccomp status: enforced, permissive, disabled';
COMMENT ON COLUMN policy.unknowns.containment_fs_mode IS
'Filesystem mode: ro, rw';
COMMENT ON COLUMN policy.unknowns.containment_network_policy IS
'Network policy: isolated, restricted, open';
COMMIT;

View File

@@ -0,0 +1,27 @@
-- Policy Schema Migration 011: Unknowns Reason Codes + Remediation
-- Adds reason code, remediation hint, evidence refs, and assumptions to policy.unknowns
-- Sprint: SPRINT_4100_0001_0001 - Reason-Coded Unknowns
-- Category: A (safe, can run at startup)
BEGIN;
ALTER TABLE policy.unknowns
ADD COLUMN IF NOT EXISTS reason_code TEXT,
ADD COLUMN IF NOT EXISTS remediation_hint TEXT,
ADD COLUMN IF NOT EXISTS evidence_refs JSONB DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS assumptions JSONB DEFAULT '[]'::jsonb;
CREATE INDEX IF NOT EXISTS idx_unknowns_reason_code
ON policy.unknowns(reason_code)
WHERE reason_code IS NOT NULL;
COMMENT ON COLUMN policy.unknowns.reason_code IS
'Canonical reason code: Reachability, Identity, Provenance, VexConflict, FeedGap, ConfigUnknown, AnalyzerLimit';
COMMENT ON COLUMN policy.unknowns.remediation_hint IS
'Actionable guidance for resolving this unknown';
COMMENT ON COLUMN policy.unknowns.evidence_refs IS
'JSON array of evidence references supporting classification';
COMMENT ON COLUMN policy.unknowns.assumptions IS
'JSON array of assumptions made during analysis';
COMMIT;

View File

@@ -60,7 +60,8 @@ public sealed class PostgresExceptionObjectRepository : RepositoryBase<PolicyDat
owner_id, requester_id, approver_ids,
created_at, updated_at, approved_at, expires_at,
reason_code, rationale, evidence_refs, compensating_controls,
metadata, ticket_ref
metadata, ticket_ref,
recheck_policy_id, last_recheck_result, last_recheck_at
)
VALUES (
@exception_id, @version, @status, @type,
@@ -69,7 +70,8 @@ public sealed class PostgresExceptionObjectRepository : RepositoryBase<PolicyDat
@owner_id, @requester_id, @approver_ids,
@created_at, @updated_at, @approved_at, @expires_at,
@reason_code, @rationale, @evidence_refs::jsonb, @compensating_controls::jsonb,
@metadata::jsonb, @ticket_ref
@metadata::jsonb, @ticket_ref,
@recheck_policy_id, @last_recheck_result::jsonb, @last_recheck_at
)
RETURNING id
""";
@@ -155,7 +157,10 @@ public sealed class PostgresExceptionObjectRepository : RepositoryBase<PolicyDat
evidence_refs = @evidence_refs::jsonb,
compensating_controls = @compensating_controls::jsonb,
metadata = @metadata::jsonb,
ticket_ref = @ticket_ref
ticket_ref = @ticket_ref,
recheck_policy_id = @recheck_policy_id,
last_recheck_result = @last_recheck_result::jsonb,
last_recheck_at = @last_recheck_at
WHERE exception_id = @exception_id AND version = @current_version
""";
@@ -171,6 +176,9 @@ public sealed class PostgresExceptionObjectRepository : RepositoryBase<PolicyDat
AddJsonbParameter(updateCommand, "compensating_controls", JsonSerializer.Serialize(exception.CompensatingControls, JsonOptions));
AddJsonbParameter(updateCommand, "metadata", JsonSerializer.Serialize(exception.Metadata, JsonOptions));
AddParameter(updateCommand, "ticket_ref", (object?)exception.TicketRef ?? DBNull.Value);
AddParameter(updateCommand, "recheck_policy_id", (object?)(exception.RecheckPolicyId ?? exception.RecheckPolicy?.PolicyId) ?? DBNull.Value);
AddJsonbParameter(updateCommand, "last_recheck_result", SerializeRecheckResult(exception.LastRecheckResult));
AddParameter(updateCommand, "last_recheck_at", (object?)exception.LastRecheckAt ?? DBNull.Value);
AddParameter(updateCommand, "current_version", currentVersion);
var rows = await updateCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
@@ -460,6 +468,9 @@ public sealed class PostgresExceptionObjectRepository : RepositoryBase<PolicyDat
AddJsonbParameter(command, "compensating_controls", JsonSerializer.Serialize(exception.CompensatingControls, JsonOptions));
AddJsonbParameter(command, "metadata", JsonSerializer.Serialize(exception.Metadata, JsonOptions));
AddParameter(command, "ticket_ref", (object?)exception.TicketRef ?? DBNull.Value);
AddParameter(command, "recheck_policy_id", (object?)(exception.RecheckPolicyId ?? exception.RecheckPolicy?.PolicyId) ?? DBNull.Value);
AddJsonbParameter(command, "last_recheck_result", SerializeRecheckResult(exception.LastRecheckResult));
AddParameter(command, "last_recheck_at", (object?)exception.LastRecheckAt ?? DBNull.Value);
}
private async Task InsertEventAsync(
@@ -618,7 +629,11 @@ public sealed class PostgresExceptionObjectRepository : RepositoryBase<PolicyDat
EvidenceRefs = ParseJsonArray(reader.GetString(reader.GetOrdinal("evidence_refs"))),
CompensatingControls = ParseJsonArray(reader.GetString(reader.GetOrdinal("compensating_controls"))),
Metadata = ParseJsonDictionary(reader.GetString(reader.GetOrdinal("metadata"))),
TicketRef = GetNullableString(reader, reader.GetOrdinal("ticket_ref"))
TicketRef = GetNullableString(reader, reader.GetOrdinal("ticket_ref")),
RecheckPolicyId = GetNullableString(reader, reader.GetOrdinal("recheck_policy_id")),
LastRecheckResult = ParseJsonObject<RecheckEvaluationResult>(
GetNullableString(reader, reader.GetOrdinal("last_recheck_result"))),
LastRecheckAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("last_recheck_at"))
};
}
@@ -668,6 +683,21 @@ public sealed class PostgresExceptionObjectRepository : RepositoryBase<PolicyDat
return dict?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty;
}
private static T? ParseJsonObject<T>(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return default;
}
return JsonSerializer.Deserialize<T>(json, JsonOptions);
}
private static string? SerializeRecheckResult(RecheckEvaluationResult? result)
{
return result is null ? null : JsonSerializer.Serialize(result, JsonOptions);
}
private static string GetScopeDescription(ExceptionScope scope)
{
var parts = new List<string>();

View File

@@ -0,0 +1,40 @@
# AGENTS.md - Policy Unknowns Library
## Purpose
- Provide deterministic ranking for unknown findings using uncertainty, exploit pressure, decay, and containment signals.
- Maintain stable, reproducible scoring and band assignment.
## Required Reading
- docs/README.md
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
- docs/modules/platform/architecture-overview.md
- docs/modules/policy/architecture.md
- docs/product-advisories/archived/2025-12-21-moat-gap-closure/14-Dec-2025 - Triage and Unknowns Technical Reference.md
## Working Directory
- src/Policy/__Libraries/StellaOps.Policy.Unknowns/
## Signal Sources
### BlastRadius
- Source: Scanner/Signals module call graph analysis.
- Dependents: count of packages in dependency tree.
- NetFacing: reachability from network entrypoints (HTTP controllers, gRPC, etc).
- Privilege: extracted from container config or runtime probes.
### ContainmentSignals
- Source: runtime probes (eBPF, Seccomp profiles, container inspection).
- Seccomp: profile enforcement status.
- FileSystem: mount mode from container spec or /proc/mounts.
- NetworkPolicy: Kubernetes NetworkPolicy or firewall rules.
### Data Flow
1. Scanner generates BlastRadius during SBOM or call graph analysis.
2. Runtime probes collect ContainmentSignals.
3. Signals are stored in policy.unknowns columns.
4. UnknownRanker reads signals for scoring and explainability.
## Engineering Rules
- Target net10.0 with preview features already enabled in repo.
- Determinism: stable ordering, UTC timestamps, and decimal math for scoring.
- No network dependencies inside ranking logic.

View File

@@ -0,0 +1,22 @@
using StellaOps.Policy.Unknowns.Models;
namespace StellaOps.Policy.Unknowns.Configuration;
/// <summary>
/// Configuration options for unknown budgets.
/// </summary>
public sealed class UnknownBudgetOptions
{
public const string SectionName = "UnknownBudgets";
/// <summary>
/// Budget configurations keyed by environment name.
/// </summary>
public Dictionary<string, UnknownBudget> Budgets { get; set; } =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Whether to enforce budgets (false = warn only).
/// </summary>
public bool EnforceBudgets { get; set; } = true;
}

View File

@@ -0,0 +1,26 @@
namespace StellaOps.Policy.Unknowns.Models;
/// <summary>
/// Represents the dependency graph impact of an unknown package.
/// Data sourced from scanner call graph analysis.
/// </summary>
public sealed record BlastRadius
{
/// <summary>
/// Number of packages that directly or transitively depend on this package.
/// 0 indicates isolation.
/// </summary>
public int Dependents { get; init; }
/// <summary>
/// Whether this package is reachable from network-facing entrypoints.
/// True indicates higher risk.
/// </summary>
public bool NetFacing { get; init; }
/// <summary>
/// Privilege level under which this package typically runs.
/// Expected values: root, user, none.
/// </summary>
public string? Privilege { get; init; }
}

View File

@@ -0,0 +1,23 @@
namespace StellaOps.Policy.Unknowns.Models;
/// <summary>
/// Represents runtime isolation and containment posture signals.
/// Data sourced from runtime probes.
/// </summary>
public sealed record ContainmentSignals
{
/// <summary>
/// Seccomp profile status: enforced, permissive, disabled, or null if unknown.
/// </summary>
public string? Seccomp { get; init; }
/// <summary>
/// Filesystem mount mode: ro, rw, or null if unknown.
/// </summary>
public string? FileSystem { get; init; }
/// <summary>
/// Network policy status: isolated, restricted, open, or null if unknown.
/// </summary>
public string? NetworkPolicy { get; init; }
}

View File

@@ -56,6 +56,24 @@ public sealed record Unknown
/// <summary>Exploit pressure from KEV/EPSS/CVSS (0.0000 - 1.0000).</summary>
public required decimal ExploitPressure { get; init; }
/// <summary>Reason code explaining why this entry is unknown.</summary>
public required UnknownReasonCode ReasonCode { get; init; }
/// <summary>Human-readable remediation guidance for this unknown.</summary>
public string? RemediationHint { get; init; }
/// <summary>References to evidence supporting the unknown classification.</summary>
public IReadOnlyList<EvidenceRef> EvidenceRefs { get; init; } = [];
/// <summary>Assumptions applied during analysis.</summary>
public IReadOnlyList<string> Assumptions { get; init; } = [];
/// <summary>Dependency impact signals for containment reduction.</summary>
public BlastRadius? BlastRadius { get; init; }
/// <summary>Runtime containment posture signals.</summary>
public ContainmentSignals? Containment { get; init; }
/// <summary>When this unknown was first detected.</summary>
public required DateTimeOffset FirstSeenAt { get; init; }
@@ -75,6 +93,14 @@ public sealed record Unknown
public required DateTimeOffset UpdatedAt { get; init; }
}
/// <summary>
/// Reference to evidence supporting unknown classification.
/// </summary>
public sealed record EvidenceRef(
string Type,
string Uri,
string? Digest);
/// <summary>
/// Summary counts of unknowns by band for dashboard display.
/// </summary>

View File

@@ -0,0 +1,92 @@
namespace StellaOps.Policy.Unknowns.Models;
/// <summary>
/// Represents an unknown budget for a specific environment.
/// Budgets define maximum acceptable unknown counts by reason code.
/// </summary>
public sealed record UnknownBudget
{
/// <summary>
/// Environment name: "prod", "stage", "dev", or custom.
/// </summary>
public required string Environment { get; init; }
/// <summary>
/// Maximum total unknowns allowed across all reason codes.
/// </summary>
public int? TotalLimit { get; init; }
/// <summary>
/// Per-reason-code limits. Missing codes inherit from TotalLimit.
/// </summary>
public IReadOnlyDictionary<UnknownReasonCode, int> ReasonLimits { get; init; }
= new Dictionary<UnknownReasonCode, int>();
/// <summary>
/// Action when budget is exceeded.
/// </summary>
public BudgetAction Action { get; init; } = BudgetAction.Warn;
/// <summary>
/// Custom message to display when budget is exceeded.
/// </summary>
public string? ExceededMessage { get; init; }
}
/// <summary>
/// Action to take when unknown budget is exceeded.
/// </summary>
public enum BudgetAction
{
/// <summary>
/// Log warning only, do not block.
/// </summary>
Warn,
/// <summary>
/// Block the operation (fail policy evaluation).
/// </summary>
Block,
/// <summary>
/// Warn but allow if exception is applied.
/// </summary>
WarnUnlessException
}
/// <summary>
/// Result of checking unknowns against a budget.
/// </summary>
public sealed record BudgetCheckResult
{
public required bool IsWithinBudget { get; init; }
public required BudgetAction RecommendedAction { get; init; }
public required int TotalUnknowns { get; init; }
public int? TotalLimit { get; init; }
public IReadOnlyDictionary<UnknownReasonCode, BudgetViolation> Violations { get; init; }
= new Dictionary<UnknownReasonCode, BudgetViolation>();
public string? Message { get; init; }
}
/// <summary>
/// Details of a specific budget violation.
/// </summary>
public sealed record BudgetViolation(
UnknownReasonCode ReasonCode,
int Count,
int Limit);
/// <summary>
/// Summary of budget status for reporting and dashboards.
/// </summary>
public sealed record BudgetStatusSummary
{
public required string Environment { get; init; }
public required int TotalUnknowns { get; init; }
public int? TotalLimit { get; init; }
public decimal PercentageUsed { get; init; }
public bool IsExceeded { get; init; }
public int ViolationCount { get; init; }
public IReadOnlyDictionary<UnknownReasonCode, int> ByReasonCode { get; init; }
= new Dictionary<UnknownReasonCode, int>();
}

View File

@@ -0,0 +1,50 @@
namespace StellaOps.Policy.Unknowns.Models;
/// <summary>
/// Canonical reason codes explaining why a component is marked as unknown.
/// Each code maps to a specific remediation action.
/// </summary>
public enum UnknownReasonCode
{
/// <summary>
/// U-RCH: Call path analysis is indeterminate.
/// The reachability analyzer cannot confirm or deny exploitability.
/// </summary>
Reachability,
/// <summary>
/// U-ID: Ambiguous package identity or missing digest.
/// Cannot uniquely identify the component (e.g., missing PURL, no checksum).
/// </summary>
Identity,
/// <summary>
/// U-PROV: Cannot map binary artifact to source repository.
/// Provenance chain is broken or unavailable.
/// </summary>
Provenance,
/// <summary>
/// U-VEX: VEX statements conflict or missing applicability data.
/// Multiple VEX sources disagree or no VEX coverage exists.
/// </summary>
VexConflict,
/// <summary>
/// U-FEED: Required knowledge source is missing or stale.
/// Advisory feed gap (e.g., no NVD/OSV data for this package).
/// </summary>
FeedGap,
/// <summary>
/// U-CONFIG: Feature flag or configuration not observable.
/// Cannot determine if vulnerable code path is enabled at runtime.
/// </summary>
ConfigUnknown,
/// <summary>
/// U-ANALYZER: Language or framework not supported by analyzer.
/// Static analysis tools do not cover this ecosystem.
/// </summary>
AnalyzerLimit
}

View File

@@ -1,4 +1,5 @@
using System.Data;
using System.Text.Json;
using Dapper;
using StellaOps.Policy.Unknowns.Models;
@@ -24,8 +25,13 @@ public sealed class UnknownsRepository : IUnknownsRepository
const string sql = """
SELECT set_config('app.current_tenant', @TenantId::text, true);
SELECT id, tenant_id, package_id, package_version, band, score,
uncertainty_factor, exploit_pressure, first_seen_at,
last_evaluated_at, resolution_reason, resolved_at,
uncertainty_factor, exploit_pressure,
reason_code, remediation_hint,
evidence_refs::text as evidence_refs,
assumptions::text as assumptions,
blast_radius_dependents, blast_radius_net_facing, blast_radius_privilege,
containment_seccomp, containment_fs_mode, containment_network_policy,
first_seen_at, last_evaluated_at, resolution_reason, resolved_at,
created_at, updated_at
FROM policy.unknowns
WHERE id = @Id;
@@ -50,8 +56,13 @@ public sealed class UnknownsRepository : IUnknownsRepository
const string sql = """
SELECT set_config('app.current_tenant', @TenantId::text, true);
SELECT id, tenant_id, package_id, package_version, band, score,
uncertainty_factor, exploit_pressure, first_seen_at,
last_evaluated_at, resolution_reason, resolved_at,
uncertainty_factor, exploit_pressure,
reason_code, remediation_hint,
evidence_refs::text as evidence_refs,
assumptions::text as assumptions,
blast_radius_dependents, blast_radius_net_facing, blast_radius_privilege,
containment_seccomp, containment_fs_mode, containment_network_policy,
first_seen_at, last_evaluated_at, resolution_reason, resolved_at,
created_at, updated_at
FROM policy.unknowns
WHERE package_id = @PackageId AND package_version = @PackageVersion;
@@ -76,8 +87,13 @@ public sealed class UnknownsRepository : IUnknownsRepository
const string sql = """
SELECT set_config('app.current_tenant', @TenantId::text, true);
SELECT id, tenant_id, package_id, package_version, band, score,
uncertainty_factor, exploit_pressure, first_seen_at,
last_evaluated_at, resolution_reason, resolved_at,
uncertainty_factor, exploit_pressure,
reason_code, remediation_hint,
evidence_refs::text as evidence_refs,
assumptions::text as assumptions,
blast_radius_dependents, blast_radius_net_facing, blast_radius_privilege,
containment_seccomp, containment_fs_mode, containment_network_policy,
first_seen_at, last_evaluated_at, resolution_reason, resolved_at,
created_at, updated_at
FROM policy.unknowns
WHERE band = @Band
@@ -122,18 +138,31 @@ public sealed class UnknownsRepository : IUnknownsRepository
SELECT set_config('app.current_tenant', @TenantId::text, true);
INSERT INTO policy.unknowns (
id, tenant_id, package_id, package_version, band, score,
uncertainty_factor, exploit_pressure, first_seen_at,
last_evaluated_at, resolution_reason, resolved_at,
uncertainty_factor, exploit_pressure,
reason_code, remediation_hint,
evidence_refs, assumptions,
blast_radius_dependents, blast_radius_net_facing, blast_radius_privilege,
containment_seccomp, containment_fs_mode, containment_network_policy,
first_seen_at, last_evaluated_at, resolution_reason, resolved_at,
created_at, updated_at
) VALUES (
@Id, @TenantId, @PackageId, @PackageVersion, @Band, @Score,
@UncertaintyFactor, @ExploitPressure, @FirstSeenAt,
@LastEvaluatedAt, @ResolutionReason, @ResolvedAt,
@UncertaintyFactor, @ExploitPressure,
@ReasonCode, @RemediationHint,
@EvidenceRefs::jsonb, @Assumptions::jsonb,
@BlastRadiusDependents, @BlastRadiusNetFacing, @BlastRadiusPrivilege,
@ContainmentSeccomp, @ContainmentFsMode, @ContainmentNetworkPolicy,
@FirstSeenAt, @LastEvaluatedAt, @ResolutionReason, @ResolvedAt,
@CreatedAt, @UpdatedAt
)
RETURNING id, tenant_id, package_id, package_version, band, score,
uncertainty_factor, exploit_pressure, first_seen_at,
last_evaluated_at, resolution_reason, resolved_at,
uncertainty_factor, exploit_pressure,
reason_code, remediation_hint,
evidence_refs::text as evidence_refs,
assumptions::text as assumptions,
blast_radius_dependents, blast_radius_net_facing, blast_radius_privilege,
containment_seccomp, containment_fs_mode, containment_network_policy,
first_seen_at, last_evaluated_at, resolution_reason, resolved_at,
created_at, updated_at;
""";
@@ -147,6 +176,16 @@ public sealed class UnknownsRepository : IUnknownsRepository
unknown.Score,
unknown.UncertaintyFactor,
unknown.ExploitPressure,
ReasonCode = unknown.ReasonCode.ToString(),
unknown.RemediationHint,
EvidenceRefs = SerializeEvidenceRefs(unknown.EvidenceRefs),
Assumptions = SerializeAssumptions(unknown.Assumptions),
BlastRadiusDependents = unknown.BlastRadius?.Dependents,
BlastRadiusNetFacing = unknown.BlastRadius?.NetFacing,
BlastRadiusPrivilege = unknown.BlastRadius?.Privilege,
ContainmentSeccomp = unknown.Containment?.Seccomp,
ContainmentFsMode = unknown.Containment?.FileSystem,
ContainmentNetworkPolicy = unknown.Containment?.NetworkPolicy,
FirstSeenAt = unknown.FirstSeenAt == default ? now : unknown.FirstSeenAt,
LastEvaluatedAt = unknown.LastEvaluatedAt == default ? now : unknown.LastEvaluatedAt,
unknown.ResolutionReason,
@@ -171,6 +210,16 @@ public sealed class UnknownsRepository : IUnknownsRepository
score = @Score,
uncertainty_factor = @UncertaintyFactor,
exploit_pressure = @ExploitPressure,
reason_code = @ReasonCode,
remediation_hint = @RemediationHint,
evidence_refs = @EvidenceRefs::jsonb,
assumptions = @Assumptions::jsonb,
blast_radius_dependents = COALESCE(@BlastRadiusDependents, blast_radius_dependents),
blast_radius_net_facing = COALESCE(@BlastRadiusNetFacing, blast_radius_net_facing),
blast_radius_privilege = COALESCE(@BlastRadiusPrivilege, blast_radius_privilege),
containment_seccomp = COALESCE(@ContainmentSeccomp, containment_seccomp),
containment_fs_mode = COALESCE(@ContainmentFsMode, containment_fs_mode),
containment_network_policy = COALESCE(@ContainmentNetworkPolicy, containment_network_policy),
last_evaluated_at = @LastEvaluatedAt,
resolution_reason = @ResolutionReason,
resolved_at = @ResolvedAt,
@@ -178,6 +227,7 @@ public sealed class UnknownsRepository : IUnknownsRepository
WHERE id = @Id;
""";
var evaluatedAt = DateTimeOffset.UtcNow;
var param = new
{
unknown.TenantId,
@@ -186,10 +236,20 @@ public sealed class UnknownsRepository : IUnknownsRepository
unknown.Score,
unknown.UncertaintyFactor,
unknown.ExploitPressure,
unknown.LastEvaluatedAt,
ReasonCode = unknown.ReasonCode.ToString(),
unknown.RemediationHint,
EvidenceRefs = SerializeEvidenceRefs(unknown.EvidenceRefs),
Assumptions = SerializeAssumptions(unknown.Assumptions),
BlastRadiusDependents = unknown.BlastRadius?.Dependents,
BlastRadiusNetFacing = unknown.BlastRadius?.NetFacing,
BlastRadiusPrivilege = unknown.BlastRadius?.Privilege,
ContainmentSeccomp = unknown.Containment?.Seccomp,
ContainmentFsMode = unknown.Containment?.FileSystem,
ContainmentNetworkPolicy = unknown.Containment?.NetworkPolicy,
LastEvaluatedAt = evaluatedAt,
unknown.ResolutionReason,
unknown.ResolvedAt,
UpdatedAt = DateTimeOffset.UtcNow
UpdatedAt = evaluatedAt
};
var affected = await _connection.ExecuteAsync(sql, param);
@@ -240,13 +300,21 @@ public sealed class UnknownsRepository : IUnknownsRepository
SELECT set_config('app.current_tenant', @TenantId::text, true);
INSERT INTO policy.unknowns (
id, tenant_id, package_id, package_version, band, score,
uncertainty_factor, exploit_pressure, first_seen_at,
last_evaluated_at, resolution_reason, resolved_at,
uncertainty_factor, exploit_pressure,
reason_code, remediation_hint,
evidence_refs, assumptions,
blast_radius_dependents, blast_radius_net_facing, blast_radius_privilege,
containment_seccomp, containment_fs_mode, containment_network_policy,
first_seen_at, last_evaluated_at, resolution_reason, resolved_at,
created_at, updated_at
) VALUES (
@Id, @TenantId, @PackageId, @PackageVersion, @Band, @Score,
@UncertaintyFactor, @ExploitPressure, @FirstSeenAt,
@LastEvaluatedAt, @ResolutionReason, @ResolvedAt,
@UncertaintyFactor, @ExploitPressure,
@ReasonCode, @RemediationHint,
@EvidenceRefs::jsonb, @Assumptions::jsonb,
@BlastRadiusDependents, @BlastRadiusNetFacing, @BlastRadiusPrivilege,
@ContainmentSeccomp, @ContainmentFsMode, @ContainmentNetworkPolicy,
@FirstSeenAt, @LastEvaluatedAt, @ResolutionReason, @ResolvedAt,
@CreatedAt, @UpdatedAt
)
ON CONFLICT (tenant_id, package_id, package_version)
@@ -255,6 +323,16 @@ public sealed class UnknownsRepository : IUnknownsRepository
score = EXCLUDED.score,
uncertainty_factor = EXCLUDED.uncertainty_factor,
exploit_pressure = EXCLUDED.exploit_pressure,
reason_code = EXCLUDED.reason_code,
remediation_hint = EXCLUDED.remediation_hint,
evidence_refs = EXCLUDED.evidence_refs,
assumptions = EXCLUDED.assumptions,
blast_radius_dependents = COALESCE(EXCLUDED.blast_radius_dependents, blast_radius_dependents),
blast_radius_net_facing = COALESCE(EXCLUDED.blast_radius_net_facing, blast_radius_net_facing),
blast_radius_privilege = COALESCE(EXCLUDED.blast_radius_privilege, blast_radius_privilege),
containment_seccomp = COALESCE(EXCLUDED.containment_seccomp, containment_seccomp),
containment_fs_mode = COALESCE(EXCLUDED.containment_fs_mode, containment_fs_mode),
containment_network_policy = COALESCE(EXCLUDED.containment_network_policy, containment_network_policy),
last_evaluated_at = EXCLUDED.last_evaluated_at,
updated_at = EXCLUDED.updated_at;
""";
@@ -272,6 +350,16 @@ public sealed class UnknownsRepository : IUnknownsRepository
unknown.Score,
unknown.UncertaintyFactor,
unknown.ExploitPressure,
ReasonCode = unknown.ReasonCode.ToString(),
unknown.RemediationHint,
EvidenceRefs = SerializeEvidenceRefs(unknown.EvidenceRefs),
Assumptions = SerializeAssumptions(unknown.Assumptions),
BlastRadiusDependents = unknown.BlastRadius?.Dependents,
BlastRadiusNetFacing = unknown.BlastRadius?.NetFacing,
BlastRadiusPrivilege = unknown.BlastRadius?.Privilege,
ContainmentSeccomp = unknown.Containment?.Seccomp,
ContainmentFsMode = unknown.Containment?.FileSystem,
ContainmentNetworkPolicy = unknown.Containment?.NetworkPolicy,
FirstSeenAt = unknown.FirstSeenAt == default ? now : unknown.FirstSeenAt,
LastEvaluatedAt = now,
unknown.ResolutionReason,
@@ -298,6 +386,16 @@ public sealed class UnknownsRepository : IUnknownsRepository
decimal score,
decimal uncertainty_factor,
decimal exploit_pressure,
string? reason_code,
string? remediation_hint,
string? evidence_refs,
string? assumptions,
int? blast_radius_dependents,
bool? blast_radius_net_facing,
string? blast_radius_privilege,
string? containment_seccomp,
string? containment_fs_mode,
string? containment_network_policy,
DateTimeOffset first_seen_at,
DateTimeOffset last_evaluated_at,
string? resolution_reason,
@@ -315,6 +413,30 @@ public sealed class UnknownsRepository : IUnknownsRepository
Score = score,
UncertaintyFactor = uncertainty_factor,
ExploitPressure = exploit_pressure,
ReasonCode = ParseReasonCode(reason_code),
RemediationHint = remediation_hint,
EvidenceRefs = ParseEvidenceRefs(evidence_refs),
Assumptions = ParseAssumptions(assumptions),
BlastRadius = blast_radius_dependents.HasValue ||
blast_radius_net_facing.HasValue ||
!string.IsNullOrEmpty(blast_radius_privilege)
? new BlastRadius
{
Dependents = blast_radius_dependents ?? 0,
NetFacing = blast_radius_net_facing ?? false,
Privilege = blast_radius_privilege
}
: null,
Containment = !string.IsNullOrEmpty(containment_seccomp) ||
!string.IsNullOrEmpty(containment_fs_mode) ||
!string.IsNullOrEmpty(containment_network_policy)
? new ContainmentSignals
{
Seccomp = containment_seccomp,
FileSystem = containment_fs_mode,
NetworkPolicy = containment_network_policy
}
: null,
FirstSeenAt = first_seen_at,
LastEvaluatedAt = last_evaluated_at,
ResolutionReason = resolution_reason,
@@ -326,5 +448,54 @@ public sealed class UnknownsRepository : IUnknownsRepository
private sealed record SummaryRow(int hot_count, int warm_count, int cold_count, int resolved_count);
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
private static IReadOnlyList<EvidenceRef> ParseEvidenceRefs(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return Array.Empty<EvidenceRef>();
try
{
return JsonSerializer.Deserialize<IReadOnlyList<EvidenceRef>>(json, JsonOptions)
?? Array.Empty<EvidenceRef>();
}
catch (JsonException)
{
return Array.Empty<EvidenceRef>();
}
}
private static IReadOnlyList<string> ParseAssumptions(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return Array.Empty<string>();
try
{
return JsonSerializer.Deserialize<IReadOnlyList<string>>(json, JsonOptions)
?? Array.Empty<string>();
}
catch (JsonException)
{
return Array.Empty<string>();
}
}
private static string SerializeEvidenceRefs(IReadOnlyList<EvidenceRef>? refs) =>
JsonSerializer.Serialize(refs ?? Array.Empty<EvidenceRef>(), JsonOptions);
private static string SerializeAssumptions(IReadOnlyList<string>? assumptions) =>
JsonSerializer.Serialize(assumptions ?? Array.Empty<string>(), JsonOptions);
private static UnknownReasonCode ParseReasonCode(string? value) =>
Enum.TryParse<UnknownReasonCode>(value, ignoreCase: true, out var parsed)
? parsed
: UnknownReasonCode.Reachability;
#endregion
}

View File

@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Policy.Unknowns.Configuration;
using StellaOps.Policy.Unknowns.Repositories;
using StellaOps.Policy.Unknowns.Services;
@@ -17,13 +18,18 @@ public static class ServiceCollectionExtensions
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddUnknownsRegistry(
this IServiceCollection services,
Action<UnknownRankerOptions>? configureOptions = null)
Action<UnknownRankerOptions>? configureOptions = null,
Action<UnknownBudgetOptions>? configureBudgetOptions = null)
{
// Configure options
if (configureOptions is not null)
services.Configure(configureOptions);
if (configureBudgetOptions is not null)
services.Configure(configureBudgetOptions);
// Register services
services.AddSingleton<IUnknownBudgetService, UnknownBudgetService>();
services.AddSingleton<IRemediationHintsRegistry, RemediationHintsRegistry>();
services.AddSingleton<IUnknownRanker, UnknownRanker>();
services.AddScoped<IUnknownsRepository, UnknownsRepository>();

View File

@@ -0,0 +1,70 @@
using System.Linq;
using StellaOps.Policy.Unknowns.Models;
namespace StellaOps.Policy.Unknowns.Services;
/// <summary>
/// Registry of remediation hints for each unknown reason code.
/// Provides actionable guidance for resolving unknowns.
/// </summary>
public sealed class RemediationHintsRegistry : IRemediationHintsRegistry
{
private static readonly IReadOnlyDictionary<UnknownReasonCode, RemediationHint> Hints =
new Dictionary<UnknownReasonCode, RemediationHint>
{
[UnknownReasonCode.Reachability] = new(
ShortHint: "Run reachability analysis",
DetailedHint: "Execute call-graph analysis to determine if vulnerable code paths are reachable from application entrypoints.",
AutomationRef: "stella analyze --reachability"),
[UnknownReasonCode.Identity] = new(
ShortHint: "Add package digest",
DetailedHint: "Ensure SBOM includes package checksums (SHA-256) and valid PURL coordinates.",
AutomationRef: "stella sbom --include-digests"),
[UnknownReasonCode.Provenance] = new(
ShortHint: "Add provenance attestation",
DetailedHint: "Generate SLSA provenance linking binary artifact to source repository and build.",
AutomationRef: "stella attest --provenance"),
[UnknownReasonCode.VexConflict] = new(
ShortHint: "Publish authoritative VEX",
DetailedHint: "Create or update VEX document with applicability assessment for your deployment context.",
AutomationRef: "stella vex create"),
[UnknownReasonCode.FeedGap] = new(
ShortHint: "Add advisory source",
DetailedHint: "Configure additional advisory feeds (OSV, vendor-specific) or request coverage from upstream.",
AutomationRef: "stella feed add"),
[UnknownReasonCode.ConfigUnknown] = new(
ShortHint: "Document feature flags",
DetailedHint: "Export runtime configuration showing which features are enabled/disabled in this deployment.",
AutomationRef: "stella config export"),
[UnknownReasonCode.AnalyzerLimit] = new(
ShortHint: "Request analyzer support",
DetailedHint: "This language/framework is not yet supported. File an issue or use manual assessment.",
AutomationRef: null)
};
public RemediationHint GetHint(UnknownReasonCode code) =>
Hints.TryGetValue(code, out var hint) ? hint : RemediationHint.Empty;
public IEnumerable<(UnknownReasonCode Code, RemediationHint Hint)> GetAllHints() =>
Hints.Select(kv => (kv.Key, kv.Value));
}
public sealed record RemediationHint(
string ShortHint,
string DetailedHint,
string? AutomationRef)
{
public static RemediationHint Empty { get; } = new("No remediation available", string.Empty, null);
}
public interface IRemediationHintsRegistry
{
RemediationHint GetHint(UnknownReasonCode code);
IEnumerable<(UnknownReasonCode Code, RemediationHint Hint)> GetAllHints();
}

View File

@@ -0,0 +1,322 @@
using System.Globalization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Unknowns.Configuration;
using StellaOps.Policy.Unknowns.Models;
namespace StellaOps.Policy.Unknowns.Services;
/// <summary>
/// Service for managing and checking unknown budgets.
/// </summary>
public sealed class UnknownBudgetService : IUnknownBudgetService
{
private static readonly string[] ReasonCodeMetadataKeys =
[
"unknownReasonCodes",
"unknown_reason_codes",
"unknown-reason-codes"
];
private static readonly IReadOnlyDictionary<string, UnknownReasonCode> ShortCodeMap =
new Dictionary<string, UnknownReasonCode>(StringComparer.OrdinalIgnoreCase)
{
["U-RCH"] = UnknownReasonCode.Reachability,
["U-ID"] = UnknownReasonCode.Identity,
["U-PROV"] = UnknownReasonCode.Provenance,
["U-VEX"] = UnknownReasonCode.VexConflict,
["U-FEED"] = UnknownReasonCode.FeedGap,
["U-CONFIG"] = UnknownReasonCode.ConfigUnknown,
["U-ANALYZER"] = UnknownReasonCode.AnalyzerLimit
};
private readonly IOptionsMonitor<UnknownBudgetOptions> _options;
private readonly ILogger<UnknownBudgetService> _logger;
public UnknownBudgetService(
IOptionsMonitor<UnknownBudgetOptions> options,
ILogger<UnknownBudgetService> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public UnknownBudget GetBudgetForEnvironment(string environment)
{
var normalized = NormalizeEnvironment(environment);
var budgets = _options.CurrentValue.Budgets
?? new Dictionary<string, UnknownBudget>(StringComparer.OrdinalIgnoreCase);
if (budgets.TryGetValue(normalized, out var budget))
{
return NormalizeBudget(budget, normalized);
}
if (budgets.TryGetValue("default", out var defaultBudget))
{
return NormalizeBudget(defaultBudget, normalized);
}
return new UnknownBudget
{
Environment = normalized,
TotalLimit = null,
Action = BudgetAction.Warn
};
}
/// <inheritdoc />
public BudgetCheckResult CheckBudget(
string environment,
IReadOnlyList<Unknown> unknowns)
{
var normalized = NormalizeEnvironment(environment);
var budget = GetBudgetForEnvironment(normalized);
var safeUnknowns = unknowns ?? Array.Empty<Unknown>();
var byReason = CountByReason(safeUnknowns);
var violations = BuildViolations(budget, byReason);
var total = safeUnknowns.Count;
var totalExceeded = budget.TotalLimit.HasValue && total > budget.TotalLimit.Value;
var isWithinBudget = violations.Count == 0 && !totalExceeded;
var action = isWithinBudget ? BudgetAction.Warn : budget.Action;
if (!isWithinBudget && !_options.CurrentValue.EnforceBudgets)
{
action = BudgetAction.Warn;
}
var message = isWithinBudget
? null
: budget.ExceededMessage ?? $"Unknown budget exceeded: {total} unknowns in {normalized}";
return new BudgetCheckResult
{
IsWithinBudget = isWithinBudget,
RecommendedAction = action,
TotalUnknowns = total,
TotalLimit = budget.TotalLimit,
Violations = violations,
Message = message
};
}
/// <inheritdoc />
public BudgetCheckResult CheckBudgetWithEscalation(
string environment,
IReadOnlyList<Unknown> unknowns,
IReadOnlyList<ExceptionObject>? exceptions = null)
{
var normalized = NormalizeEnvironment(environment);
var baseResult = CheckBudget(normalized, unknowns);
if (baseResult.IsWithinBudget || exceptions is null || exceptions.Count == 0)
{
return baseResult;
}
var coveredReasons = CollectCoveredReasons(exceptions, normalized);
if (coveredReasons.Count == 0)
{
LogViolation(normalized, baseResult);
return baseResult;
}
var byReason = CountByReason(unknowns ?? Array.Empty<Unknown>());
var totalExceeded = baseResult.TotalLimit.HasValue && baseResult.TotalUnknowns > baseResult.TotalLimit.Value;
var violationsCovered = baseResult.Violations.Keys.All(coveredReasons.Contains);
var totalCovered = !totalExceeded || byReason.Keys.All(coveredReasons.Contains);
if (violationsCovered && totalCovered)
{
return baseResult with
{
IsWithinBudget = true,
RecommendedAction = BudgetAction.Warn,
Message = "Budget exceeded but covered by approved exceptions"
};
}
LogViolation(normalized, baseResult);
return baseResult;
}
/// <inheritdoc />
public BudgetStatusSummary GetBudgetStatus(
string environment,
IReadOnlyList<Unknown> unknowns)
{
var normalized = NormalizeEnvironment(environment);
var budget = GetBudgetForEnvironment(normalized);
var safeUnknowns = unknowns ?? Array.Empty<Unknown>();
var byReason = CountByReason(safeUnknowns);
var result = CheckBudget(normalized, safeUnknowns);
var percentage = budget.TotalLimit.HasValue && budget.TotalLimit.Value > 0
? (decimal)safeUnknowns.Count / budget.TotalLimit.Value * 100m
: 0m;
return new BudgetStatusSummary
{
Environment = normalized,
TotalUnknowns = safeUnknowns.Count,
TotalLimit = budget.TotalLimit,
PercentageUsed = percentage,
IsExceeded = !result.IsWithinBudget,
ViolationCount = result.Violations.Count,
ByReasonCode = byReason
};
}
/// <inheritdoc />
public bool ShouldBlock(BudgetCheckResult result) =>
!result.IsWithinBudget && result.RecommendedAction == BudgetAction.Block;
private static string NormalizeEnvironment(string environment) =>
string.IsNullOrWhiteSpace(environment) ? "default" : environment.Trim();
private static UnknownBudget NormalizeBudget(UnknownBudget budget, string environment)
{
var reasonLimits = budget.ReasonLimits ?? new Dictionary<UnknownReasonCode, int>();
return budget with
{
Environment = environment,
ReasonLimits = reasonLimits
};
}
private static IReadOnlyDictionary<UnknownReasonCode, int> CountByReason(IReadOnlyList<Unknown> unknowns) =>
unknowns
.GroupBy(u => u.ReasonCode)
.OrderBy(g => g.Key)
.ToDictionary(g => g.Key, g => g.Count());
private static IReadOnlyDictionary<UnknownReasonCode, BudgetViolation> BuildViolations(
UnknownBudget budget,
IReadOnlyDictionary<UnknownReasonCode, int> byReason)
{
var violations = new Dictionary<UnknownReasonCode, BudgetViolation>();
foreach (var entry in budget.ReasonLimits.OrderBy(r => r.Key))
{
if (byReason.TryGetValue(entry.Key, out var count) && count > entry.Value)
{
violations[entry.Key] = new BudgetViolation(entry.Key, count, entry.Value);
}
}
return violations;
}
private static HashSet<UnknownReasonCode> CollectCoveredReasons(
IReadOnlyList<ExceptionObject> exceptions,
string environment)
{
var covered = new HashSet<UnknownReasonCode>();
foreach (var exception in exceptions)
{
if (exception.Type != ExceptionType.Unknown)
{
continue;
}
if (exception.Status is not (ExceptionStatus.Approved or ExceptionStatus.Active))
{
continue;
}
if (exception.Scope.Environments.Length > 0
&& !exception.Scope.Environments.Any(env => env.Equals(environment, StringComparison.OrdinalIgnoreCase)))
{
continue;
}
var reasons = ParseCoveredReasonCodes(exception);
if (reasons.Count == 0)
{
foreach (var code in Enum.GetValues<UnknownReasonCode>())
{
covered.Add(code);
}
}
else
{
foreach (var code in reasons)
{
covered.Add(code);
}
}
}
return covered;
}
private static HashSet<UnknownReasonCode> ParseCoveredReasonCodes(ExceptionObject exception)
{
foreach (var key in ReasonCodeMetadataKeys)
{
if (exception.Metadata.TryGetValue(key, out var value))
{
return ParseReasonCodes(value);
}
}
return [];
}
private static HashSet<UnknownReasonCode> ParseReasonCodes(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
{
return [];
}
var tokens = raw.Split([',', ';', '|'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var codes = new HashSet<UnknownReasonCode>();
foreach (var token in tokens)
{
if (ShortCodeMap.TryGetValue(token, out var shortCode))
{
codes.Add(shortCode);
continue;
}
var cleaned = token.Replace("U-", "", StringComparison.OrdinalIgnoreCase).Trim();
if (Enum.TryParse(cleaned, ignoreCase: true, out UnknownReasonCode parsed))
{
codes.Add(parsed);
}
}
return codes;
}
private void LogViolation(string environment, BudgetCheckResult result)
{
if (result.IsWithinBudget)
{
return;
}
_logger.LogWarning(
"Unknown budget exceeded for environment {Environment}: {Total}/{Limit}",
environment,
result.TotalUnknowns,
result.TotalLimit?.ToString(CultureInfo.InvariantCulture) ?? "none");
}
}
public interface IUnknownBudgetService
{
UnknownBudget GetBudgetForEnvironment(string environment);
BudgetCheckResult CheckBudget(string environment, IReadOnlyList<Unknown> unknowns);
BudgetCheckResult CheckBudgetWithEscalation(
string environment,
IReadOnlyList<Unknown> unknowns,
IReadOnlyList<ExceptionObject>? exceptions = null);
BudgetStatusSummary GetBudgetStatus(string environment, IReadOnlyList<Unknown> unknowns);
bool ShouldBlock(BudgetCheckResult result);
}

View File

@@ -13,6 +13,17 @@ namespace StellaOps.Policy.Unknowns.Services;
/// <param name="IsInKev">Whether the CVE is in the CISA KEV list.</param>
/// <param name="EpssScore">EPSS score (0.0 - 1.0).</param>
/// <param name="CvssScore">CVSS base score (0.0 - 10.0).</param>
/// <param name="FirstSeenAt">When the unknown was first observed.</param>
/// <param name="LastEvaluatedAt">When the unknown was last re-ranked.</param>
/// <param name="AsOfDateTime">Reference time for decay calculations.</param>
/// <param name="BlastRadius">Dependency impact signals for containment reduction.</param>
/// <param name="Containment">Runtime containment posture signals.</param>
/// <param name="HasPackageDigest">Whether a package digest is available.</param>
/// <param name="HasProvenanceAttestation">Whether provenance attestation exists.</param>
/// <param name="HasVexConflicts">Whether VEX statements conflict.</param>
/// <param name="HasFeedCoverage">Whether advisory feeds cover this package.</param>
/// <param name="HasConfigVisibility">Whether configuration visibility is available.</param>
/// <param name="IsAnalyzerSupported">Whether analyzer supports this ecosystem.</param>
public sealed record UnknownRankInput(
bool HasVexStatement,
bool HasReachabilityData,
@@ -20,7 +31,18 @@ public sealed record UnknownRankInput(
bool IsStaleAdvisory,
bool IsInKev,
decimal EpssScore,
decimal CvssScore);
decimal CvssScore,
DateTimeOffset? FirstSeenAt,
DateTimeOffset? LastEvaluatedAt,
DateTimeOffset AsOfDateTime,
BlastRadius? BlastRadius,
ContainmentSignals? Containment,
bool HasPackageDigest,
bool HasProvenanceAttestation,
bool HasVexConflicts,
bool HasFeedCoverage,
bool HasConfigVisibility,
bool IsAnalyzerSupported);
/// <summary>
/// Result of unknown ranking calculation.
@@ -29,11 +51,19 @@ public sealed record UnknownRankInput(
/// <param name="UncertaintyFactor">Uncertainty component (0.0000 - 1.0000).</param>
/// <param name="ExploitPressure">Exploit pressure component (0.0000 - 1.0000).</param>
/// <param name="Band">Assigned band based on score thresholds.</param>
/// <param name="DecayFactor">Applied time-based decay multiplier.</param>
/// <param name="ContainmentReduction">Applied containment reduction factor.</param>
/// <param name="ReasonCode">Primary reason code for the unknown classification.</param>
/// <param name="RemediationHint">Short remediation hint for the reason code.</param>
public sealed record UnknownRankResult(
decimal Score,
decimal UncertaintyFactor,
decimal ExploitPressure,
UnknownBand Band);
UnknownBand Band,
decimal DecayFactor = 1.0m,
decimal ContainmentReduction = 0m,
UnknownReasonCode ReasonCode = UnknownReasonCode.Reachability,
string? RemediationHint = null);
/// <summary>
/// Service for computing deterministic unknown rankings.
@@ -74,9 +104,13 @@ public interface IUnknownRanker
public sealed class UnknownRanker : IUnknownRanker
{
private readonly UnknownRankerOptions _options;
private readonly IRemediationHintsRegistry _hintsRegistry;
public UnknownRanker(IOptions<UnknownRankerOptions> options)
=> _options = options.Value;
public UnknownRanker(IOptions<UnknownRankerOptions> options, IRemediationHintsRegistry? hintsRegistry = null)
{
_options = options.Value;
_hintsRegistry = hintsRegistry ?? new RemediationHintsRegistry();
}
/// <summary>
/// Default constructor for simple usage without DI.
@@ -88,10 +122,29 @@ public sealed class UnknownRanker : IUnknownRanker
{
var uncertainty = ComputeUncertainty(input);
var pressure = ComputeExploitPressure(input);
var score = Math.Round((uncertainty * 50m) + (pressure * 50m), 2);
var band = AssignBand(score);
var rawScore = Math.Round((uncertainty * 50m) + (pressure * 50m), 2);
return new UnknownRankResult(score, uncertainty, pressure, band);
var decayFactor = _options.EnableDecay ? ComputeDecayFactor(input) : 1.0m;
var decayedScore = Math.Round(rawScore * decayFactor, 2);
var containmentReduction = _options.EnableContainmentReduction
? ComputeContainmentReduction(input)
: 0m;
var finalScore = Math.Round(Math.Max(0m, decayedScore * (1m - containmentReduction)), 2);
var band = AssignBand(finalScore);
var reasonCode = DetermineReasonCode(input);
var hint = _hintsRegistry.GetHint(reasonCode);
return new UnknownRankResult(
finalScore,
uncertainty,
pressure,
band,
decayFactor,
containmentReduction,
reasonCode,
hint.ShortHint);
}
/// <summary>
@@ -144,16 +197,113 @@ public sealed class UnknownRanker : IUnknownRanker
return Math.Min(pressure, 1.0m);
}
/// <summary>
/// Computes time-based decay factor for stale unknowns.
/// </summary>
private decimal ComputeDecayFactor(UnknownRankInput input)
{
if (input.LastEvaluatedAt is null)
return 1.0m;
if (_options.DecayBuckets is null || _options.DecayBuckets.Count == 0)
return 1.0m;
var ageDays = (int)Math.Max(0, (input.AsOfDateTime - input.LastEvaluatedAt.Value).TotalDays);
DecayBucket? selected = null;
foreach (var bucket in _options.DecayBuckets)
{
if (bucket.MaxAgeDays >= ageDays &&
(selected is null || bucket.MaxAgeDays < selected.MaxAgeDays))
{
selected = bucket;
}
}
if (selected is null)
return 1.0m;
var clamped = Math.Clamp(selected.MultiplierBps, 0, 10000);
return clamped / 10000m;
}
/// <summary>
/// Computes containment-based reduction factor.
/// </summary>
private decimal ComputeContainmentReduction(UnknownRankInput input)
{
decimal reduction = 0m;
if (input.BlastRadius is { } blast)
{
if (blast.Dependents == 0)
reduction += _options.IsolatedReduction;
if (!blast.NetFacing)
reduction += _options.NotNetFacingReduction;
if (blast.Privilege is "user" or "none")
reduction += _options.NonRootReduction;
}
if (input.Containment is { } containment)
{
if (containment.Seccomp == "enforced")
reduction += _options.SeccompEnforcedReduction;
if (containment.FileSystem == "ro")
reduction += _options.FsReadOnlyReduction;
if (containment.NetworkPolicy == "isolated")
reduction += _options.NetworkIsolatedReduction;
}
return Math.Min(reduction, _options.MaxContainmentReduction);
}
/// <summary>
/// Assigns band based on score thresholds.
/// </summary>
private UnknownBand AssignBand(decimal score) => score switch
private UnknownBand AssignBand(decimal score)
{
>= 75m => UnknownBand.Hot, // Hot threshold (configurable)
>= 50m => UnknownBand.Warm, // Warm threshold
>= 25m => UnknownBand.Cold, // Cold threshold
_ => UnknownBand.Resolved // Below cold = resolved
};
if (score >= _options.HotThreshold)
return UnknownBand.Hot;
if (score >= _options.WarmThreshold)
return UnknownBand.Warm;
if (score >= _options.ColdThreshold)
return UnknownBand.Cold;
return UnknownBand.Resolved;
}
/// <summary>
/// Determines the primary reason code for unknown classification.
/// Returns the most actionable/resolvable reason.
/// </summary>
private static UnknownReasonCode DetermineReasonCode(UnknownRankInput input)
{
if (!input.IsAnalyzerSupported)
return UnknownReasonCode.AnalyzerLimit;
if (!input.HasReachabilityData)
return UnknownReasonCode.Reachability;
if (!input.HasPackageDigest)
return UnknownReasonCode.Identity;
if (!input.HasProvenanceAttestation)
return UnknownReasonCode.Provenance;
if (input.HasVexConflicts || !input.HasVexStatement)
return UnknownReasonCode.VexConflict;
if (!input.HasFeedCoverage)
return UnknownReasonCode.FeedGap;
if (!input.HasConfigVisibility)
return UnknownReasonCode.ConfigUnknown;
return UnknownReasonCode.Reachability;
}
}
/// <summary>
@@ -169,4 +319,50 @@ public sealed class UnknownRankerOptions
/// <summary>Score threshold for COLD band (default: 25).</summary>
public decimal ColdThreshold { get; set; } = 25m;
/// <summary>Enable time-based score decay.</summary>
public bool EnableDecay { get; set; } = true;
/// <summary>Decay buckets ordered by maximum age in days.</summary>
public IReadOnlyList<DecayBucket> DecayBuckets { get; set; } = DefaultDecayBuckets;
/// <summary>Default decay buckets using basis points.</summary>
public static IReadOnlyList<DecayBucket> DefaultDecayBuckets { get; } =
[
new DecayBucket(7, 10000),
new DecayBucket(30, 9000),
new DecayBucket(90, 7500),
new DecayBucket(180, 6000),
new DecayBucket(365, 4000),
new DecayBucket(int.MaxValue, 2000)
];
/// <summary>Enable containment-based reduction.</summary>
public bool EnableContainmentReduction { get; set; } = true;
/// <summary>Reduction for isolated package (dependents=0).</summary>
public decimal IsolatedReduction { get; set; } = 0.15m;
/// <summary>Reduction for not network-facing packages.</summary>
public decimal NotNetFacingReduction { get; set; } = 0.05m;
/// <summary>Reduction for non-root privilege.</summary>
public decimal NonRootReduction { get; set; } = 0.05m;
/// <summary>Reduction for enforced Seccomp.</summary>
public decimal SeccompEnforcedReduction { get; set; } = 0.10m;
/// <summary>Reduction for read-only filesystem.</summary>
public decimal FsReadOnlyReduction { get; set; } = 0.10m;
/// <summary>Reduction for isolated network policy.</summary>
public decimal NetworkIsolatedReduction { get; set; } = 0.05m;
/// <summary>Maximum reduction allowed from containment signals.</summary>
public decimal MaxContainmentReduction { get; set; } = 0.40m;
}
/// <summary>
/// Represents a decay bucket using basis points.
/// </summary>
public sealed record DecayBucket(int MaxAgeDays, int MultiplierBps);

View File

@@ -10,10 +10,12 @@
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="Npgsql" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Npgsql" Version="9.0.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Policy.Exceptions\StellaOps.Policy.Exceptions.csproj" />
<ProjectReference Include="..\StellaOps.Policy\StellaOps.Policy.csproj" />
</ItemGroup>

View File

@@ -0,0 +1,215 @@
using Microsoft.Extensions.Logging;
namespace StellaOps.Policy.Unknowns;
/// <summary>
/// Unknowns budget configuration for policy evaluation.
/// </summary>
public sealed record UnknownsBudgetConfig
{
/// <summary>
/// Maximum allowed critical severity unknowns.
/// </summary>
public int MaxCriticalUnknowns { get; init; } = 0;
/// <summary>
/// Maximum allowed high severity unknowns.
/// </summary>
public int MaxHighUnknowns { get; init; } = 5;
/// <summary>
/// Maximum allowed medium severity unknowns.
/// </summary>
public int MaxMediumUnknowns { get; init; } = 20;
/// <summary>
/// Maximum allowed low severity unknowns.
/// </summary>
public int MaxLowUnknowns { get; init; } = 50;
/// <summary>
/// Maximum total unknowns across all severities.
/// </summary>
public int? MaxTotalUnknowns { get; init; }
/// <summary>
/// Action to take when budget is exceeded.
/// </summary>
public UnknownsBudgetAction Action { get; init; } = UnknownsBudgetAction.Block;
/// <summary>
/// Environment-specific overrides.
/// </summary>
public Dictionary<string, UnknownsBudgetConfig>? EnvironmentOverrides { get; init; }
}
/// <summary>
/// Action to take when unknowns budget is exceeded.
/// </summary>
public enum UnknownsBudgetAction
{
/// <summary>
/// Block deployment/approval.
/// </summary>
Block,
/// <summary>
/// Warn but allow deployment.
/// </summary>
Warn,
/// <summary>
/// Log only, no enforcement.
/// </summary>
Log
}
/// <summary>
/// Counts of unknowns by severity.
/// </summary>
public sealed record UnknownsCounts
{
public int Critical { get; init; }
public int High { get; init; }
public int Medium { get; init; }
public int Low { get; init; }
public int Total => Critical + High + Medium + Low;
}
/// <summary>
/// Result of unknowns budget enforcement.
/// </summary>
public sealed record UnknownsBudgetResult
{
public required bool WithinBudget { get; init; }
public required UnknownsCounts Counts { get; init; }
public required UnknownsBudgetConfig Budget { get; init; }
public required UnknownsBudgetAction Action { get; init; }
public IReadOnlyList<string>? Violations { get; init; }
}
/// <summary>
/// Enforces unknowns budget for policy decisions.
/// </summary>
public sealed class UnknownsBudgetEnforcer
{
private readonly ILogger<UnknownsBudgetEnforcer> _logger;
public UnknownsBudgetEnforcer(ILogger<UnknownsBudgetEnforcer> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Evaluate unknowns counts against budget.
/// </summary>
public UnknownsBudgetResult Evaluate(
UnknownsCounts counts,
UnknownsBudgetConfig budget,
string? environment = null)
{
ArgumentNullException.ThrowIfNull(counts);
ArgumentNullException.ThrowIfNull(budget);
var effectiveBudget = GetEffectiveBudget(budget, environment);
var violations = new List<string>();
if (counts.Critical > effectiveBudget.MaxCriticalUnknowns)
{
violations.Add($"Critical unknowns ({counts.Critical}) exceeds budget ({effectiveBudget.MaxCriticalUnknowns})");
}
if (counts.High > effectiveBudget.MaxHighUnknowns)
{
violations.Add($"High unknowns ({counts.High}) exceeds budget ({effectiveBudget.MaxHighUnknowns})");
}
if (counts.Medium > effectiveBudget.MaxMediumUnknowns)
{
violations.Add($"Medium unknowns ({counts.Medium}) exceeds budget ({effectiveBudget.MaxMediumUnknowns})");
}
if (counts.Low > effectiveBudget.MaxLowUnknowns)
{
violations.Add($"Low unknowns ({counts.Low}) exceeds budget ({effectiveBudget.MaxLowUnknowns})");
}
if (effectiveBudget.MaxTotalUnknowns.HasValue &&
counts.Total > effectiveBudget.MaxTotalUnknowns.Value)
{
violations.Add($"Total unknowns ({counts.Total}) exceeds budget ({effectiveBudget.MaxTotalUnknowns.Value})");
}
var withinBudget = violations.Count == 0;
if (!withinBudget)
{
LogViolations(violations, effectiveBudget.Action, environment);
}
return new UnknownsBudgetResult
{
WithinBudget = withinBudget,
Counts = counts,
Budget = effectiveBudget,
Action = effectiveBudget.Action,
Violations = violations
};
}
/// <summary>
/// Check if deployment should be blocked based on budget result.
/// </summary>
public bool ShouldBlock(UnknownsBudgetResult result)
{
ArgumentNullException.ThrowIfNull(result);
return !result.WithinBudget && result.Action == UnknownsBudgetAction.Block;
}
private static UnknownsBudgetConfig GetEffectiveBudget(
UnknownsBudgetConfig budget,
string? environment)
{
if (string.IsNullOrWhiteSpace(environment) ||
budget.EnvironmentOverrides is null ||
!budget.EnvironmentOverrides.TryGetValue(environment, out var override_))
{
return budget;
}
return override_;
}
private void LogViolations(
List<string> violations,
UnknownsBudgetAction action,
string? environment)
{
var envStr = string.IsNullOrWhiteSpace(environment) ? "" : $" (env: {environment})";
switch (action)
{
case UnknownsBudgetAction.Block:
_logger.LogError(
"Unknowns budget exceeded{Env}. Blocking deployment. Violations: {Violations}",
envStr,
string.Join("; ", violations));
break;
case UnknownsBudgetAction.Warn:
_logger.LogWarning(
"Unknowns budget exceeded{Env}. Allowing deployment with warning. Violations: {Violations}",
envStr,
string.Join("; ", violations));
break;
case UnknownsBudgetAction.Log:
_logger.LogInformation(
"Unknowns budget exceeded{Env}. Logging only. Violations: {Violations}",
envStr,
string.Join("; ", violations));
break;
}
}
}

View File

@@ -0,0 +1,50 @@
using System;
namespace StellaOps.Policy.Confidence.Configuration;
/// <summary>
/// Configuration for confidence factor weights.
/// </summary>
public sealed class ConfidenceWeightOptions
{
public const string SectionName = "ConfidenceWeights";
/// <summary>
/// Weight for reachability factor (default: 0.30).
/// </summary>
public decimal Reachability { get; set; } = 0.30m;
/// <summary>
/// Weight for runtime corroboration (default: 0.20).
/// </summary>
public decimal Runtime { get; set; } = 0.20m;
/// <summary>
/// Weight for VEX statements (default: 0.25).
/// </summary>
public decimal Vex { get; set; } = 0.25m;
/// <summary>
/// Weight for provenance quality (default: 0.15).
/// </summary>
public decimal Provenance { get; set; } = 0.15m;
/// <summary>
/// Weight for policy match (default: 0.10).
/// </summary>
public decimal Policy { get; set; } = 0.10m;
/// <summary>
/// Minimum confidence for not_affected verdict.
/// </summary>
public decimal MinimumForNotAffected { get; set; } = 0.70m;
/// <summary>
/// Validates weights sum to 1.0.
/// </summary>
public bool Validate()
{
var sum = Reachability + Runtime + Vex + Provenance + Policy;
return Math.Abs(sum - 1.0m) < 0.001m;
}
}

View File

@@ -0,0 +1,97 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Policy.Confidence.Models;
public sealed record ReachabilityEvidence
{
public required ReachabilityState State { get; init; }
public required decimal AnalysisConfidence { get; init; }
public IReadOnlyList<string> GraphDigests { get; init; } = [];
}
public enum ReachabilityState
{
Unknown,
StaticReachable,
StaticUnreachable,
ConfirmedReachable,
ConfirmedUnreachable
}
public sealed record RuntimeEvidence
{
public required RuntimePosture Posture { get; init; }
public required int ObservationCount { get; init; }
public required DateTimeOffset LastObserved { get; init; }
public IReadOnlyList<string> SessionDigests { get; init; } = [];
public bool HasObservations => ObservationCount > 0;
public bool ObservedWithinHours(int hours, DateTimeOffset? referenceTime = null)
{
var now = referenceTime ?? DateTimeOffset.UtcNow;
if (hours <= 0 || now == DateTimeOffset.MinValue)
{
return false;
}
if (now < DateTimeOffset.MinValue.AddHours(hours))
{
return false;
}
return LastObserved > now.AddHours(-hours);
}
}
public enum RuntimePosture
{
Unknown,
Supports,
Contradicts
}
public sealed record VexEvidence
{
public required IReadOnlyList<VexStatement> Statements { get; init; }
}
public sealed record VexStatement
{
public required VexStatus Status { get; init; }
public required string Issuer { get; init; }
public required decimal TrustScore { get; init; }
public required DateTimeOffset Timestamp { get; init; }
public required string StatementDigest { get; init; }
}
public enum VexStatus
{
Affected,
NotAffected,
Fixed,
UnderInvestigation
}
public sealed record ProvenanceEvidence
{
public required ProvenanceLevel Level { get; init; }
public required decimal SbomCompleteness { get; init; }
public IReadOnlyList<string> AttestationDigests { get; init; } = [];
}
public enum ProvenanceLevel
{
Unsigned,
Signed,
SlsaLevel1,
SlsaLevel2,
SlsaLevel3
}
public sealed record PolicyEvidence
{
public required string RuleName { get; init; }
public required decimal MatchStrength { get; init; }
public required string EvaluationDigest { get; init; }
}

View File

@@ -0,0 +1,116 @@
using System.Collections.Generic;
namespace StellaOps.Policy.Confidence.Models;
/// <summary>
/// Unified confidence score aggregating all evidence types.
/// Bounded between 0.0 (no confidence) and 1.0 (full confidence).
/// </summary>
public sealed record ConfidenceScore
{
/// <summary>
/// Final aggregated confidence (0.0 - 1.0).
/// </summary>
public required decimal Value { get; init; }
/// <summary>
/// Confidence tier for quick categorization.
/// </summary>
public ConfidenceTier Tier => Value switch
{
>= 0.9m => ConfidenceTier.VeryHigh,
>= 0.7m => ConfidenceTier.High,
>= 0.5m => ConfidenceTier.Medium,
>= 0.3m => ConfidenceTier.Low,
_ => ConfidenceTier.VeryLow
};
/// <summary>
/// Breakdown of contributing factors.
/// </summary>
public required IReadOnlyList<ConfidenceFactor> Factors { get; init; }
/// <summary>
/// Human-readable explanation of the score.
/// </summary>
public required string Explanation { get; init; }
/// <summary>
/// What would improve this confidence score.
/// </summary>
public IReadOnlyList<ConfidenceImprovement> Improvements { get; init; } = [];
}
/// <summary>
/// A single factor contributing to confidence.
/// </summary>
public sealed record ConfidenceFactor
{
/// <summary>
/// Factor type (reachability, runtime, vex, provenance, policy).
/// </summary>
public required ConfidenceFactorType Type { get; init; }
/// <summary>
/// Weight of this factor in aggregation (0.0 - 1.0).
/// </summary>
public required decimal Weight { get; init; }
/// <summary>
/// Raw value before weighting (0.0 - 1.0).
/// </summary>
public required decimal RawValue { get; init; }
/// <summary>
/// Weighted contribution to final score.
/// </summary>
public decimal Contribution => Weight * RawValue;
/// <summary>
/// Human-readable reason for this value.
/// </summary>
public required string Reason { get; init; }
/// <summary>
/// Evidence digests supporting this factor.
/// </summary>
public IReadOnlyList<string> EvidenceDigests { get; init; } = [];
}
public enum ConfidenceFactorType
{
/// <summary>Call graph reachability analysis.</summary>
Reachability,
/// <summary>Runtime corroboration (eBPF, dyld, ETW).</summary>
Runtime,
/// <summary>VEX statement from vendor/distro.</summary>
Vex,
/// <summary>Build provenance and SBOM quality.</summary>
Provenance,
/// <summary>Policy rule match strength.</summary>
Policy,
/// <summary>Advisory freshness and source quality.</summary>
Advisory
}
public enum ConfidenceTier
{
VeryLow,
Low,
Medium,
High,
VeryHigh
}
/// <summary>
/// Actionable improvement to increase confidence.
/// </summary>
public sealed record ConfidenceImprovement(
ConfidenceFactorType Factor,
string Action,
decimal PotentialGain);

View File

@@ -0,0 +1,363 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Confidence.Configuration;
using StellaOps.Policy.Confidence.Models;
namespace StellaOps.Policy.Confidence.Services;
public interface IConfidenceCalculator
{
ConfidenceScore Calculate(ConfidenceInput input);
}
public sealed class ConfidenceCalculator : IConfidenceCalculator
{
private readonly IOptionsMonitor<ConfidenceWeightOptions> _options;
public ConfidenceCalculator(IOptionsMonitor<ConfidenceWeightOptions> options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public ConfidenceScore Calculate(ConfidenceInput input)
{
ArgumentNullException.ThrowIfNull(input);
var weights = NormalizeWeights(_options.CurrentValue);
var factors = new List<ConfidenceFactor>(capacity: 5)
{
CalculateReachabilityFactor(input.Reachability, weights.Reachability),
CalculateRuntimeFactor(input.Runtime, weights.Runtime, input.EvaluationTimestamp),
CalculateVexFactor(input.Vex, weights.Vex),
CalculateProvenanceFactor(input.Provenance, weights.Provenance),
CalculatePolicyFactor(input.Policy, weights.Policy)
};
var totalValue = factors.Sum(f => f.Contribution);
var clampedValue = Clamp01(totalValue);
var explanation = GenerateExplanation(factors, clampedValue);
var improvements = GenerateImprovements(factors, weights, input.Status, clampedValue);
return new ConfidenceScore
{
Value = clampedValue,
Factors = factors,
Explanation = explanation,
Improvements = improvements
};
}
private static ConfidenceFactor CalculateReachabilityFactor(ReachabilityEvidence? evidence, decimal weight)
{
if (evidence is null)
{
return new ConfidenceFactor
{
Type = ConfidenceFactorType.Reachability,
Weight = weight,
RawValue = 0.5m,
Reason = "No reachability analysis performed",
EvidenceDigests = []
};
}
var baseValue = evidence.State switch
{
ReachabilityState.ConfirmedUnreachable => 1.0m,
ReachabilityState.StaticUnreachable => 0.85m,
ReachabilityState.Unknown => 0.5m,
ReachabilityState.StaticReachable => 0.3m,
ReachabilityState.ConfirmedReachable => 0.1m,
_ => 0.5m
};
var rawValue = Clamp01(baseValue * Clamp01(evidence.AnalysisConfidence));
return new ConfidenceFactor
{
Type = ConfidenceFactorType.Reachability,
Weight = weight,
RawValue = rawValue,
Reason = $"Reachability: {evidence.State} (analysis confidence: {Clamp01(evidence.AnalysisConfidence):P0})",
EvidenceDigests = evidence.GraphDigests.ToList()
};
}
private static ConfidenceFactor CalculateRuntimeFactor(
RuntimeEvidence? evidence,
decimal weight,
DateTimeOffset? evaluationTimestamp)
{
if (evidence is null || !evidence.HasObservations)
{
return new ConfidenceFactor
{
Type = ConfidenceFactorType.Runtime,
Weight = weight,
RawValue = 0.5m,
Reason = "No runtime observations available",
EvidenceDigests = []
};
}
var rawValue = evidence.Posture switch
{
RuntimePosture.Supports => 0.9m,
RuntimePosture.Contradicts => 0.2m,
RuntimePosture.Unknown => 0.5m,
_ => 0.5m
};
var recencyBonus = evidence.ObservedWithinHours(24, evaluationTimestamp) ? 0.1m : 0m;
rawValue = Clamp01(rawValue + recencyBonus);
return new ConfidenceFactor
{
Type = ConfidenceFactorType.Runtime,
Weight = weight,
RawValue = rawValue,
Reason = $"Runtime {evidence.Posture.ToString().ToLowerInvariant()}: {evidence.ObservationCount} observations",
EvidenceDigests = evidence.SessionDigests.ToList()
};
}
private static ConfidenceFactor CalculateVexFactor(VexEvidence? evidence, decimal weight)
{
if (evidence is null || evidence.Statements.Count == 0)
{
return new ConfidenceFactor
{
Type = ConfidenceFactorType.Vex,
Weight = weight,
RawValue = 0.5m,
Reason = "No VEX statements available",
EvidenceDigests = []
};
}
var best = evidence.Statements
.OrderByDescending(s => s.TrustScore)
.ThenByDescending(s => s.Timestamp)
.ThenBy(s => s.StatementDigest, StringComparer.Ordinal)
.First();
var rawValue = best.Status switch
{
VexStatus.NotAffected => Clamp01(best.TrustScore),
VexStatus.Fixed => Clamp01(best.TrustScore * 0.9m),
VexStatus.UnderInvestigation => 0.4m,
VexStatus.Affected => 0.1m,
_ => 0.5m
};
return new ConfidenceFactor
{
Type = ConfidenceFactorType.Vex,
Weight = weight,
RawValue = rawValue,
Reason = $"VEX {best.Status} from {best.Issuer} (trust: {Clamp01(best.TrustScore):P0})",
EvidenceDigests = [best.StatementDigest]
};
}
private static ConfidenceFactor CalculateProvenanceFactor(ProvenanceEvidence? evidence, decimal weight)
{
if (evidence is null)
{
return new ConfidenceFactor
{
Type = ConfidenceFactorType.Provenance,
Weight = weight,
RawValue = 0.3m,
Reason = "No provenance information",
EvidenceDigests = []
};
}
var rawValue = evidence.Level switch
{
ProvenanceLevel.SlsaLevel3 => 1.0m,
ProvenanceLevel.SlsaLevel2 => 0.85m,
ProvenanceLevel.SlsaLevel1 => 0.7m,
ProvenanceLevel.Signed => 0.6m,
ProvenanceLevel.Unsigned => 0.3m,
_ => 0.3m
};
if (Clamp01(evidence.SbomCompleteness) >= 0.9m)
{
rawValue = Clamp01(rawValue + 0.1m);
}
return new ConfidenceFactor
{
Type = ConfidenceFactorType.Provenance,
Weight = weight,
RawValue = rawValue,
Reason = $"Provenance: {evidence.Level}, SBOM completeness: {Clamp01(evidence.SbomCompleteness):P0}",
EvidenceDigests = evidence.AttestationDigests.ToList()
};
}
private static ConfidenceFactor CalculatePolicyFactor(PolicyEvidence? evidence, decimal weight)
{
if (evidence is null)
{
return new ConfidenceFactor
{
Type = ConfidenceFactorType.Policy,
Weight = weight,
RawValue = 0.5m,
Reason = "No policy evaluation",
EvidenceDigests = []
};
}
var rawValue = Clamp01(evidence.MatchStrength);
return new ConfidenceFactor
{
Type = ConfidenceFactorType.Policy,
Weight = weight,
RawValue = rawValue,
Reason = $"Policy rule '{evidence.RuleName}' matched (strength: {rawValue:P0})",
EvidenceDigests = [evidence.EvaluationDigest]
};
}
private static string GenerateExplanation(IReadOnlyList<ConfidenceFactor> factors, decimal totalValue)
{
var tier = totalValue switch
{
>= 0.9m => "very high",
>= 0.7m => "high",
>= 0.5m => "medium",
>= 0.3m => "low",
_ => "very low"
};
var topFactors = factors
.OrderByDescending(f => f.Contribution)
.ThenBy(f => f.Type)
.Take(2)
.Select(f => f.Type.ToString().ToLowerInvariant())
.ToArray();
if (topFactors.Length == 0)
{
return $"Confidence is {tier} ({totalValue:P0}).";
}
return $"Confidence is {tier} ({totalValue:P0}), primarily driven by {string.Join(" and ", topFactors)}.";
}
private static IReadOnlyList<ConfidenceImprovement> GenerateImprovements(
IReadOnlyList<ConfidenceFactor> factors,
ConfidenceWeightOptions weights,
string? status,
decimal totalValue)
{
var improvements = new List<ConfidenceImprovement>();
foreach (var factor in factors.Where(f => f.RawValue < 0.7m))
{
var (action, potentialGain) = factor.Type switch
{
ConfidenceFactorType.Reachability =>
("Run deeper reachability analysis", factor.Weight * 0.3m),
ConfidenceFactorType.Runtime =>
("Deploy runtime sensor and collect observations", factor.Weight * 0.4m),
ConfidenceFactorType.Vex =>
("Obtain VEX statement from vendor", factor.Weight * 0.4m),
ConfidenceFactorType.Provenance =>
("Add SLSA provenance attestation", factor.Weight * 0.3m),
ConfidenceFactorType.Policy =>
("Review and refine policy rules", factor.Weight * 0.2m),
_ => ("Gather additional evidence", 0.1m)
};
improvements.Add(new ConfidenceImprovement(factor.Type, action, potentialGain));
}
if (IsNotAffected(status) && weights.MinimumForNotAffected > 0m && totalValue < weights.MinimumForNotAffected)
{
improvements.Add(new ConfidenceImprovement(
ConfidenceFactorType.Policy,
$"Increase evidence to reach {weights.MinimumForNotAffected:P0} confidence for not_affected",
Clamp01(weights.MinimumForNotAffected - totalValue)));
}
return improvements
.OrderByDescending(i => i.PotentialGain)
.ThenBy(i => i.Factor)
.Take(3)
.ToList();
}
private static bool IsNotAffected(string? status)
{
return status != null
&& status.Equals("not_affected", StringComparison.OrdinalIgnoreCase);
}
private static ConfidenceWeightOptions NormalizeWeights(ConfidenceWeightOptions input)
{
if (input is null)
{
return new ConfidenceWeightOptions();
}
var sum = input.Reachability + input.Runtime + input.Vex + input.Provenance + input.Policy;
if (sum <= 0m)
{
return new ConfidenceWeightOptions();
}
if (Math.Abs(sum - 1.0m) < 0.001m)
{
return input;
}
return new ConfidenceWeightOptions
{
Reachability = input.Reachability / sum,
Runtime = input.Runtime / sum,
Vex = input.Vex / sum,
Provenance = input.Provenance / sum,
Policy = input.Policy / sum,
MinimumForNotAffected = input.MinimumForNotAffected
};
}
private static decimal Clamp01(decimal value)
{
if (value <= 0m)
{
return 0m;
}
if (value >= 1m)
{
return 1m;
}
return value;
}
}
/// <summary>
/// Input container for confidence calculation.
/// </summary>
public sealed record ConfidenceInput
{
public ReachabilityEvidence? Reachability { get; init; }
public RuntimeEvidence? Runtime { get; init; }
public VexEvidence? Vex { get; init; }
public ProvenanceEvidence? Provenance { get; init; }
public PolicyEvidence? Policy { get; init; }
public string? Status { get; init; }
public DateTimeOffset? EvaluationTimestamp { get; init; }
}

View File

@@ -0,0 +1,55 @@
namespace StellaOps.Policy.Counterfactuals;
/// <summary>
/// Result of counterfactual analysis - what would flip the verdict.
/// </summary>
public sealed record CounterfactualResult
{
public required Guid FindingId { get; init; }
public required string CurrentVerdict { get; init; }
public required string TargetVerdict { get; init; }
public required IReadOnlyList<CounterfactualPath> Paths { get; init; }
public bool HasPaths => Paths.Count > 0;
public CounterfactualPath? RecommendedPath =>
Paths.OrderBy(path => path.EstimatedEffort).FirstOrDefault();
}
/// <summary>
/// A single path that would flip the verdict.
/// </summary>
public sealed record CounterfactualPath
{
public required CounterfactualType Type { get; init; }
public required string Description { get; init; }
public required IReadOnlyList<CounterfactualCondition> Conditions { get; init; }
public int EstimatedEffort { get; init; }
public required string Actor { get; init; }
public string? ActionUri { get; init; }
}
/// <summary>
/// A specific condition in a counterfactual path.
/// </summary>
public sealed record CounterfactualCondition
{
public required string Field { get; init; }
public required string CurrentValue { get; init; }
public required string RequiredValue { get; init; }
public bool IsMet { get; init; }
}
/// <summary>
/// Type of counterfactual change.
/// </summary>
public enum CounterfactualType
{
VexStatus,
Exception,
Reachability,
VersionUpgrade,
PolicyChange,
ComponentRemoval,
CompensatingControl,
}

View File

@@ -0,0 +1,202 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Policy.Freshness;
public interface IEvidenceTtlEnforcer
{
/// <summary>
/// Checks freshness of all evidence in a bundle.
/// </summary>
EvidenceFreshnessResult CheckFreshness(EvidenceBundle bundle, DateTimeOffset asOf);
/// <summary>
/// Gets TTL for a specific evidence type.
/// </summary>
TimeSpan GetTtl(EvidenceType type);
/// <summary>
/// Computes expiration time for evidence.
/// </summary>
DateTimeOffset ComputeExpiration(EvidenceType type, DateTimeOffset createdAt);
}
public sealed class EvidenceTtlEnforcer : IEvidenceTtlEnforcer
{
private readonly EvidenceTtlOptions _options;
private readonly ILogger<EvidenceTtlEnforcer> _logger;
public EvidenceTtlEnforcer(
IOptions<EvidenceTtlOptions> options,
ILogger<EvidenceTtlEnforcer> logger)
{
_options = options.Value;
_logger = logger;
}
public EvidenceFreshnessResult CheckFreshness(EvidenceBundle bundle, DateTimeOffset asOf)
{
var checks = new List<EvidenceFreshnessCheck>();
// Check each evidence type
if (bundle.Reachability is not null)
{
checks.Add(CheckType(EvidenceType.Reachability, bundle.Reachability.ComputedAt, asOf));
}
if (bundle.CallStack is not null)
{
checks.Add(CheckType(EvidenceType.CallStack, bundle.CallStack.CapturedAt, asOf));
}
if (bundle.VexStatus is not null)
{
checks.Add(CheckType(EvidenceType.Vex, bundle.VexStatus.Timestamp, asOf));
}
if (bundle.Provenance is not null)
{
checks.Add(CheckType(EvidenceType.Sbom, bundle.Provenance.BuildTime, asOf));
}
if (bundle.Boundary is not null)
{
checks.Add(CheckType(EvidenceType.Boundary, bundle.Boundary.ObservedAt, asOf));
}
// Determine overall status
var anyStale = checks.Any(c => c.Status == FreshnessStatus.Stale);
var anyWarning = checks.Any(c => c.Status == FreshnessStatus.Warning);
return new EvidenceFreshnessResult
{
OverallStatus = anyStale ? FreshnessStatus.Stale
: anyWarning ? FreshnessStatus.Warning
: FreshnessStatus.Fresh,
Checks = checks,
RecommendedAction = anyStale ? _options.StaleAction : StaleEvidenceAction.Warn,
CheckedAt = asOf
};
}
private EvidenceFreshnessCheck CheckType(
EvidenceType type,
DateTimeOffset createdAt,
DateTimeOffset asOf)
{
var ttl = GetTtl(type);
var expiresAt = createdAt + ttl;
var remaining = expiresAt - asOf;
var warningThreshold = ttl * _options.WarningThresholdPercent;
FreshnessStatus status;
if (remaining <= TimeSpan.Zero)
{
status = FreshnessStatus.Stale;
}
else if (remaining <= warningThreshold)
{
status = FreshnessStatus.Warning;
}
else
{
status = FreshnessStatus.Fresh;
}
return new EvidenceFreshnessCheck
{
Type = type,
CreatedAt = createdAt,
ExpiresAt = expiresAt,
Ttl = ttl,
Remaining = remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero,
Status = status,
Message = status switch
{
FreshnessStatus.Stale => $"{type} evidence expired {-remaining.TotalHours:F0}h ago",
FreshnessStatus.Warning => $"{type} evidence expires in {remaining.TotalHours:F0}h",
_ => $"{type} evidence fresh ({remaining.TotalDays:F0}d remaining)"
}
};
}
public TimeSpan GetTtl(EvidenceType type)
{
return type switch
{
EvidenceType.Sbom => _options.SbomTtl,
EvidenceType.Reachability => _options.ReachabilityTtl,
EvidenceType.Boundary => _options.BoundaryTtl,
EvidenceType.Vex => _options.VexTtl,
EvidenceType.PolicyDecision => _options.PolicyDecisionTtl,
EvidenceType.HumanApproval => _options.HumanApprovalTtl,
EvidenceType.CallStack => _options.ReachabilityTtl,
_ => TimeSpan.FromDays(7)
};
}
public DateTimeOffset ComputeExpiration(EvidenceType type, DateTimeOffset createdAt)
{
return createdAt + GetTtl(type);
}
}
public sealed record EvidenceFreshnessResult
{
public required FreshnessStatus OverallStatus { get; init; }
public required IReadOnlyList<EvidenceFreshnessCheck> Checks { get; init; }
public required StaleEvidenceAction RecommendedAction { get; init; }
public required DateTimeOffset CheckedAt { get; init; }
public bool IsAcceptable => OverallStatus != FreshnessStatus.Stale;
public bool HasWarnings => OverallStatus == FreshnessStatus.Warning;
}
public sealed record EvidenceFreshnessCheck
{
public required EvidenceType Type { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
public required TimeSpan Ttl { get; init; }
public required TimeSpan Remaining { get; init; }
public required FreshnessStatus Status { get; init; }
public required string Message { get; init; }
}
/// <summary>
/// Evidence bundle placeholder - reference to actual evidence models.
/// In practice, this would be replaced with actual evidence bundle from Scanner/Attestor modules.
/// </summary>
public sealed record EvidenceBundle
{
public ReachabilityEvidence? Reachability { get; init; }
public CallStackEvidence? CallStack { get; init; }
public VexEvidence? VexStatus { get; init; }
public ProvenanceEvidence? Provenance { get; init; }
public BoundaryEvidence? Boundary { get; init; }
}
public sealed record ReachabilityEvidence
{
public required DateTimeOffset ComputedAt { get; init; }
}
public sealed record CallStackEvidence
{
public required DateTimeOffset CapturedAt { get; init; }
}
public sealed record VexEvidence
{
public required DateTimeOffset Timestamp { get; init; }
}
public sealed record ProvenanceEvidence
{
public required DateTimeOffset BuildTime { get; init; }
}
public sealed record BoundaryEvidence
{
public required DateTimeOffset ObservedAt { get; init; }
}

View File

@@ -0,0 +1,99 @@
namespace StellaOps.Policy.Freshness;
/// <summary>
/// TTL configuration per evidence type.
/// </summary>
public sealed class EvidenceTtlOptions
{
/// <summary>
/// SBOM evidence TTL. Long because digest is immutable.
/// Default: 30 days.
/// </summary>
public TimeSpan SbomTtl { get; set; } = TimeSpan.FromDays(30);
/// <summary>
/// Boundary evidence TTL. Short because environment changes.
/// Default: 72 hours.
/// </summary>
public TimeSpan BoundaryTtl { get; set; } = TimeSpan.FromHours(72);
/// <summary>
/// Reachability evidence TTL. Medium based on code churn.
/// Default: 7 days.
/// </summary>
public TimeSpan ReachabilityTtl { get; set; } = TimeSpan.FromDays(7);
/// <summary>
/// VEX evidence TTL. Renew on boundary/reachability change.
/// Default: 14 days.
/// </summary>
public TimeSpan VexTtl { get; set; } = TimeSpan.FromDays(14);
/// <summary>
/// Policy decision TTL.
/// Default: 24 hours.
/// </summary>
public TimeSpan PolicyDecisionTtl { get; set; } = TimeSpan.FromHours(24);
/// <summary>
/// Human approval TTL.
/// Default: 30 days.
/// </summary>
public TimeSpan HumanApprovalTtl { get; set; } = TimeSpan.FromDays(30);
/// <summary>
/// Warning threshold as percentage of TTL remaining.
/// Default: 20% (warn when 80% of TTL elapsed).
/// </summary>
public double WarningThresholdPercent { get; set; } = 0.20;
/// <summary>
/// Action when evidence is stale.
/// </summary>
public StaleEvidenceAction StaleAction { get; set; } = StaleEvidenceAction.Warn;
}
/// <summary>
/// Action to take when evidence is stale.
/// </summary>
public enum StaleEvidenceAction
{
/// <summary>
/// Allow but log warning.
/// </summary>
Warn,
/// <summary>
/// Block the decision.
/// </summary>
Block,
/// <summary>
/// Degrade confidence score.
/// </summary>
DegradeConfidence
}
/// <summary>
/// Evidence type for TTL enforcement.
/// </summary>
public enum EvidenceType
{
Sbom,
Reachability,
Boundary,
Vex,
PolicyDecision,
HumanApproval,
CallStack
}
/// <summary>
/// Freshness status for evidence.
/// </summary>
public enum FreshnessStatus
{
Fresh,
Warning,
Stale
}

View File

@@ -0,0 +1,99 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Freshness;
using StellaOps.Policy.TrustLattice;
namespace StellaOps.Policy.Gates;
/// <summary>
/// Policy gate that enforces evidence TTL requirements.
/// Blocks, warns, or degrades confidence based on evidence staleness.
/// </summary>
public sealed class EvidenceFreshnessGate : IPolicyGate
{
private readonly IEvidenceTtlEnforcer _ttlEnforcer;
private readonly ILogger<EvidenceFreshnessGate> _logger;
public EvidenceFreshnessGate(
IEvidenceTtlEnforcer ttlEnforcer,
ILogger<EvidenceFreshnessGate> logger)
{
_ttlEnforcer = ttlEnforcer;
_logger = logger;
}
public Task<GateResult> EvaluateAsync(
MergeResult mergeResult,
PolicyGateContext context,
CancellationToken ct = default)
{
// Build evidence bundle from context
var evidenceBundle = BuildEvidenceBundleFromContext(context);
var freshnessResult = _ttlEnforcer.CheckFreshness(evidenceBundle, DateTimeOffset.UtcNow);
var details = ImmutableDictionary.CreateBuilder<string, object>();
details.Add("overall_status", freshnessResult.OverallStatus.ToString());
details.Add("recommended_action", freshnessResult.RecommendedAction.ToString());
details.Add("checked_at", freshnessResult.CheckedAt);
details.Add("checks", freshnessResult.Checks.Select(c => new
{
type = c.Type.ToString(),
status = c.Status.ToString(),
expires_at = c.ExpiresAt,
remaining_hours = c.Remaining.TotalHours,
message = c.Message
}).ToList());
// Determine pass/fail based on recommended action
var passed = freshnessResult.OverallStatus switch
{
FreshnessStatus.Fresh => true,
FreshnessStatus.Warning => true, // Warnings don't block by default
FreshnessStatus.Stale when freshnessResult.RecommendedAction == StaleEvidenceAction.Warn => true,
FreshnessStatus.Stale when freshnessResult.RecommendedAction == StaleEvidenceAction.DegradeConfidence => true,
FreshnessStatus.Stale when freshnessResult.RecommendedAction == StaleEvidenceAction.Block => false,
_ => true
};
var reason = passed
? freshnessResult.HasWarnings
? $"Evidence approaching expiration: {string.Join(", ", freshnessResult.Checks.Where(c => c.Status == FreshnessStatus.Warning).Select(c => c.Type))}"
: null
: $"Stale evidence detected: {string.Join(", ", freshnessResult.Checks.Where(c => c.Status == FreshnessStatus.Stale).Select(c => c.Type))}";
if (!passed)
{
_logger.LogWarning(
"Evidence freshness gate failed: {Reason}. Stale evidence: {StaleTypes}",
reason,
string.Join(", ", freshnessResult.Checks.Where(c => c.Status == FreshnessStatus.Stale).Select(c => c.Type)));
}
else if (freshnessResult.HasWarnings)
{
_logger.LogInformation(
"Evidence freshness warning: {WarningTypes}",
string.Join(", ", freshnessResult.Checks.Where(c => c.Status == FreshnessStatus.Warning).Select(c => c.Type)));
}
return Task.FromResult(new GateResult
{
GateName = "EvidenceFreshness",
Passed = passed,
Reason = reason,
Details = details.ToImmutable()
});
}
private static EvidenceBundle BuildEvidenceBundleFromContext(PolicyGateContext context)
{
// In a real implementation, this would extract evidence metadata from the context
// For now, return a minimal bundle
// This should be extended when evidence metadata is added to PolicyGateContext
return new EvidenceBundle
{
// Evidence would be populated from context metadata
// This is a placeholder until PolicyGateContext is extended with evidence timestamps
};
}
}

View File

@@ -0,0 +1,87 @@
using System.Collections.Immutable;
using StellaOps.Policy.TrustLattice;
using VexStatus = StellaOps.Policy.Confidence.Models.VexStatus;
namespace StellaOps.Policy.Gates;
public sealed record MinimumConfidenceGateOptions
{
public bool Enabled { get; init; } = true;
public IReadOnlyDictionary<string, double> Thresholds { get; init; } = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
{
["production"] = 0.75,
["staging"] = 0.60,
["development"] = 0.40,
};
public IReadOnlyCollection<VexStatus> ApplyToStatuses { get; init; } = new[]
{
VexStatus.NotAffected,
VexStatus.Fixed,
};
}
public sealed class MinimumConfidenceGate : IPolicyGate
{
private readonly MinimumConfidenceGateOptions _options;
public MinimumConfidenceGate(MinimumConfidenceGateOptions? options = null)
{
_options = options ?? new MinimumConfidenceGateOptions();
}
public Task<GateResult> EvaluateAsync(MergeResult mergeResult, PolicyGateContext context, CancellationToken ct = default)
{
if (!_options.Enabled)
{
return Task.FromResult(Pass("disabled"));
}
if (mergeResult.Status == VexStatus.Affected)
{
return Task.FromResult(Pass("affected_bypass"));
}
if (!_options.ApplyToStatuses.Contains(mergeResult.Status))
{
return Task.FromResult(Pass("status_not_applicable"));
}
var threshold = GetThreshold(context.Environment);
var passed = mergeResult.Confidence >= threshold;
var details = ImmutableDictionary<string, object>.Empty
.Add("threshold", threshold)
.Add("confidence", mergeResult.Confidence)
.Add("environment", context.Environment);
return Task.FromResult(new GateResult
{
GateName = nameof(MinimumConfidenceGate),
Passed = passed,
Reason = passed ? null : "confidence_below_threshold",
Details = details,
});
}
private double GetThreshold(string environment)
{
if (_options.Thresholds.TryGetValue(environment, out var threshold))
{
return threshold;
}
if (_options.Thresholds.TryGetValue("production", out var prod))
{
return prod;
}
return 0.0;
}
private static GateResult Pass(string reason) => new()
{
GateName = nameof(MinimumConfidenceGate),
Passed = true,
Reason = reason,
Details = ImmutableDictionary<string, object>.Empty,
};
}

View File

@@ -0,0 +1,53 @@
using System.Collections.Immutable;
using StellaOps.Policy.TrustLattice;
namespace StellaOps.Policy.Gates;
public sealed record PolicyGateContext
{
public string Environment { get; init; } = "production";
public int UnknownCount { get; init; }
public IReadOnlyList<double> UnknownClaimScores { get; init; } = Array.Empty<double>();
public IReadOnlyDictionary<string, double> SourceInfluence { get; init; } = new Dictionary<string, double>(StringComparer.Ordinal);
public bool HasReachabilityProof { get; init; }
public string? Severity { get; init; }
public IReadOnlyCollection<string> ReasonCodes { get; init; } = Array.Empty<string>();
}
public sealed record GateResult
{
public required string GateName { get; init; }
public required bool Passed { get; init; }
public required string? Reason { get; init; }
public required ImmutableDictionary<string, object> Details { get; init; }
}
public sealed record GateEvaluationResult
{
public required bool AllPassed { get; init; }
public required ImmutableArray<GateResult> Results { get; init; }
public GateResult? FirstFailure => Results.FirstOrDefault(r => !r.Passed);
}
public interface IPolicyGate
{
Task<GateResult> EvaluateAsync(
MergeResult mergeResult,
PolicyGateContext context,
CancellationToken ct = default);
}
public sealed record PolicyGateRegistryOptions
{
public bool StopOnFirstFailure { get; init; } = true;
}
public interface IPolicyGateRegistry
{
void Register<TGate>(string name) where TGate : IPolicyGate;
Task<GateEvaluationResult> EvaluateAsync(
MergeResult mergeResult,
PolicyGateContext context,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,68 @@
using System.Collections.Immutable;
namespace StellaOps.Policy.Gates;
public sealed class PolicyGateRegistry : IPolicyGateRegistry
{
private readonly IServiceProvider _serviceProvider;
private readonly PolicyGateRegistryOptions _options;
private readonly List<GateDescriptor> _gates = new();
public PolicyGateRegistry(IServiceProvider serviceProvider, PolicyGateRegistryOptions? options = null)
{
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
_options = options ?? new PolicyGateRegistryOptions();
}
public void Register<TGate>(string name) where TGate : IPolicyGate
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("Gate name must be provided.", nameof(name));
}
_gates.Add(new GateDescriptor(name, typeof(TGate)));
}
public async Task<GateEvaluationResult> EvaluateAsync(
TrustLattice.MergeResult mergeResult,
PolicyGateContext context,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(mergeResult);
ArgumentNullException.ThrowIfNull(context);
var results = new List<GateResult>();
foreach (var gate in _gates)
{
var instance = _serviceProvider.GetService(gate.Type) as IPolicyGate
?? (IPolicyGate)Activator.CreateInstance(gate.Type)!;
var result = await instance.EvaluateAsync(mergeResult, context, ct).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(result.GateName))
{
result = result with { GateName = gate.Name };
}
if (result.Details is null)
{
result = result with { Details = ImmutableDictionary<string, object>.Empty };
}
results.Add(result);
if (!result.Passed && _options.StopOnFirstFailure)
{
break;
}
}
return new GateEvaluationResult
{
AllPassed = results.All(r => r.Passed),
Results = results.ToImmutableArray(),
};
}
private sealed record GateDescriptor(string Name, Type Type);
}

View File

@@ -0,0 +1,97 @@
using System.Collections.Immutable;
using StellaOps.Policy.TrustLattice;
using VexStatus = StellaOps.Policy.Confidence.Models.VexStatus;
namespace StellaOps.Policy.Gates;
public sealed record ReachabilityRequirementGateOptions
{
public bool Enabled { get; init; } = true;
public string SeverityThreshold { get; init; } = "CRITICAL";
public IReadOnlyCollection<VexStatus> RequiredForStatuses { get; init; } = new[]
{
VexStatus.NotAffected,
};
public IReadOnlyCollection<string> BypassReasons { get; init; } = new[]
{
"component_not_present",
"vulnerable_configuration_unused",
};
}
public sealed class ReachabilityRequirementGate : IPolicyGate
{
private readonly ReachabilityRequirementGateOptions _options;
public ReachabilityRequirementGate(ReachabilityRequirementGateOptions? options = null)
{
_options = options ?? new ReachabilityRequirementGateOptions();
}
public Task<GateResult> EvaluateAsync(MergeResult mergeResult, PolicyGateContext context, CancellationToken ct = default)
{
if (!_options.Enabled)
{
return Task.FromResult(Pass("disabled"));
}
if (!_options.RequiredForStatuses.Contains(mergeResult.Status))
{
return Task.FromResult(Pass("status_not_applicable"));
}
var severityRank = SeverityRank(context.Severity);
var thresholdRank = SeverityRank(_options.SeverityThreshold);
if (severityRank < thresholdRank)
{
return Task.FromResult(Pass("severity_below_threshold"));
}
if (HasBypass(context.ReasonCodes))
{
return Task.FromResult(Pass("bypass_reason"));
}
var passed = context.HasReachabilityProof;
var details = ImmutableDictionary<string, object>.Empty
.Add("severity", context.Severity ?? string.Empty)
.Add("threshold", _options.SeverityThreshold)
.Add("hasReachabilityProof", context.HasReachabilityProof);
return Task.FromResult(new GateResult
{
GateName = nameof(ReachabilityRequirementGate),
Passed = passed,
Reason = passed ? null : "reachability_proof_missing",
Details = details,
});
}
private bool HasBypass(IReadOnlyCollection<string> reasons)
=> reasons.Any(reason => _options.BypassReasons.Contains(reason, StringComparer.OrdinalIgnoreCase));
private static int SeverityRank(string? severity)
{
if (string.IsNullOrWhiteSpace(severity))
{
return 0;
}
return severity.Trim().ToUpperInvariant() switch
{
"CRITICAL" => 4,
"HIGH" => 3,
"MEDIUM" => 2,
"LOW" => 1,
_ => 0,
};
}
private static GateResult Pass(string reason) => new()
{
GateName = nameof(ReachabilityRequirementGate),
Passed = true,
Reason = reason,
Details = ImmutableDictionary<string, object>.Empty,
};
}

View File

@@ -0,0 +1,93 @@
using System.Collections.Immutable;
using StellaOps.Policy.TrustLattice;
using VexStatus = StellaOps.Policy.Confidence.Models.VexStatus;
namespace StellaOps.Policy.Gates;
public sealed record SourceQuotaGateOptions
{
public bool Enabled { get; init; } = true;
public double MaxInfluencePercent { get; init; } = 60;
public double CorroborationDelta { get; init; } = 0.10;
public IReadOnlyCollection<VexStatus> RequireCorroborationFor { get; init; } = new[]
{
VexStatus.NotAffected,
VexStatus.Fixed,
};
}
public sealed class SourceQuotaGate : IPolicyGate
{
private readonly SourceQuotaGateOptions _options;
public SourceQuotaGate(SourceQuotaGateOptions? options = null)
{
_options = options ?? new SourceQuotaGateOptions();
}
public Task<GateResult> EvaluateAsync(MergeResult mergeResult, PolicyGateContext context, CancellationToken ct = default)
{
if (!_options.Enabled)
{
return Task.FromResult(Pass("disabled"));
}
if (!_options.RequireCorroborationFor.Contains(mergeResult.Status))
{
return Task.FromResult(Pass("status_not_applicable"));
}
var influence = context.SourceInfluence.Count > 0
? context.SourceInfluence
: ComputeInfluence(mergeResult);
if (influence.Count == 0)
{
return Task.FromResult(Pass("no_sources"));
}
var maxAllowed = _options.MaxInfluencePercent / 100.0;
var ordered = influence.OrderByDescending(kv => kv.Value).ToList();
var top = ordered[0];
var second = ordered.Count > 1 ? ordered[1] : new KeyValuePair<string, double>(string.Empty, 0);
var corroborated = ordered.Count > 1 && (top.Value - second.Value) <= _options.CorroborationDelta;
var passed = top.Value <= maxAllowed || corroborated;
var details = ImmutableDictionary<string, object>.Empty
.Add("maxInfluence", maxAllowed)
.Add("topSource", top.Key)
.Add("topShare", top.Value)
.Add("secondShare", second.Value)
.Add("corroborated", corroborated);
return Task.FromResult(new GateResult
{
GateName = nameof(SourceQuotaGate),
Passed = passed,
Reason = passed ? null : "source_quota_exceeded",
Details = details,
});
}
private static IReadOnlyDictionary<string, double> ComputeInfluence(MergeResult mergeResult)
{
var relevant = mergeResult.AllClaims.Where(c => c.Status == mergeResult.Status).ToList();
var total = relevant.Sum(c => c.AdjustedScore);
if (total <= 0)
{
return new Dictionary<string, double>(StringComparer.Ordinal);
}
return relevant
.GroupBy(c => c.SourceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.Sum(c => c.AdjustedScore) / total, StringComparer.Ordinal);
}
private static GateResult Pass(string reason) => new()
{
GateName = nameof(SourceQuotaGate),
Passed = true,
Reason = reason,
Details = ImmutableDictionary<string, object>.Empty,
};
}

View File

@@ -0,0 +1,59 @@
using System.Collections.Immutable;
using StellaOps.Policy.TrustLattice;
namespace StellaOps.Policy.Gates;
public sealed record UnknownsBudgetGateOptions
{
public bool Enabled { get; init; } = true;
public int MaxUnknownCount { get; init; } = 5;
public double MaxCumulativeUncertainty { get; init; } = 2.0;
public bool EscalateOnFail { get; init; } = true;
}
public sealed class UnknownsBudgetGate : IPolicyGate
{
private readonly UnknownsBudgetGateOptions _options;
public UnknownsBudgetGate(UnknownsBudgetGateOptions? options = null)
{
_options = options ?? new UnknownsBudgetGateOptions();
}
public Task<GateResult> EvaluateAsync(MergeResult mergeResult, PolicyGateContext context, CancellationToken ct = default)
{
if (!_options.Enabled)
{
return Task.FromResult(Pass("disabled"));
}
var unknownCount = context.UnknownCount;
var cumulative = context.UnknownClaimScores.Sum(score => 1.0 - score);
var countExceeded = unknownCount > _options.MaxUnknownCount;
var cumulativeExceeded = cumulative > _options.MaxCumulativeUncertainty;
var passed = !countExceeded && !cumulativeExceeded;
var details = ImmutableDictionary<string, object>.Empty
.Add("unknownCount", unknownCount)
.Add("maxUnknownCount", _options.MaxUnknownCount)
.Add("cumulativeUncertainty", cumulative)
.Add("maxCumulativeUncertainty", _options.MaxCumulativeUncertainty);
return Task.FromResult(new GateResult
{
GateName = nameof(UnknownsBudgetGate),
Passed = passed,
Reason = passed ? null : "unknowns_budget_exceeded",
Details = details,
});
}
private static GateResult Pass(string reason) => new()
{
GateName = nameof(UnknownsBudgetGate),
Passed = true,
Reason = reason,
Details = ImmutableDictionary<string, object>.Empty,
};
}

View File

@@ -11,6 +11,7 @@
<PackageReference Include="System.CommandLine" Version="2.0.0-beta5.25306.1" />
<PackageReference Include="YamlDotNet" Version="13.7.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="JsonSchema.Net" Version="7.3.2" />
</ItemGroup>

View File

@@ -7,4 +7,17 @@ This file mirrors sprint work for the `StellaOps.Policy` library.
| `DET-3401-001` | `docs/implplan/SPRINT_3401_0001_0001_determinism_scoring_foundations.md` | DONE (2025-12-14) | Added `FreshnessBucket` + `FreshnessMultiplierConfig` in `src/Policy/__Libraries/StellaOps.Policy/Scoring/FreshnessModels.cs` and covered bucket boundaries in `src/Policy/__Tests/StellaOps.Policy.Tests/Scoring/EvidenceFreshnessCalculatorTests.cs`. |
| `DET-3401-002` | `docs/implplan/SPRINT_3401_0001_0001_determinism_scoring_foundations.md` | DONE (2025-12-14) | Implemented `EvidenceFreshnessCalculator` in `src/Policy/__Libraries/StellaOps.Policy/Scoring/EvidenceFreshnessCalculator.cs`. |
| `DET-3401-009` | `docs/implplan/SPRINT_3401_0001_0001_determinism_scoring_foundations.md` | DONE (2025-12-14) | Added `ScoreExplanation` + `ScoreExplainBuilder` in `src/Policy/__Libraries/StellaOps.Policy/Scoring/ScoreExplanation.cs` and tests in `src/Policy/__Tests/StellaOps.Policy.Tests/Scoring/ScoreExplainBuilderTests.cs`. |
| `EXC-3900-0003-0002-T1` | `docs/implplan/SPRINT_3900_0003_0002_recheck_policy_evidence_hooks.md` | DONE (2025-12-22) | Defined RecheckPolicy model in `src/Policy/__Libraries/StellaOps.Policy.Exceptions/Models/RecheckPolicy.cs`. |
| `EXC-3900-0003-0002-T2` | `docs/implplan/SPRINT_3900_0003_0002_recheck_policy_evidence_hooks.md` | DONE (2025-12-22) | Extended ExceptionObject, repository mapping, and migration for recheck policy tracking. |
| `EXC-3900-0003-0002-T3` | `docs/implplan/SPRINT_3900_0003_0002_recheck_policy_evidence_hooks.md` | DONE (2025-12-22) | Added evidence hook and requirements models in `src/Policy/__Libraries/StellaOps.Policy.Exceptions/Models/EvidenceHook.cs`. |
| `EXC-3900-0003-0002-T4` | `docs/implplan/SPRINT_3900_0003_0002_recheck_policy_evidence_hooks.md` | DONE (2025-12-22) | Added RecheckEvaluationService and context model. |
| `EXC-3900-0003-0002-T5` | `docs/implplan/SPRINT_3900_0003_0002_recheck_policy_evidence_hooks.md` | DONE (2025-12-22) | Added EvidenceRequirementValidator and support interfaces. |
| `EXC-3900-0003-0002-T8` | `docs/implplan/SPRINT_3900_0003_0002_recheck_policy_evidence_hooks.md` | DONE (2025-12-22) | Aligned recheck/evidence migration and added Postgres tests for recheck fields. |
| `SPRINT-7000-0002-0001-T1` | `docs/implplan/SPRINT_7000_0002_0001_unified_confidence_model.md` | DONE (2025-12-22) | Added unified confidence score models in `src/Policy/__Libraries/StellaOps.Policy/Confidence/Models/ConfidenceScore.cs`. |
| `SPRINT-7000-0002-0001-T2` | `docs/implplan/SPRINT_7000_0002_0001_unified_confidence_model.md` | DONE (2025-12-22) | Added configurable weights in `src/Policy/__Libraries/StellaOps.Policy/Confidence/Configuration/ConfidenceWeightOptions.cs`. |
| `SPRINT-7000-0002-0001-T3` | `docs/implplan/SPRINT_7000_0002_0001_unified_confidence_model.md` | DONE (2025-12-22) | Implemented calculator and inputs in `src/Policy/__Libraries/StellaOps.Policy/Confidence/Services/ConfidenceCalculator.cs`. |
| `SPRINT-7000-0002-0001-T4` | `docs/implplan/SPRINT_7000_0002_0001_unified_confidence_model.md` | DONE (2025-12-22) | Added confidence evidence models in `src/Policy/__Libraries/StellaOps.Policy/Confidence/Models/ConfidenceEvidence.cs`. |
| `SPRINT-7000-0002-0001-T5` | `docs/implplan/SPRINT_7000_0002_0001_unified_confidence_model.md` | DONE (2025-12-22) | Integrated confidence scoring into policy evaluation and runtime responses. |
| `SPRINT-7000-0002-0001-T6` | `docs/implplan/SPRINT_7000_0002_0001_unified_confidence_model.md` | DONE (2025-12-22) | Added confidence calculator tests in `src/Policy/__Tests/StellaOps.Policy.Tests/Confidence/ConfidenceCalculatorTests.cs` and runtime eval assertion. |
| `SPRINT-7100-0002-0001` | `docs/implplan/SPRINT_7100_0002_0001_policy_gates_merge.md` | DOING | Implementing ClaimScore merge + policy gates for trust lattice decisioning. |

View File

@@ -0,0 +1,168 @@
using System.Collections.Immutable;
using VexStatus = StellaOps.Policy.Confidence.Models.VexStatus;
namespace StellaOps.Policy.TrustLattice;
public sealed record VexClaim
{
public required string SourceId { get; init; }
public required VexStatus Status { get; init; }
public required int ScopeSpecificity { get; init; }
public required DateTimeOffset IssuedAt { get; init; }
public string? StatementDigest { get; init; }
public string? Reason { get; init; }
}
public sealed record ClaimScoreResult
{
public required double Score { get; init; }
public required double BaseTrust { get; init; }
public required double StrengthMultiplier { get; init; }
public required double FreshnessMultiplier { get; init; }
}
public sealed record MergePolicy
{
public double ConflictPenalty { get; init; } = 0.25;
public bool PreferSpecificity { get; init; } = true;
public bool RequireReplayProofOnConflict { get; init; } = true;
}
public sealed record MergeResult
{
public required VexStatus Status { get; init; }
public required double Confidence { get; init; }
public required bool HasConflicts { get; init; }
public required ImmutableArray<ScoredClaim> AllClaims { get; init; }
public required ScoredClaim WinningClaim { get; init; }
public required ImmutableArray<ConflictRecord> Conflicts { get; init; }
public bool RequiresReplayProof { get; init; }
}
public sealed record ScoredClaim
{
public required string SourceId { get; init; }
public required VexStatus Status { get; init; }
public required double OriginalScore { get; init; }
public required double AdjustedScore { get; init; }
public required int ScopeSpecificity { get; init; }
public required bool Accepted { get; init; }
public required string Reason { get; init; }
}
public sealed record ConflictRecord
{
public required string SourceId { get; init; }
public required VexStatus Status { get; init; }
public required string ConflictsWithSourceId { get; init; }
public required string Reason { get; init; }
}
public interface IClaimScoreMerger
{
MergeResult Merge(
IEnumerable<(VexClaim Claim, ClaimScoreResult Score)> scoredClaims,
MergePolicy policy,
CancellationToken ct = default);
}
public sealed class ClaimScoreMerger : IClaimScoreMerger
{
public MergeResult Merge(
IEnumerable<(VexClaim Claim, ClaimScoreResult Score)> scoredClaims,
MergePolicy policy,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(scoredClaims);
ArgumentNullException.ThrowIfNull(policy);
var input = scoredClaims.Select((pair, index) => new ClaimCandidate(pair.Claim, pair.Score, index)).ToList();
if (input.Count == 0)
{
var empty = new ScoredClaim
{
SourceId = "none",
Status = VexStatus.UnderInvestigation,
OriginalScore = 0,
AdjustedScore = 0,
ScopeSpecificity = 0,
Accepted = false,
Reason = "no_claims",
};
return new MergeResult
{
Status = VexStatus.UnderInvestigation,
Confidence = 0,
HasConflicts = false,
AllClaims = ImmutableArray<ScoredClaim>.Empty,
WinningClaim = empty,
Conflicts = ImmutableArray<ConflictRecord>.Empty,
RequiresReplayProof = false,
};
}
var scored = input
.Select(candidate => new ScoredClaim
{
SourceId = candidate.Claim.SourceId,
Status = candidate.Claim.Status,
OriginalScore = candidate.Score.Score,
AdjustedScore = candidate.Score.Score,
ScopeSpecificity = candidate.Claim.ScopeSpecificity,
Accepted = false,
Reason = "initial",
})
.ToList();
var hasConflicts = scored.Select(s => s.Status).Distinct().Count() > 1;
if (hasConflicts)
{
var penalizer = new ConflictPenalizer { ConflictPenalty = policy.ConflictPenalty };
scored = penalizer.ApplyPenalties(scored).ToList();
}
var ordered = scored
.Select((claim, index) => new { claim, index })
.OrderByDescending(x => x.claim.AdjustedScore)
.ThenByDescending(x => policy.PreferSpecificity ? x.claim.ScopeSpecificity : 0)
.ThenByDescending(x => x.claim.OriginalScore)
.ThenBy(x => x.claim.SourceId, StringComparer.Ordinal)
.ThenBy(x => x.index)
.ToList();
var winning = ordered.First().claim;
var updatedClaims = ordered.Select(x => x.claim with
{
Accepted = x.claim.SourceId == winning.SourceId && x.claim.Status == winning.Status,
Reason = x.claim.SourceId == winning.SourceId && x.claim.Status == winning.Status ? "winner" : x.claim.Reason,
}).ToImmutableArray();
var conflicts = hasConflicts
? updatedClaims
.Where(c => c.Status != winning.Status)
.OrderBy(c => c.SourceId, StringComparer.Ordinal)
.Select(c => new ConflictRecord
{
SourceId = c.SourceId,
Status = c.Status,
ConflictsWithSourceId = winning.SourceId,
Reason = "status_conflict",
})
.ToImmutableArray()
: ImmutableArray<ConflictRecord>.Empty;
return new MergeResult
{
Status = winning.Status,
Confidence = Math.Clamp(winning.AdjustedScore, 0, 1),
HasConflicts = hasConflicts,
AllClaims = updatedClaims,
WinningClaim = winning,
Conflicts = conflicts,
RequiresReplayProof = hasConflicts && policy.RequireReplayProofOnConflict,
};
}
private sealed record ClaimCandidate(VexClaim Claim, ClaimScoreResult Score, int Index);
}

View File

@@ -0,0 +1,37 @@
namespace StellaOps.Policy.TrustLattice;
public sealed class ConflictPenalizer
{
public double ConflictPenalty { get; init; } = 0.25;
public IReadOnlyList<ScoredClaim> ApplyPenalties(IReadOnlyList<ScoredClaim> claims)
{
ArgumentNullException.ThrowIfNull(claims);
var statuses = claims.Select(c => c.Status).Distinct().ToList();
if (statuses.Count <= 1)
{
return claims;
}
var strongest = claims
.OrderByDescending(c => c.OriginalScore)
.ThenByDescending(c => c.ScopeSpecificity)
.ThenBy(c => c.SourceId, StringComparer.Ordinal)
.First();
return claims.Select(c =>
{
if (c.Status == strongest.Status)
{
return c;
}
return c with
{
AdjustedScore = c.OriginalScore * (1 - ConflictPenalty),
Reason = $"Conflict penalty applied (disagrees with {strongest.SourceId})",
};
}).ToList();
}
}

View File

@@ -178,6 +178,18 @@ public sealed class TrustLatticeEngine
return _selector.Select(state);
}
/// <summary>
/// Merges scored VEX claims using the ClaimScore-based lattice merge algorithm.
/// </summary>
public MergeResult MergeClaims(
IEnumerable<(VexClaim Claim, ClaimScoreResult Score)> scoredClaims,
MergePolicy? policy = null,
CancellationToken ct = default)
{
var merger = new ClaimScoreMerger();
return merger.Merge(scoredClaims, policy ?? new MergePolicy(), ct);
}
/// <summary>
/// Evaluates all subjects and produces dispositions.
/// </summary>

View File

@@ -2,10 +2,16 @@ using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Policy;
using StellaOps.PolicyDsl;
using StellaOps.Policy.Engine.Evaluation;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Unknowns.Configuration;
using StellaOps.Policy.Unknowns.Models;
using StellaOps.Policy.Unknowns.Services;
using Xunit;
using Xunit.Sdk;
@@ -331,6 +337,35 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
Assert.Contains(result.Warnings, warning => warning.Contains("Git-sourced", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Evaluate_UnknownBudgetExceeded_BlocksEvaluation()
{
var document = CompileBaseline();
var budgetService = CreateBudgetService();
var evaluator = new PolicyEvaluator(budgetService: budgetService);
var context = new PolicyEvaluationContext(
new PolicyEvaluationSeverity("High"),
new PolicyEvaluationEnvironment(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["name"] = "prod"
}.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase)),
new PolicyEvaluationAdvisory("GHSA", ImmutableDictionary<string, string>.Empty),
PolicyEvaluationVexEvidence.Empty,
PolicyEvaluationSbom.Empty,
PolicyEvaluationExceptions.Empty,
ImmutableArray.Create(CreateUnknown(UnknownReasonCode.Reachability)),
ImmutableArray<ExceptionObject>.Empty,
PolicyEvaluationReachability.Unknown,
PolicyEvaluationEntropy.Unknown);
var result = evaluator.Evaluate(new PolicyEvaluationRequest(document, context));
Assert.Equal("blocked", result.Status);
Assert.Equal(PolicyFailureReason.UnknownBudgetExceeded, result.FailureReason);
Assert.NotNull(result.UnknownBudgetStatus);
}
private PolicyIrDocument CompileBaseline()
{
var compilation = compiler.Compile(BaselinePolicy);
@@ -354,10 +389,69 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
PolicyEvaluationVexEvidence.Empty,
PolicyEvaluationSbom.Empty,
exceptions ?? PolicyEvaluationExceptions.Empty,
ImmutableArray<Unknown>.Empty,
ImmutableArray<ExceptionObject>.Empty,
PolicyEvaluationReachability.Unknown,
PolicyEvaluationEntropy.Unknown);
}
private static UnknownBudgetService CreateBudgetService()
{
var options = new UnknownBudgetOptions
{
Budgets = new Dictionary<string, UnknownBudget>(StringComparer.OrdinalIgnoreCase)
{
["prod"] = new UnknownBudget
{
Environment = "prod",
TotalLimit = 0,
Action = BudgetAction.Block
}
}
};
return new UnknownBudgetService(
new TestOptionsMonitor<UnknownBudgetOptions>(options),
NullLogger<UnknownBudgetService>.Instance);
}
private static Unknown CreateUnknown(UnknownReasonCode reasonCode)
{
var timestamp = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
return new Unknown
{
Id = Guid.NewGuid(),
TenantId = Guid.NewGuid(),
PackageId = "pkg:npm/lodash",
PackageVersion = "4.17.21",
Band = UnknownBand.Hot,
Score = 80m,
UncertaintyFactor = 0.5m,
ExploitPressure = 0.7m,
ReasonCode = reasonCode,
FirstSeenAt = timestamp,
LastEvaluatedAt = timestamp,
CreatedAt = timestamp,
UpdatedAt = timestamp
};
}
private sealed class TestOptionsMonitor<T>(T current) : IOptionsMonitor<T>
{
private readonly T _current = current;
public T CurrentValue => _current;
public T Get(string? name) => _current;
public IDisposable OnChange(Action<T, string?> listener) => NoopDisposable.Instance;
}
private sealed class NoopDisposable : IDisposable
{
public static readonly NoopDisposable Instance = new();
public void Dispose() { }
}
private static string Describe(ImmutableArray<PolicyIssue> issues) =>
string.Join(" | ", issues.Select(issue => $"{issue.Severity}:{issue.Code}:{issue.Message}"));

View File

@@ -51,6 +51,7 @@ public sealed class PolicyRuntimeEvaluationServiceTests
Assert.Equal("pack-1", response.PackId);
Assert.Equal(1, response.Version);
Assert.NotNull(response.PolicyDigest);
Assert.NotNull(response.Confidence);
Assert.False(response.Cached);
}

View File

@@ -0,0 +1,142 @@
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Exceptions.Services;
using Xunit;
namespace StellaOps.Policy.Exceptions.Tests;
public sealed class EvidenceRequirementValidatorTests
{
[Fact]
public async Task ValidateForApprovalAsync_NoHooks_ReturnsValid()
{
var validator = CreateValidator(new StubHookRegistry([]));
var exception = CreateException();
var result = await validator.ValidateForApprovalAsync(exception);
result.IsValid.Should().BeTrue();
result.MissingEvidence.Should().BeEmpty();
}
[Fact]
public async Task ValidateForApprovalAsync_MissingEvidence_ReturnsInvalid()
{
var hooks = ImmutableArray.Create(new EvidenceHook
{
HookId = "hook-1",
Type = EvidenceType.FeatureFlagDisabled,
Description = "Feature flag disabled",
IsMandatory = true
});
var validator = CreateValidator(new StubHookRegistry(hooks));
var exception = CreateException();
var result = await validator.ValidateForApprovalAsync(exception);
result.IsValid.Should().BeFalse();
result.MissingEvidence.Should().HaveCount(1);
}
[Fact]
public async Task ValidateForApprovalAsync_TrustScoreTooLow_ReturnsInvalid()
{
var hooks = ImmutableArray.Create(new EvidenceHook
{
HookId = "hook-1",
Type = EvidenceType.BackportMerged,
Description = "Backport merged",
IsMandatory = true,
MinTrustScore = 0.8m
});
var validator = CreateValidator(
new StubHookRegistry(hooks),
trustScore: 0.5m);
var exception = CreateException(new EvidenceRequirements
{
Hooks = hooks,
SubmittedEvidence = ImmutableArray.Create(new SubmittedEvidence
{
EvidenceId = "e-1",
HookId = "hook-1",
Type = EvidenceType.BackportMerged,
Reference = "ref",
SubmittedAt = DateTimeOffset.UtcNow,
SubmittedBy = "tester",
ValidationStatus = EvidenceValidationStatus.Valid
})
});
var result = await validator.ValidateForApprovalAsync(exception);
result.IsValid.Should().BeFalse();
result.InvalidEvidence.Should().HaveCount(1);
}
private static EvidenceRequirementValidator CreateValidator(
IEvidenceHookRegistry registry,
decimal trustScore = 1.0m,
bool schemaValid = true,
bool signatureValid = true)
{
return new EvidenceRequirementValidator(
registry,
new StubAttestationVerifier(signatureValid),
new StubTrustScoreService(trustScore),
new StubSchemaValidator(schemaValid),
NullLogger<EvidenceRequirementValidator>.Instance);
}
private static ExceptionObject CreateException(EvidenceRequirements? requirements = null)
{
return new ExceptionObject
{
ExceptionId = "EXC-TEST",
Version = 1,
Status = ExceptionStatus.Active,
Type = ExceptionType.Vulnerability,
Scope = new ExceptionScope { VulnerabilityId = "CVE-2024-0001" },
OwnerId = "owner",
RequesterId = "requester",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
ReasonCode = ExceptionReason.AcceptedRisk,
Rationale = "This rationale is long enough to satisfy the minimum character requirement.",
EvidenceRequirements = requirements
};
}
private sealed class StubHookRegistry(ImmutableArray<EvidenceHook> hooks) : IEvidenceHookRegistry
{
public Task<ImmutableArray<EvidenceHook>> GetRequiredHooksAsync(
ExceptionType exceptionType,
ExceptionScope scope,
CancellationToken ct = default) => Task.FromResult(hooks);
}
private sealed class StubAttestationVerifier(bool isValid) : IAttestationVerifier
{
public Task<EvidenceVerificationResult> VerifyAsync(string dsseEnvelope, CancellationToken ct = default) =>
Task.FromResult(new EvidenceVerificationResult(isValid, isValid ? null : "invalid"));
}
private sealed class StubTrustScoreService(decimal score) : ITrustScoreService
{
public Task<decimal> GetScoreAsync(string reference, CancellationToken ct = default) => Task.FromResult(score);
}
private sealed class StubSchemaValidator(bool isValid) : IEvidenceSchemaValidator
{
public Task<EvidenceSchemaValidationResult> ValidateAsync(
string schemaId,
string? content,
CancellationToken ct = default) =>
Task.FromResult(new EvidenceSchemaValidationResult(isValid, isValid ? null : "schema invalid"));
}
}

View File

@@ -0,0 +1,71 @@
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Policy.Exceptions.Models;
using Xunit;
namespace StellaOps.Policy.Exceptions.Tests;
public sealed class EvidenceRequirementsTests
{
[Fact]
public void EvidenceRequirements_ShouldBeSatisfied_WhenAllMandatoryHooksValid()
{
var hooks = ImmutableArray.Create(
new EvidenceHook
{
HookId = "hook-1",
Type = EvidenceType.FeatureFlagDisabled,
Description = "Feature flag disabled",
IsMandatory = true
},
new EvidenceHook
{
HookId = "hook-2",
Type = EvidenceType.BackportMerged,
Description = "Backport merged",
IsMandatory = false
});
var submitted = ImmutableArray.Create(new SubmittedEvidence
{
EvidenceId = "e-1",
HookId = "hook-1",
Type = EvidenceType.FeatureFlagDisabled,
Reference = "attestation:feature-flag",
SubmittedAt = DateTimeOffset.UtcNow,
SubmittedBy = "tester",
ValidationStatus = EvidenceValidationStatus.Valid
});
var requirements = new EvidenceRequirements
{
Hooks = hooks,
SubmittedEvidence = submitted
};
requirements.IsSatisfied.Should().BeTrue();
requirements.MissingEvidence.Should().BeEmpty();
}
[Fact]
public void EvidenceRequirements_ShouldReportMissing_WhenMandatoryHookMissing()
{
var hooks = ImmutableArray.Create(new EvidenceHook
{
HookId = "hook-1",
Type = EvidenceType.CompensatingControl,
Description = "Compensating control",
IsMandatory = true
});
var requirements = new EvidenceRequirements
{
Hooks = hooks,
SubmittedEvidence = []
};
requirements.IsSatisfied.Should().BeFalse();
requirements.MissingEvidence.Should().HaveCount(1);
requirements.MissingEvidence[0].HookId.Should().Be("hook-1");
}
}

View File

@@ -210,6 +210,40 @@ public sealed class ExceptionObjectTests
exception.EvidenceRefs.Should().Contain("sha256:evidence1hash");
}
[Fact]
public void ExceptionObject_IsBlockedByRecheck_WhenBlockTriggered_ShouldBeTrue()
{
// Arrange
var exception = CreateException(recheckResult: new RecheckEvaluationResult
{
IsTriggered = true,
TriggeredConditions = [],
RecommendedAction = RecheckAction.Block,
EvaluatedAt = DateTimeOffset.UtcNow
});
// Act & Assert
exception.IsBlockedByRecheck.Should().BeTrue();
exception.RequiresReapproval.Should().BeFalse();
}
[Fact]
public void ExceptionObject_RequiresReapproval_WhenReapprovalTriggered_ShouldBeTrue()
{
// Arrange
var exception = CreateException(recheckResult: new RecheckEvaluationResult
{
IsTriggered = true,
TriggeredConditions = [],
RecommendedAction = RecheckAction.RequireReapproval,
EvaluatedAt = DateTimeOffset.UtcNow
});
// Act & Assert
exception.RequiresReapproval.Should().BeTrue();
exception.IsBlockedByRecheck.Should().BeFalse();
}
[Fact]
public void ExceptionObject_WithMetadata_ShouldStoreKeyValuePairs()
{
@@ -265,7 +299,8 @@ public sealed class ExceptionObjectTests
DateTimeOffset? expiresAt = null,
ImmutableArray<string>? approverIds = null,
ImmutableArray<string>? evidenceRefs = null,
ImmutableDictionary<string, string>? metadata = null)
ImmutableDictionary<string, string>? metadata = null,
RecheckEvaluationResult? recheckResult = null)
{
return new ExceptionObject
{
@@ -287,7 +322,9 @@ public sealed class ExceptionObjectTests
Rationale = "This is a test rationale that meets the minimum character requirement of 50 characters.",
EvidenceRefs = evidenceRefs ?? [],
CompensatingControls = [],
Metadata = metadata ?? ImmutableDictionary<string, string>.Empty
Metadata = metadata ?? ImmutableDictionary<string, string>.Empty,
LastRecheckResult = recheckResult,
LastRecheckAt = recheckResult?.EvaluatedAt
};
}

View File

@@ -0,0 +1,190 @@
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Exceptions.Services;
using Xunit;
namespace StellaOps.Policy.Exceptions.Tests;
public sealed class RecheckEvaluationServiceTests
{
[Fact]
public async Task EvaluateAsync_NoPolicy_ReturnsNoTrigger()
{
var service = new RecheckEvaluationService();
var exception = CreateException(recheckPolicy: null);
var context = new RecheckEvaluationContext
{
ArtifactDigest = "sha256:abc",
Environment = "prod",
EvaluatedAt = DateTimeOffset.UtcNow
};
var result = await service.EvaluateAsync(exception, context);
result.IsTriggered.Should().BeFalse();
result.RecommendedAction.Should().BeNull();
}
[Fact]
public async Task EvaluateAsync_EpssAbove_Triggers()
{
var service = new RecheckEvaluationService();
var policy = new RecheckPolicy
{
PolicyId = "policy-1",
Name = "EPSS gate",
DefaultAction = RecheckAction.Warn,
CreatedAt = DateTimeOffset.UtcNow,
Conditions = ImmutableArray.Create(new RecheckCondition
{
Type = RecheckConditionType.EPSSAbove,
Threshold = 0.5m,
Action = RecheckAction.RequireReapproval
})
};
var exception = CreateException(recheckPolicy: policy);
var context = new RecheckEvaluationContext
{
ArtifactDigest = "sha256:abc",
Environment = "prod",
EvaluatedAt = DateTimeOffset.UtcNow,
EpssScore = 0.9m
};
var result = await service.EvaluateAsync(exception, context);
result.IsTriggered.Should().BeTrue();
result.TriggeredConditions.Should().HaveCount(1);
result.RecommendedAction.Should().Be(RecheckAction.RequireReapproval);
}
[Fact]
public async Task EvaluateAsync_EnvironmentScope_FiltersConditions()
{
var service = new RecheckEvaluationService();
var policy = new RecheckPolicy
{
PolicyId = "policy-1",
Name = "Env gate",
DefaultAction = RecheckAction.Warn,
CreatedAt = DateTimeOffset.UtcNow,
Conditions = ImmutableArray.Create(new RecheckCondition
{
Type = RecheckConditionType.KEVFlagged,
Action = RecheckAction.Block,
EnvironmentScope = ["prod"]
})
};
var exception = CreateException(recheckPolicy: policy);
var context = new RecheckEvaluationContext
{
ArtifactDigest = "sha256:abc",
Environment = "dev",
EvaluatedAt = DateTimeOffset.UtcNow,
KevFlagged = true
};
var result = await service.EvaluateAsync(exception, context);
result.IsTriggered.Should().BeFalse();
}
[Fact]
public async Task EvaluateAsync_ActionPriority_PicksBlock()
{
var service = new RecheckEvaluationService();
var policy = new RecheckPolicy
{
PolicyId = "policy-1",
Name = "Priority gate",
DefaultAction = RecheckAction.Warn,
CreatedAt = DateTimeOffset.UtcNow,
Conditions = ImmutableArray.Create(
new RecheckCondition
{
Type = RecheckConditionType.ExpiryWithin,
Threshold = 10,
Action = RecheckAction.Warn
},
new RecheckCondition
{
Type = RecheckConditionType.KEVFlagged,
Action = RecheckAction.Block
})
};
var exception = CreateException(
expiresAt: DateTimeOffset.UtcNow.AddDays(1),
recheckPolicy: policy);
var context = new RecheckEvaluationContext
{
ArtifactDigest = "sha256:abc",
Environment = "prod",
EvaluatedAt = DateTimeOffset.UtcNow,
KevFlagged = true
};
var result = await service.EvaluateAsync(exception, context);
result.IsTriggered.Should().BeTrue();
result.RecommendedAction.Should().Be(RecheckAction.Block);
}
[Fact]
public async Task EvaluateAsync_ExpiryWithin_UsesThreshold()
{
var service = new RecheckEvaluationService();
var policy = new RecheckPolicy
{
PolicyId = "policy-1",
Name = "Expiry gate",
DefaultAction = RecheckAction.Warn,
CreatedAt = DateTimeOffset.UtcNow,
Conditions = ImmutableArray.Create(new RecheckCondition
{
Type = RecheckConditionType.ExpiryWithin,
Threshold = 5,
Action = RecheckAction.Warn
})
};
var exception = CreateException(
expiresAt: DateTimeOffset.UtcNow.AddDays(3),
recheckPolicy: policy);
var context = new RecheckEvaluationContext
{
ArtifactDigest = "sha256:abc",
Environment = "prod",
EvaluatedAt = DateTimeOffset.UtcNow
};
var result = await service.EvaluateAsync(exception, context);
result.IsTriggered.Should().BeTrue();
}
private static ExceptionObject CreateException(
DateTimeOffset? expiresAt = null,
RecheckPolicy? recheckPolicy = null)
{
return new ExceptionObject
{
ExceptionId = "EXC-TEST",
Version = 1,
Status = ExceptionStatus.Active,
Type = ExceptionType.Vulnerability,
Scope = new ExceptionScope { VulnerabilityId = "CVE-2024-0001" },
OwnerId = "owner",
RequesterId = "requester",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
ExpiresAt = expiresAt ?? DateTimeOffset.UtcNow.AddDays(30),
ReasonCode = ExceptionReason.AcceptedRisk,
Rationale = "This rationale is long enough to satisfy the minimum character requirement.",
RecheckPolicy = recheckPolicy
};
}
}

View File

@@ -50,6 +50,45 @@ public sealed class PostgresExceptionObjectRepositoryTests : IAsyncLifetime
created.Version.Should().Be(1);
}
[Fact]
public async Task CreateAsync_PersistsRecheckTrackingFields()
{
// Arrange
var lastResult = new RecheckEvaluationResult
{
IsTriggered = true,
TriggeredConditions = ImmutableArray.Create(
new TriggeredCondition(
RecheckConditionType.EPSSAbove,
"EPSS above threshold",
CurrentValue: 0.7m,
ThresholdValue: 0.5m,
Action: RecheckAction.Block)),
RecommendedAction = RecheckAction.Block,
EvaluatedAt = DateTimeOffset.UtcNow
};
var exception = CreateVulnerabilityException("CVE-2024-12345") with
{
RecheckPolicyId = "policy-critical",
LastRecheckResult = lastResult,
LastRecheckAt = DateTimeOffset.UtcNow
};
// Act
await _repository.CreateAsync(exception, "creator@example.com");
var fetched = await _repository.GetByIdAsync(exception.ExceptionId);
// Assert
fetched.Should().NotBeNull();
fetched!.RecheckPolicyId.Should().Be("policy-critical");
fetched.LastRecheckResult.Should().NotBeNull();
fetched.LastRecheckResult!.RecommendedAction.Should().Be(RecheckAction.Block);
fetched.LastRecheckResult!.TriggeredConditions.Should().ContainSingle(
c => c.Type == RecheckConditionType.EPSSAbove);
fetched.LastRecheckAt.Should().BeCloseTo(exception.LastRecheckAt!.Value, TimeSpan.FromSeconds(1));
}
[Fact]
public async Task CreateAsync_RecordsCreatedEvent()
{

View File

@@ -0,0 +1,46 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Npgsql;
using StellaOps.Policy.Storage.Postgres;
using Xunit;
namespace StellaOps.Policy.Storage.Postgres.Tests;
[Collection(PolicyPostgresCollection.Name)]
public sealed class RecheckEvidenceMigrationTests : IAsyncLifetime
{
private readonly PolicyPostgresFixture _fixture;
private readonly PolicyDataSource _dataSource;
public RecheckEvidenceMigrationTests(PolicyPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
_dataSource = new PolicyDataSource(Options.Create(options), NullLogger<PolicyDataSource>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task Migration_CreatesRecheckAndEvidenceTables()
{
await using var connection = await _dataSource.OpenConnectionAsync("default", "reader", CancellationToken.None);
await AssertTableExistsAsync(connection, "policy.recheck_policies");
await AssertTableExistsAsync(connection, "policy.evidence_hooks");
await AssertTableExistsAsync(connection, "policy.submitted_evidence");
}
private static async Task AssertTableExistsAsync(NpgsqlConnection connection, string tableName)
{
await using var command = new NpgsqlCommand("SELECT to_regclass(@name)", connection);
command.Parameters.AddWithValue("name", tableName);
var result = await command.ExecuteScalarAsync();
result.Should().NotBeNull($"{tableName} should exist after migrations");
}
}

View File

@@ -28,6 +28,7 @@
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Policy.Storage.Postgres\StellaOps.Policy.Storage.Postgres.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Policy.Exceptions\StellaOps.Policy.Exceptions.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Policy.Unknowns\StellaOps.Policy.Unknowns.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
<ProjectReference Include="..\..\StellaOps.Policy.Scoring\StellaOps.Policy.Scoring.csproj" />
</ItemGroup>

View File

@@ -0,0 +1,120 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Storage.Postgres;
using StellaOps.Policy.Unknowns.Models;
using StellaOps.Policy.Unknowns.Repositories;
using Xunit;
namespace StellaOps.Policy.Storage.Postgres.Tests;
[Collection(PolicyPostgresCollection.Name)]
public sealed class UnknownsRepositoryTests : IAsyncLifetime
{
private readonly PolicyPostgresFixture _fixture;
private readonly PolicyDataSource _dataSource;
private readonly Guid _tenantId = Guid.NewGuid();
public UnknownsRepositoryTests(PolicyPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
_dataSource = new PolicyDataSource(Options.Create(options), NullLogger<PolicyDataSource>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public async Task DisposeAsync() => await _dataSource.DisposeAsync();
[Fact]
public async Task CreateAndGetById_RoundTripsReasonCodeAndEvidence()
{
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId.ToString());
var repository = new UnknownsRepository(connection);
var now = new DateTimeOffset(2025, 1, 2, 3, 4, 5, TimeSpan.Zero);
var unknown = CreateUnknown(
reasonCode: UnknownReasonCode.Reachability,
remediationHint: "Run reachability analysis",
evidenceRefs: new List<EvidenceRef>
{
new("reachability", "proofs/unknowns/unk-123/evidence.json", "sha256:abc123")
},
assumptions: new List<string> { "assume-dynamic-imports" },
timestamp: now);
var created = await repository.CreateAsync(unknown);
var fetched = await repository.GetByIdAsync(_tenantId, created.Id);
fetched.Should().NotBeNull();
fetched!.ReasonCode.Should().Be(UnknownReasonCode.Reachability);
fetched.RemediationHint.Should().Be("Run reachability analysis");
fetched.EvidenceRefs.Should().ContainSingle();
fetched.EvidenceRefs[0].Type.Should().Be("reachability");
fetched.EvidenceRefs[0].Uri.Should().Contain("evidence.json");
fetched.Assumptions.Should().ContainSingle("assume-dynamic-imports");
}
[Fact]
public async Task UpdateAsync_PersistsReasonCodeAndAssumptions()
{
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId.ToString());
var repository = new UnknownsRepository(connection);
var now = new DateTimeOffset(2025, 2, 3, 4, 5, 6, TimeSpan.Zero);
var unknown = CreateUnknown(
reasonCode: UnknownReasonCode.Identity,
remediationHint: null,
evidenceRefs: Array.Empty<EvidenceRef>(),
assumptions: Array.Empty<string>(),
timestamp: now);
var created = await repository.CreateAsync(unknown);
var updated = created with
{
ReasonCode = UnknownReasonCode.VexConflict,
RemediationHint = "Publish authoritative VEX",
EvidenceRefs = new List<EvidenceRef>
{
new("vex", "proofs/unknowns/unk-123/vex.json", "sha256:def456")
},
Assumptions = new List<string> { "assume-vex-defaults" }
};
var result = await repository.UpdateAsync(updated);
var fetched = await repository.GetByIdAsync(_tenantId, created.Id);
result.Should().BeTrue();
fetched.Should().NotBeNull();
fetched!.ReasonCode.Should().Be(UnknownReasonCode.VexConflict);
fetched.RemediationHint.Should().Be("Publish authoritative VEX");
fetched.Assumptions.Should().ContainSingle("assume-vex-defaults");
}
private Unknown CreateUnknown(
UnknownReasonCode reasonCode,
string? remediationHint,
IReadOnlyList<EvidenceRef> evidenceRefs,
IReadOnlyList<string> assumptions,
DateTimeOffset timestamp) => new()
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
PackageId = "pkg:npm/lodash",
PackageVersion = "4.17.21",
Band = UnknownBand.Hot,
Score = 90.5m,
UncertaintyFactor = 0.75m,
ExploitPressure = 0.9m,
ReasonCode = reasonCode,
RemediationHint = remediationHint,
EvidenceRefs = evidenceRefs,
Assumptions = assumptions,
FirstSeenAt = timestamp,
LastEvaluatedAt = timestamp,
CreatedAt = timestamp,
UpdatedAt = timestamp
};
}

View File

@@ -0,0 +1,165 @@
using FluentAssertions;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Confidence.Configuration;
using StellaOps.Policy.Confidence.Models;
using StellaOps.Policy.Confidence.Services;
using Xunit;
namespace StellaOps.Policy.Tests.Confidence;
public sealed class ConfidenceCalculatorTests
{
private static readonly DateTimeOffset FixedTimestamp = new(2025, 12, 22, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void Calculate_AllHighFactors_ReturnsVeryHighConfidence()
{
var calculator = CreateCalculator();
var input = CreateInput(
reachability: ReachabilityState.ConfirmedUnreachable,
runtime: RuntimePosture.Supports,
vex: VexStatus.NotAffected,
provenance: ProvenanceLevel.SlsaLevel3,
policyStrength: 1.0m);
var result = calculator.Calculate(input);
result.Tier.Should().Be(ConfidenceTier.VeryHigh);
result.Value.Should().BeGreaterThanOrEqualTo(0.9m);
}
[Fact]
public void Calculate_AllLowFactors_ReturnsLowConfidence()
{
var calculator = CreateCalculator();
var input = CreateInput(
reachability: ReachabilityState.Unknown,
runtime: RuntimePosture.Contradicts,
vex: VexStatus.UnderInvestigation,
provenance: ProvenanceLevel.Unsigned,
policyStrength: 0.3m);
var result = calculator.Calculate(input);
result.Tier.Should().Be(ConfidenceTier.Low);
}
[Fact]
public void Calculate_MissingEvidence_UsesFallbackValues()
{
var calculator = CreateCalculator();
var input = new ConfidenceInput();
var result = calculator.Calculate(input);
result.Value.Should().BeApproximately(0.47m, 0.05m);
result.Factors.Should().AllSatisfy(f => f.Reason.Should().Contain("No"));
}
[Fact]
public void Calculate_GeneratesImprovements_ForLowFactors()
{
var calculator = CreateCalculator();
var input = CreateInput(reachability: ReachabilityState.Unknown);
var result = calculator.Calculate(input);
result.Improvements.Should().Contain(i => i.Factor == ConfidenceFactorType.Reachability);
}
[Fact]
public void Calculate_WeightsSumToOne()
{
var options = new ConfidenceWeightOptions();
options.Validate().Should().BeTrue();
}
[Fact]
public void Calculate_FactorContributions_SumToValue()
{
var calculator = CreateCalculator();
var input = CreateInput();
var result = calculator.Calculate(input);
var sumOfContributions = result.Factors.Sum(f => f.Contribution);
result.Value.Should().BeApproximately(sumOfContributions, 0.001m);
}
private static ConfidenceInput CreateInput(
ReachabilityState reachability = ReachabilityState.StaticUnreachable,
RuntimePosture runtime = RuntimePosture.Supports,
VexStatus vex = VexStatus.NotAffected,
ProvenanceLevel provenance = ProvenanceLevel.Signed,
decimal policyStrength = 0.8m)
{
return new ConfidenceInput
{
Reachability = new ReachabilityEvidence
{
State = reachability,
AnalysisConfidence = 1.0m,
GraphDigests = ["sha256:reachability"]
},
Runtime = new RuntimeEvidence
{
Posture = runtime,
ObservationCount = 3,
LastObserved = FixedTimestamp,
SessionDigests = ["sha256:runtime"]
},
Vex = new VexEvidence
{
Statements =
[
new VexStatement
{
Status = vex,
Issuer = "NVD",
TrustScore = 0.95m,
Timestamp = FixedTimestamp,
StatementDigest = "sha256:vex"
}
]
},
Provenance = new ProvenanceEvidence
{
Level = provenance,
SbomCompleteness = 0.95m,
AttestationDigests = ["sha256:attestation"]
},
Policy = new PolicyEvidence
{
RuleName = "rule-1",
MatchStrength = policyStrength,
EvaluationDigest = "sha256:policy"
},
EvaluationTimestamp = FixedTimestamp
};
}
private static ConfidenceCalculator CreateCalculator()
{
return new ConfidenceCalculator(new StaticOptionsMonitor<ConfidenceWeightOptions>(new ConfidenceWeightOptions()));
}
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
{
private readonly T _value;
public StaticOptionsMonitor(T value) => _value = value;
public T CurrentValue => _value;
public T Get(string? name) => _value;
public IDisposable OnChange(Action<T, string> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static readonly NullDisposable Instance = new();
public void Dispose() { }
}
}
}

View File

@@ -0,0 +1,208 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Freshness;
using Xunit;
namespace StellaOps.Policy.Tests.Freshness;
public sealed class EvidenceTtlEnforcerTests
{
private readonly EvidenceTtlEnforcer _enforcer;
private readonly EvidenceTtlOptions _options;
public EvidenceTtlEnforcerTests()
{
_options = new EvidenceTtlOptions();
_enforcer = new EvidenceTtlEnforcer(
Options.Create(_options),
NullLogger<EvidenceTtlEnforcer>.Instance);
}
[Fact]
public void CheckFreshness_AllFresh_ReturnsFresh()
{
var now = DateTimeOffset.UtcNow;
var bundle = new EvidenceBundle
{
Reachability = new ReachabilityEvidence { ComputedAt = now.AddHours(-1) },
VexStatus = new VexEvidence { Timestamp = now.AddHours(-1) },
Provenance = new ProvenanceEvidence { BuildTime = now.AddDays(-1) }
};
var result = _enforcer.CheckFreshness(bundle, now);
Assert.Equal(FreshnessStatus.Fresh, result.OverallStatus);
Assert.True(result.IsAcceptable);
Assert.False(result.HasWarnings);
}
[Fact]
public void CheckFreshness_ReachabilityNearExpiry_ReturnsWarning()
{
var now = DateTimeOffset.UtcNow;
var bundle = new EvidenceBundle
{
// 7 day TTL, 20% warning threshold = warn after 5.6 days
// At 6 days old, should be in warning state
Reachability = new ReachabilityEvidence { ComputedAt = now.AddDays(-6) }
};
var result = _enforcer.CheckFreshness(bundle, now);
Assert.Equal(FreshnessStatus.Warning, result.OverallStatus);
Assert.True(result.IsAcceptable);
Assert.True(result.HasWarnings);
var reachabilityCheck = result.Checks.First(c => c.Type == EvidenceType.Reachability);
Assert.Equal(FreshnessStatus.Warning, reachabilityCheck.Status);
}
[Fact]
public void CheckFreshness_BoundaryExpired_ReturnsStale()
{
var now = DateTimeOffset.UtcNow;
var bundle = new EvidenceBundle
{
// 72 hour TTL, so 5 days is definitely expired
Boundary = new BoundaryEvidence { ObservedAt = now.AddDays(-5) }
};
var result = _enforcer.CheckFreshness(bundle, now);
Assert.Equal(FreshnessStatus.Stale, result.OverallStatus);
Assert.False(result.IsAcceptable);
var boundaryCheck = result.Checks.First(c => c.Type == EvidenceType.Boundary);
Assert.Equal(FreshnessStatus.Stale, boundaryCheck.Status);
Assert.True(boundaryCheck.Remaining == TimeSpan.Zero);
}
[Theory]
[InlineData(EvidenceType.Sbom, 30)]
[InlineData(EvidenceType.Boundary, 3)]
[InlineData(EvidenceType.Reachability, 7)]
[InlineData(EvidenceType.Vex, 14)]
[InlineData(EvidenceType.PolicyDecision, 1)]
[InlineData(EvidenceType.HumanApproval, 30)]
[InlineData(EvidenceType.CallStack, 7)]
public void GetTtl_ReturnsConfiguredValue(EvidenceType type, int expectedDays)
{
var ttl = _enforcer.GetTtl(type);
Assert.Equal(expectedDays, ttl.TotalDays, precision: 1);
}
[Fact]
public void CheckFreshness_MixedStates_ReturnsStaleOverall()
{
var now = DateTimeOffset.UtcNow;
var bundle = new EvidenceBundle
{
Reachability = new ReachabilityEvidence { ComputedAt = now.AddHours(-1) }, // Fresh
Boundary = new BoundaryEvidence { ObservedAt = now.AddDays(-5) }, // Stale (72h TTL)
VexStatus = new VexEvidence { Timestamp = now.AddDays(-2) } // Fresh
};
var result = _enforcer.CheckFreshness(bundle, now);
Assert.Equal(FreshnessStatus.Stale, result.OverallStatus);
Assert.False(result.IsAcceptable);
Assert.Equal(3, result.Checks.Count);
var freshChecks = result.Checks.Where(c => c.Status == FreshnessStatus.Fresh).ToList();
var staleChecks = result.Checks.Where(c => c.Status == FreshnessStatus.Stale).ToList();
Assert.Equal(2, freshChecks.Count);
Assert.Single(staleChecks);
Assert.Equal(EvidenceType.Boundary, staleChecks[0].Type);
}
[Fact]
public void ComputeExpiration_CalculatesCorrectly()
{
var createdAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
var expiresAt = _enforcer.ComputeExpiration(EvidenceType.Boundary, createdAt);
// Boundary TTL is 72 hours = 3 days
Assert.Equal(createdAt.AddHours(72), expiresAt);
}
[Fact]
public void CheckFreshness_EmptyBundle_ReturnsEmptyChecks()
{
var now = DateTimeOffset.UtcNow;
var bundle = new EvidenceBundle();
var result = _enforcer.CheckFreshness(bundle, now);
Assert.Equal(FreshnessStatus.Fresh, result.OverallStatus);
Assert.Empty(result.Checks);
}
[Fact]
public void CheckFreshness_CustomOptions_UsesCustomTtl()
{
var customOptions = new EvidenceTtlOptions
{
BoundaryTtl = TimeSpan.FromDays(1), // Custom: 1 day instead of default 3 days
WarningThresholdPercent = 0.5 // Custom: 50% instead of default 20%
};
var customEnforcer = new EvidenceTtlEnforcer(
Options.Create(customOptions),
NullLogger<EvidenceTtlEnforcer>.Instance);
var now = DateTimeOffset.UtcNow;
var bundle = new EvidenceBundle
{
// 1 day TTL with 50% warning threshold = warn after 12 hours
// At 16 hours old, should be in warning state
Boundary = new BoundaryEvidence { ObservedAt = now.AddHours(-16) }
};
var result = customEnforcer.CheckFreshness(bundle, now);
Assert.Equal(FreshnessStatus.Warning, result.OverallStatus);
}
[Fact]
public void CheckType_GeneratesCorrectMessage()
{
var now = DateTimeOffset.UtcNow;
var bundle = new EvidenceBundle
{
Reachability = new ReachabilityEvidence { ComputedAt = now.AddDays(-8) } // Expired (7 day TTL)
};
var result = _enforcer.CheckFreshness(bundle, now);
var check = result.Checks.First();
Assert.Equal(EvidenceType.Reachability, check.Type);
Assert.Contains("expired", check.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("ago", check.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void CheckFreshness_RecommendedAction_BasedOnConfiguration()
{
var blockOptions = new EvidenceTtlOptions
{
StaleAction = StaleEvidenceAction.Block
};
var blockEnforcer = new EvidenceTtlEnforcer(
Options.Create(blockOptions),
NullLogger<EvidenceTtlEnforcer>.Instance);
var now = DateTimeOffset.UtcNow;
var bundle = new EvidenceBundle
{
Boundary = new BoundaryEvidence { ObservedAt = now.AddDays(-5) } // Stale
};
var result = blockEnforcer.CheckFreshness(bundle, now);
Assert.Equal(StaleEvidenceAction.Block, result.RecommendedAction);
}
}

View File

@@ -0,0 +1,98 @@
using FluentAssertions;
using StellaOps.Policy.TrustLattice;
using VexStatus = StellaOps.Policy.Confidence.Models.VexStatus;
using Xunit;
namespace StellaOps.Policy.Tests.TrustLattice;
public sealed class ClaimScoreMergerTests
{
[Fact]
public void Merge_SelectsHighestScore()
{
var claims = new List<(VexClaim, ClaimScoreResult)>
{
(new VexClaim
{
SourceId = "source-a",
Status = VexStatus.NotAffected,
ScopeSpecificity = 2,
IssuedAt = DateTimeOffset.Parse("2025-01-01T00:00:00Z"),
}, new ClaimScoreResult { Score = 0.7, BaseTrust = 0.7, StrengthMultiplier = 1, FreshnessMultiplier = 1 }),
(new VexClaim
{
SourceId = "source-b",
Status = VexStatus.NotAffected,
ScopeSpecificity = 3,
IssuedAt = DateTimeOffset.Parse("2025-01-02T00:00:00Z"),
}, new ClaimScoreResult { Score = 0.9, BaseTrust = 0.9, StrengthMultiplier = 1, FreshnessMultiplier = 1 }),
};
var merger = new ClaimScoreMerger();
var result = merger.Merge(claims, new MergePolicy());
result.Status.Should().Be(VexStatus.NotAffected);
result.WinningClaim.SourceId.Should().Be("source-b");
result.Confidence.Should().Be(0.9);
}
[Fact]
public void Merge_AppliesConflictPenalty()
{
var claims = new List<(VexClaim, ClaimScoreResult)>
{
(new VexClaim
{
SourceId = "source-a",
Status = VexStatus.NotAffected,
ScopeSpecificity = 2,
IssuedAt = DateTimeOffset.Parse("2025-01-01T00:00:00Z"),
}, new ClaimScoreResult { Score = 0.8, BaseTrust = 0.8, StrengthMultiplier = 1, FreshnessMultiplier = 1 }),
(new VexClaim
{
SourceId = "source-b",
Status = VexStatus.Affected,
ScopeSpecificity = 1,
IssuedAt = DateTimeOffset.Parse("2025-01-01T00:00:00Z"),
}, new ClaimScoreResult { Score = 0.7, BaseTrust = 0.7, StrengthMultiplier = 1, FreshnessMultiplier = 1 }),
};
var merger = new ClaimScoreMerger();
var result = merger.Merge(claims, new MergePolicy { ConflictPenalty = 0.25 });
result.HasConflicts.Should().BeTrue();
result.RequiresReplayProof.Should().BeTrue();
result.Conflicts.Should().HaveCount(1);
result.AllClaims.Should().Contain(c => c.SourceId == "source-b" && c.AdjustedScore == 0.525);
}
[Fact]
public void Merge_IsDeterministic()
{
var claims = new List<(VexClaim, ClaimScoreResult)>
{
(new VexClaim
{
SourceId = "source-a",
Status = VexStatus.Fixed,
ScopeSpecificity = 1,
IssuedAt = DateTimeOffset.Parse("2025-01-01T00:00:00Z"),
}, new ClaimScoreResult { Score = 0.6, BaseTrust = 0.6, StrengthMultiplier = 1, FreshnessMultiplier = 1 }),
(new VexClaim
{
SourceId = "source-b",
Status = VexStatus.Fixed,
ScopeSpecificity = 1,
IssuedAt = DateTimeOffset.Parse("2025-01-01T00:00:00Z"),
}, new ClaimScoreResult { Score = 0.6, BaseTrust = 0.6, StrengthMultiplier = 1, FreshnessMultiplier = 1 }),
};
var merger = new ClaimScoreMerger();
var expected = merger.Merge(claims, new MergePolicy());
for (var i = 0; i < 1000; i++)
{
merger.Merge(claims, new MergePolicy()).WinningClaim.SourceId.Should().Be(expected.WinningClaim.SourceId);
}
}
}

View File

@@ -0,0 +1,98 @@
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Policy.Gates;
using StellaOps.Policy.TrustLattice;
using VexStatus = StellaOps.Policy.Confidence.Models.VexStatus;
using Xunit;
namespace StellaOps.Policy.Tests.TrustLattice;
public sealed class PolicyGateRegistryTests
{
[Fact]
public async Task Registry_StopsOnFirstFailure()
{
var registry = new PolicyGateRegistry(new StubServiceProvider(), new PolicyGateRegistryOptions { StopOnFirstFailure = true });
registry.Register<FailingGate>("fail");
registry.Register<PassingGate>("pass");
var mergeResult = CreateMergeResult();
var context = new PolicyGateContext();
var evaluation = await registry.EvaluateAsync(mergeResult, context);
evaluation.Results.Should().HaveCount(1);
evaluation.Results[0].GateName.Should().Be("fail");
evaluation.AllPassed.Should().BeFalse();
}
[Fact]
public async Task Registry_CollectsAllWhenConfigured()
{
var registry = new PolicyGateRegistry(new StubServiceProvider(), new PolicyGateRegistryOptions { StopOnFirstFailure = false });
registry.Register<FailingGate>("fail");
registry.Register<PassingGate>("pass");
var mergeResult = CreateMergeResult();
var context = new PolicyGateContext();
var evaluation = await registry.EvaluateAsync(mergeResult, context);
evaluation.Results.Should().HaveCount(2);
evaluation.Results.Select(r => r.GateName).Should().ContainInOrder("fail", "pass");
}
private static MergeResult CreateMergeResult()
{
var winner = new ScoredClaim
{
SourceId = "source",
Status = VexStatus.NotAffected,
OriginalScore = 0.9,
AdjustedScore = 0.9,
ScopeSpecificity = 1,
Accepted = true,
Reason = "winner",
};
return new MergeResult
{
Status = VexStatus.NotAffected,
Confidence = 0.9,
HasConflicts = false,
RequiresReplayProof = false,
WinningClaim = winner,
AllClaims = ImmutableArray.Create(winner),
Conflicts = ImmutableArray<ConflictRecord>.Empty,
};
}
private sealed class StubServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
private sealed class FailingGate : IPolicyGate
{
public Task<GateResult> EvaluateAsync(MergeResult mergeResult, PolicyGateContext context, CancellationToken ct = default)
=> Task.FromResult(new GateResult
{
GateName = nameof(FailingGate),
Passed = false,
Reason = "fail",
Details = ImmutableDictionary<string, object>.Empty,
});
}
private sealed class PassingGate : IPolicyGate
{
public Task<GateResult> EvaluateAsync(MergeResult mergeResult, PolicyGateContext context, CancellationToken ct = default)
=> Task.FromResult(new GateResult
{
GateName = nameof(PassingGate),
Passed = true,
Reason = null,
Details = ImmutableDictionary<string, object>.Empty,
});
}
}

View File

@@ -0,0 +1,133 @@
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Policy.Gates;
using StellaOps.Policy.TrustLattice;
using VexStatus = StellaOps.Policy.Confidence.Models.VexStatus;
using Xunit;
namespace StellaOps.Policy.Tests.TrustLattice;
public sealed class PolicyGatesTests
{
[Fact]
public async Task MinimumConfidenceGate_FailsBelowThreshold()
{
var gate = new MinimumConfidenceGate();
var mergeResult = CreateMergeResult(VexStatus.NotAffected, 0.7);
var context = new PolicyGateContext { Environment = "production" };
var result = await gate.EvaluateAsync(mergeResult, context);
result.Passed.Should().BeFalse();
result.Reason.Should().Be("confidence_below_threshold");
}
[Fact]
public async Task UnknownsBudgetGate_FailsWhenBudgetExceeded()
{
var gate = new UnknownsBudgetGate(new UnknownsBudgetGateOptions { MaxUnknownCount = 1, MaxCumulativeUncertainty = 0.5 });
var mergeResult = CreateMergeResult(VexStatus.NotAffected, 0.9);
var context = new PolicyGateContext
{
UnknownCount = 2,
UnknownClaimScores = new[] { 0.4, 0.3 }
};
var result = await gate.EvaluateAsync(mergeResult, context);
result.Passed.Should().BeFalse();
result.Reason.Should().Be("unknowns_budget_exceeded");
}
[Fact]
public async Task SourceQuotaGate_FailsWithoutCorroboration()
{
var gate = new SourceQuotaGate(new SourceQuotaGateOptions { MaxInfluencePercent = 60, CorroborationDelta = 0.10 });
var mergeResult = new MergeResult
{
Status = VexStatus.NotAffected,
Confidence = 0.8,
HasConflicts = false,
RequiresReplayProof = false,
WinningClaim = new ScoredClaim
{
SourceId = "source-a",
Status = VexStatus.NotAffected,
OriginalScore = 0.9,
AdjustedScore = 0.9,
ScopeSpecificity = 1,
Accepted = true,
Reason = "winner",
},
AllClaims = ImmutableArray.Create(
new ScoredClaim
{
SourceId = "source-a",
Status = VexStatus.NotAffected,
OriginalScore = 0.9,
AdjustedScore = 0.9,
ScopeSpecificity = 1,
Accepted = true,
Reason = "winner",
},
new ScoredClaim
{
SourceId = "source-b",
Status = VexStatus.NotAffected,
OriginalScore = 0.1,
AdjustedScore = 0.1,
ScopeSpecificity = 1,
Accepted = false,
Reason = "initial",
}),
Conflicts = ImmutableArray<ConflictRecord>.Empty,
};
var result = await gate.EvaluateAsync(mergeResult, new PolicyGateContext());
result.Passed.Should().BeFalse();
result.Reason.Should().Be("source_quota_exceeded");
}
[Fact]
public async Task ReachabilityRequirementGate_FailsWithoutProof()
{
var gate = new ReachabilityRequirementGate();
var mergeResult = CreateMergeResult(VexStatus.NotAffected, 0.9);
var context = new PolicyGateContext
{
Severity = "CRITICAL",
HasReachabilityProof = false,
};
var result = await gate.EvaluateAsync(mergeResult, context);
result.Passed.Should().BeFalse();
result.Reason.Should().Be("reachability_proof_missing");
}
private static MergeResult CreateMergeResult(VexStatus status, double confidence)
{
var winner = new ScoredClaim
{
SourceId = "source-a",
Status = status,
OriginalScore = confidence,
AdjustedScore = confidence,
ScopeSpecificity = 1,
Accepted = true,
Reason = "winner",
};
return new MergeResult
{
Status = status,
Confidence = confidence,
HasConflicts = false,
RequiresReplayProof = false,
WinningClaim = winner,
AllClaims = ImmutableArray.Create(winner),
Conflicts = ImmutableArray<ConflictRecord>.Empty,
};
}
}

View File

@@ -0,0 +1,221 @@
using System.Collections.Immutable;
using System.Linq;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Unknowns.Configuration;
using StellaOps.Policy.Unknowns.Models;
using StellaOps.Policy.Unknowns.Services;
using Xunit;
namespace StellaOps.Policy.Unknowns.Tests.Services;
public sealed class UnknownBudgetServiceTests
{
[Fact]
public void GetBudgetForEnvironment_KnownEnv_ReturnsBudget()
{
var service = CreateService(new UnknownBudget
{
Environment = "prod",
TotalLimit = 3
});
var budget = service.GetBudgetForEnvironment("prod");
budget.TotalLimit.Should().Be(3);
budget.Environment.Should().Be("prod");
}
[Fact]
public void CheckBudget_WithinLimit_ReturnsSuccess()
{
var service = CreateService(new UnknownBudget
{
Environment = "prod",
TotalLimit = 3,
Action = BudgetAction.Block
});
var result = service.CheckBudget("prod", CreateUnknowns(count: 2));
result.IsWithinBudget.Should().BeTrue();
}
[Fact]
public void CheckBudget_ExceedsTotal_ReturnsViolation()
{
var service = CreateService(new UnknownBudget
{
Environment = "prod",
TotalLimit = 3,
Action = BudgetAction.Block
});
var result = service.CheckBudget("prod", CreateUnknowns(count: 5));
result.IsWithinBudget.Should().BeFalse();
result.RecommendedAction.Should().Be(BudgetAction.Block);
result.TotalUnknowns.Should().Be(5);
}
[Fact]
public void CheckBudget_ExceedsReasonLimit_ReturnsSpecificViolation()
{
var service = CreateService(new UnknownBudget
{
Environment = "prod",
TotalLimit = 5,
ReasonLimits = new Dictionary<UnknownReasonCode, int>
{
[UnknownReasonCode.Reachability] = 0
},
Action = BudgetAction.Block
});
var unknowns = CreateUnknowns(reachability: 2, identity: 1);
var result = service.CheckBudget("prod", unknowns);
result.Violations.Should().ContainKey(UnknownReasonCode.Reachability);
result.Violations[UnknownReasonCode.Reachability].Count.Should().Be(2);
}
[Fact]
public void CheckBudgetWithEscalation_ExceptionCovers_AllowsOperation()
{
var service = CreateService(new UnknownBudget
{
Environment = "prod",
TotalLimit = 1,
ReasonLimits = new Dictionary<UnknownReasonCode, int>
{
[UnknownReasonCode.Reachability] = 0
},
Action = BudgetAction.WarnUnlessException
});
var unknowns = CreateUnknowns(reachability: 1);
var exceptions = new[] { CreateException(UnknownReasonCode.Reachability) };
var result = service.CheckBudgetWithEscalation("prod", unknowns, exceptions);
result.IsWithinBudget.Should().BeTrue();
result.Message.Should().Contain("covered by approved exceptions");
}
[Fact]
public void ShouldBlock_BlockAction_ReturnsTrue()
{
var service = CreateService(new UnknownBudget { Environment = "prod" });
var result = new BudgetCheckResult
{
IsWithinBudget = false,
RecommendedAction = BudgetAction.Block,
TotalUnknowns = 4
};
service.ShouldBlock(result).Should().BeTrue();
}
private static UnknownBudgetService CreateService(UnknownBudget prodBudget)
{
var options = new UnknownBudgetOptions
{
Budgets = new Dictionary<string, UnknownBudget>(StringComparer.OrdinalIgnoreCase)
{
["prod"] = prodBudget,
["default"] = new UnknownBudget
{
Environment = "default",
TotalLimit = 5,
Action = BudgetAction.Warn
}
}
};
return new UnknownBudgetService(
new TestOptionsMonitor<UnknownBudgetOptions>(options),
NullLogger<UnknownBudgetService>.Instance);
}
private static IReadOnlyList<Unknown> CreateUnknowns(
int count = 0,
int reachability = 0,
int identity = 0)
{
var results = new List<Unknown>();
results.AddRange(Enumerable.Range(0, reachability).Select(_ => CreateUnknown(UnknownReasonCode.Reachability)));
results.AddRange(Enumerable.Range(0, identity).Select(_ => CreateUnknown(UnknownReasonCode.Identity)));
var remaining = Math.Max(0, count - results.Count);
results.AddRange(Enumerable.Range(0, remaining).Select(_ => CreateUnknown(UnknownReasonCode.FeedGap)));
return results;
}
private static Unknown CreateUnknown(UnknownReasonCode reasonCode)
{
var timestamp = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
return new Unknown
{
Id = Guid.NewGuid(),
TenantId = Guid.NewGuid(),
PackageId = "pkg:npm/lodash",
PackageVersion = "4.17.21",
Band = UnknownBand.Hot,
Score = 80m,
UncertaintyFactor = 0.5m,
ExploitPressure = 0.7m,
ReasonCode = reasonCode,
FirstSeenAt = timestamp,
LastEvaluatedAt = timestamp,
CreatedAt = timestamp,
UpdatedAt = timestamp
};
}
private static ExceptionObject CreateException(UnknownReasonCode reasonCode)
{
var now = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
return new ExceptionObject
{
ExceptionId = "EXC-UNKNOWN-001",
Version = 1,
Status = ExceptionStatus.Approved,
Type = ExceptionType.Unknown,
Scope = new ExceptionScope
{
Environments = ImmutableArray.Create("prod")
},
OwnerId = "owner",
RequesterId = "requester",
CreatedAt = now,
UpdatedAt = now,
ExpiresAt = now.AddDays(30),
ReasonCode = ExceptionReason.AcceptedRisk,
Rationale = "Approved exception for unknown budget coverage",
Metadata = ImmutableDictionary<string, string>.Empty
.Add("unknown_reason_codes", reasonCode.ToString())
};
}
private sealed class TestOptionsMonitor<T>(T current) : IOptionsMonitor<T>
{
private readonly T _current = current;
public T CurrentValue => _current;
public T Get(string? name) => _current;
public IDisposable OnChange(Action<T, string?> listener) => NoopDisposable.Instance;
}
private sealed class NoopDisposable : IDisposable
{
public static readonly NoopDisposable Instance = new();
public void Dispose() { }
}
}

View File

@@ -1,4 +1,5 @@
using FluentAssertions;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Unknowns.Models;
using StellaOps.Policy.Unknowns.Services;
@@ -10,6 +11,7 @@ namespace StellaOps.Policy.Unknowns.Tests.Services;
public class UnknownRankerTests
{
private readonly UnknownRanker _ranker = new();
private static readonly DateTimeOffset DefaultAsOf = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
#region Determinism Tests
@@ -17,7 +19,7 @@ public class UnknownRankerTests
public void Rank_SameInput_ReturnsSameResult()
{
// Arrange
var input = new UnknownRankInput(
var input = CreateInput(
HasVexStatement: false,
HasReachabilityData: false,
HasConflictingSources: true,
@@ -38,7 +40,7 @@ public class UnknownRankerTests
public void Rank_MultipleExecutions_ProducesIdenticalScores()
{
// Arrange
var input = new UnknownRankInput(
var input = CreateInput(
HasVexStatement: true,
HasReachabilityData: false,
HasConflictingSources: false,
@@ -67,7 +69,7 @@ public class UnknownRankerTests
public void ComputeUncertainty_MissingVex_Adds040()
{
// Arrange
var input = new UnknownRankInput(
var input = CreateInput(
HasVexStatement: false, // Missing VEX = +0.40
HasReachabilityData: true,
HasConflictingSources: false,
@@ -87,7 +89,7 @@ public class UnknownRankerTests
public void ComputeUncertainty_MissingReachability_Adds030()
{
// Arrange
var input = new UnknownRankInput(
var input = CreateInput(
HasVexStatement: true,
HasReachabilityData: false, // Missing reachability = +0.30
HasConflictingSources: false,
@@ -107,7 +109,7 @@ public class UnknownRankerTests
public void ComputeUncertainty_ConflictingSources_Adds020()
{
// Arrange
var input = new UnknownRankInput(
var input = CreateInput(
HasVexStatement: true,
HasReachabilityData: true,
HasConflictingSources: true, // Conflicts = +0.20
@@ -127,7 +129,7 @@ public class UnknownRankerTests
public void ComputeUncertainty_StaleAdvisory_Adds010()
{
// Arrange
var input = new UnknownRankInput(
var input = CreateInput(
HasVexStatement: true,
HasReachabilityData: true,
HasConflictingSources: false,
@@ -147,7 +149,7 @@ public class UnknownRankerTests
public void ComputeUncertainty_AllFactors_SumsTo100()
{
// Arrange - All uncertainty factors active (0.40 + 0.30 + 0.20 + 0.10 = 1.00)
var input = new UnknownRankInput(
var input = CreateInput(
HasVexStatement: false,
HasReachabilityData: false,
HasConflictingSources: true,
@@ -167,7 +169,7 @@ public class UnknownRankerTests
public void ComputeUncertainty_NoFactors_ReturnsZero()
{
// Arrange - All uncertainty factors inactive
var input = new UnknownRankInput(
var input = CreateInput(
HasVexStatement: true,
HasReachabilityData: true,
HasConflictingSources: false,
@@ -191,7 +193,7 @@ public class UnknownRankerTests
public void ComputeExploitPressure_InKev_Adds050()
{
// Arrange
var input = new UnknownRankInput(
var input = CreateInput(
HasVexStatement: true,
HasReachabilityData: true,
HasConflictingSources: false,
@@ -211,7 +213,7 @@ public class UnknownRankerTests
public void ComputeExploitPressure_HighEpss_Adds030()
{
// Arrange
var input = new UnknownRankInput(
var input = CreateInput(
HasVexStatement: true,
HasReachabilityData: true,
HasConflictingSources: false,
@@ -231,7 +233,7 @@ public class UnknownRankerTests
public void ComputeExploitPressure_MediumEpss_Adds015()
{
// Arrange
var input = new UnknownRankInput(
var input = CreateInput(
HasVexStatement: true,
HasReachabilityData: true,
HasConflictingSources: false,
@@ -251,7 +253,7 @@ public class UnknownRankerTests
public void ComputeExploitPressure_CriticalCvss_Adds005()
{
// Arrange
var input = new UnknownRankInput(
var input = CreateInput(
HasVexStatement: true,
HasReachabilityData: true,
HasConflictingSources: false,
@@ -271,7 +273,7 @@ public class UnknownRankerTests
public void ComputeExploitPressure_AllFactors_SumsCorrectly()
{
// Arrange - KEV (0.50) + high EPSS (0.30) + critical CVSS (0.05) = 0.85
var input = new UnknownRankInput(
var input = CreateInput(
HasVexStatement: true,
HasReachabilityData: true,
HasConflictingSources: false,
@@ -291,7 +293,7 @@ public class UnknownRankerTests
public void ComputeExploitPressure_EpssThresholds_AreMutuallyExclusive()
{
// Arrange - High EPSS should NOT also add medium EPSS bonus
var input = new UnknownRankInput(
var input = CreateInput(
HasVexStatement: true,
HasReachabilityData: true,
HasConflictingSources: false,
@@ -318,7 +320,7 @@ public class UnknownRankerTests
// Uncertainty: 0.40 (missing VEX)
// Pressure: 0.50 (KEV)
// Expected: (0.40 × 50) + (0.50 × 50) = 20 + 25 = 45
var input = new UnknownRankInput(
var input = CreateInput(
HasVexStatement: false,
HasReachabilityData: true,
HasConflictingSources: false,
@@ -341,7 +343,7 @@ public class UnknownRankerTests
// Uncertainty: 1.00 (all factors)
// Pressure: 0.85 (KEV + high EPSS + critical CVSS, capped at 1.00)
// Expected: (1.00 × 50) + (0.85 × 50) = 50 + 42.5 = 92.50
var input = new UnknownRankInput(
var input = CreateInput(
HasVexStatement: false,
HasReachabilityData: false,
HasConflictingSources: true,
@@ -361,7 +363,7 @@ public class UnknownRankerTests
public void Rank_MinimumScore_IsZero()
{
// Arrange - No uncertainty, no pressure
var input = new UnknownRankInput(
var input = CreateInput(
HasVexStatement: true,
HasReachabilityData: true,
HasConflictingSources: false,
@@ -379,6 +381,198 @@ public class UnknownRankerTests
#endregion
#region Reason Code Tests
[Fact]
public void Rank_AnalyzerUnsupported_AssignsAnalyzerLimit()
{
var input = CreateInput(
HasVexStatement: true,
HasReachabilityData: true,
HasConflictingSources: false,
IsStaleAdvisory: false,
IsInKev: false,
EpssScore: 0,
CvssScore: 0,
IsAnalyzerSupported: false);
var result = _ranker.Rank(input);
result.ReasonCode.Should().Be(UnknownReasonCode.AnalyzerLimit);
}
[Fact]
public void Rank_MissingReachability_AssignsReachability()
{
var input = CreateInput(
HasVexStatement: true,
HasReachabilityData: false,
HasConflictingSources: false,
IsStaleAdvisory: false,
IsInKev: false,
EpssScore: 0,
CvssScore: 0);
var result = _ranker.Rank(input);
result.ReasonCode.Should().Be(UnknownReasonCode.Reachability);
}
[Fact]
public void Rank_MissingDigest_AssignsIdentity()
{
var input = CreateInput(
HasVexStatement: true,
HasReachabilityData: true,
HasConflictingSources: false,
IsStaleAdvisory: false,
IsInKev: false,
EpssScore: 0,
CvssScore: 0,
HasPackageDigest: false);
var result = _ranker.Rank(input);
result.ReasonCode.Should().Be(UnknownReasonCode.Identity);
}
#endregion
#region Decay Factor Tests
[Fact]
public void ComputeDecay_NullLastEvaluated_Returns100Percent()
{
var input = CreateInputWithAge(lastEvaluatedAt: null);
var result = _ranker.Rank(input);
result.DecayFactor.Should().Be(1.00m);
}
[Theory]
[InlineData(0, 1.00)]
[InlineData(7, 1.00)]
[InlineData(8, 0.90)]
[InlineData(30, 0.90)]
[InlineData(31, 0.75)]
[InlineData(90, 0.75)]
[InlineData(91, 0.60)]
[InlineData(180, 0.60)]
[InlineData(181, 0.40)]
[InlineData(365, 0.40)]
[InlineData(366, 0.20)]
[InlineData(1000, 0.20)]
public void ComputeDecay_AgeBuckets_ReturnsCorrectMultiplier(int ageDays, decimal expected)
{
var input = CreateInputWithAge(ageDays: ageDays);
var result = _ranker.Rank(input);
result.DecayFactor.Should().Be(expected);
}
[Fact]
public void Rank_WithDecay_AppliesMultiplierToScore()
{
var input = CreateHighScoreInput(ageDays: 100);
var result = _ranker.Rank(input);
result.Score.Should().Be(30.00m);
result.DecayFactor.Should().Be(0.60m);
}
[Fact]
public void Rank_DecayDisabled_ReturnsFullScore()
{
var options = new UnknownRankerOptions { EnableDecay = false };
var ranker = new UnknownRanker(Options.Create(options));
var input = CreateHighScoreInput(ageDays: 100);
var result = ranker.Rank(input);
result.DecayFactor.Should().Be(1.0m);
result.Score.Should().Be(50.00m);
}
[Fact]
public void Rank_Decay_Determinism_SameInputSameOutput()
{
var input = CreateInputWithAge(ageDays: 45);
var results = Enumerable.Range(0, 100)
.Select(_ => _ranker.Rank(input))
.ToList();
results.Should().AllBeEquivalentTo(results[0]);
}
#endregion
#region Containment Reduction Tests
[Fact]
public void ComputeContainmentReduction_NullInputs_ReturnsZero()
{
var input = CreateInputWithContainment(blastRadius: null, containment: null);
var result = _ranker.Rank(input);
result.ContainmentReduction.Should().Be(0m);
}
[Fact]
public void ComputeContainmentReduction_IsolatedPackage_Returns15Percent()
{
var blast = new BlastRadius { Dependents = 0, NetFacing = true };
var input = CreateInputWithContainment(blastRadius: blast);
var result = _ranker.Rank(input);
result.ContainmentReduction.Should().Be(0.15m);
}
[Fact]
public void ComputeContainmentReduction_AllContainmentFactors_CapsAt40Percent()
{
var blast = new BlastRadius { Dependents = 0, NetFacing = false, Privilege = "none" };
var contain = new ContainmentSignals { Seccomp = "enforced", FileSystem = "ro", NetworkPolicy = "isolated" };
var input = CreateInputWithContainment(blastRadius: blast, containment: contain);
var result = _ranker.Rank(input);
result.ContainmentReduction.Should().Be(0.40m);
}
[Fact]
public void Rank_WithContainment_AppliesReductionToScore()
{
var blast = new BlastRadius { Dependents = 0 };
var input = CreateHighScoreInputWithContainment(blast);
var result = _ranker.Rank(input);
result.Score.Should().Be(48.00m);
result.ContainmentReduction.Should().Be(0.20m);
}
[Fact]
public void Rank_ContainmentDisabled_NoReduction()
{
var options = new UnknownRankerOptions { EnableContainmentReduction = false };
var ranker = new UnknownRanker(Options.Create(options));
var blast = new BlastRadius { Dependents = 0 };
var input = CreateHighScoreInputWithContainment(blast);
var result = ranker.Rank(input);
result.ContainmentReduction.Should().Be(0m);
result.Score.Should().Be(60.00m);
}
#endregion
#region Band Assignment Tests
[Theory]
@@ -404,7 +598,7 @@ public class UnknownRankerTests
public void Rank_ScoreAbove75_AssignsHotBand()
{
// Arrange - Score = (1.00 × 50) + (0.50 × 50) = 75.00
var input = new UnknownRankInput(
var input = CreateInput(
HasVexStatement: false,
HasReachabilityData: false,
HasConflictingSources: true,
@@ -426,7 +620,7 @@ public class UnknownRankerTests
{
// Arrange - Score = (0.70 × 50) + (0.50 × 50) = 35 + 25 = 60
// Uncertainty: 0.70 (missing VEX + missing reachability)
var input = new UnknownRankInput(
var input = CreateInput(
HasVexStatement: false,
HasReachabilityData: false,
HasConflictingSources: false,
@@ -447,7 +641,7 @@ public class UnknownRankerTests
public void Rank_ScoreBetween25And50_AssignsColdBand()
{
// Arrange - Score = (0.40 × 50) + (0.15 × 50) = 20 + 7.5 = 27.5
var input = new UnknownRankInput(
var input = CreateInput(
HasVexStatement: false,
HasReachabilityData: true,
HasConflictingSources: false,
@@ -468,7 +662,7 @@ public class UnknownRankerTests
public void Rank_ScoreBelow25_AssignsResolvedBand()
{
// Arrange - Score = (0.10 × 50) + (0.05 × 50) = 5 + 2.5 = 7.5
var input = new UnknownRankInput(
var input = CreateInput(
HasVexStatement: true,
HasReachabilityData: true,
HasConflictingSources: false,
@@ -486,4 +680,113 @@ public class UnknownRankerTests
}
#endregion
private static UnknownRankInput CreateInput(
bool HasVexStatement,
bool HasReachabilityData,
bool HasConflictingSources,
bool IsStaleAdvisory,
bool IsInKev,
decimal EpssScore,
decimal CvssScore,
DateTimeOffset? FirstSeenAt = null,
DateTimeOffset? LastEvaluatedAt = null,
DateTimeOffset? AsOfDateTime = null,
BlastRadius? BlastRadius = null,
ContainmentSignals? Containment = null,
bool HasPackageDigest = true,
bool HasProvenanceAttestation = true,
bool HasVexConflicts = false,
bool HasFeedCoverage = true,
bool HasConfigVisibility = true,
bool IsAnalyzerSupported = true)
{
var asOf = AsOfDateTime ?? DefaultAsOf;
return new UnknownRankInput(
HasVexStatement,
HasReachabilityData,
HasConflictingSources,
IsStaleAdvisory,
IsInKev,
EpssScore,
CvssScore,
FirstSeenAt,
LastEvaluatedAt,
asOf,
BlastRadius,
Containment,
HasPackageDigest,
HasProvenanceAttestation,
HasVexConflicts,
HasFeedCoverage,
HasConfigVisibility,
IsAnalyzerSupported);
}
private static UnknownRankInput CreateInputWithAge(
int? ageDays = null,
DateTimeOffset? lastEvaluatedAt = null,
DateTimeOffset? asOfDateTime = null)
{
var asOf = asOfDateTime ?? DefaultAsOf;
var evaluatedAt = lastEvaluatedAt ?? (ageDays.HasValue ? asOf.AddDays(-ageDays.Value) : null);
return CreateInput(
HasVexStatement: true,
HasReachabilityData: true,
HasConflictingSources: false,
IsStaleAdvisory: false,
IsInKev: false,
EpssScore: 0,
CvssScore: 0,
LastEvaluatedAt: evaluatedAt,
AsOfDateTime: asOf);
}
private static UnknownRankInput CreateHighScoreInput(int ageDays)
{
var asOf = DefaultAsOf;
return CreateInput(
HasVexStatement: false,
HasReachabilityData: false,
HasConflictingSources: true,
IsStaleAdvisory: true,
IsInKev: false,
EpssScore: 0,
CvssScore: 0,
LastEvaluatedAt: asOf.AddDays(-ageDays),
AsOfDateTime: asOf);
}
private static UnknownRankInput CreateInputWithContainment(
BlastRadius? blastRadius = null,
ContainmentSignals? containment = null)
{
return CreateInput(
HasVexStatement: true,
HasReachabilityData: true,
HasConflictingSources: false,
IsStaleAdvisory: false,
IsInKev: false,
EpssScore: 0,
CvssScore: 0,
BlastRadius: blastRadius,
Containment: containment);
}
private static UnknownRankInput CreateHighScoreInputWithContainment(BlastRadius blastRadius)
{
return CreateInput(
HasVexStatement: false,
HasReachabilityData: false,
HasConflictingSources: false,
IsStaleAdvisory: false,
IsInKev: true,
EpssScore: 0,
CvssScore: 0,
BlastRadius: blastRadius);
}
}

View File

@@ -21,6 +21,7 @@
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Policy.Exceptions/StellaOps.Policy.Exceptions.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Policy.Unknowns/StellaOps.Policy.Unknowns.csproj" />
</ItemGroup>
</Project>