Refactor code structure for improved readability and maintainability; optimize performance in key functions.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ internal static class BatchEvaluationEndpoint
|
||||
response.Annotations,
|
||||
response.Warnings,
|
||||
response.AppliedException,
|
||||
response.Confidence,
|
||||
response.CorrelationId,
|
||||
response.Cached,
|
||||
response.CacheSource,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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. |
|
||||
|
||||
35
src/Policy/__Libraries/StellaOps.Policy.Exceptions/AGENTS.md
Normal file
35
src/Policy/__Libraries/StellaOps.Policy.Exceptions/AGENTS.md
Normal 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.
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>();
|
||||
|
||||
40
src/Policy/__Libraries/StellaOps.Policy.Unknowns/AGENTS.md
Normal file
40
src/Policy/__Libraries/StellaOps.Policy.Unknowns/AGENTS.md
Normal 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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}"));
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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() { }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user