sprints work
This commit is contained in:
@@ -7,6 +7,7 @@ using StellaOps.Policy.Confidence.Models;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Unknowns.Models;
|
||||
using StellaOps.PolicyDsl;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Evaluation;
|
||||
|
||||
@@ -128,7 +129,8 @@ internal sealed record PolicyEvaluationResult(
|
||||
ConfidenceScore? Confidence,
|
||||
PolicyFailureReason? FailureReason = null,
|
||||
string? FailureMessage = null,
|
||||
BudgetStatusSummary? UnknownBudgetStatus = null)
|
||||
BudgetStatusSummary? UnknownBudgetStatus = null,
|
||||
EvidenceWeightedScoreResult? EvidenceWeightedScore = null)
|
||||
{
|
||||
public static PolicyEvaluationResult CreateDefault(string? severity) => new(
|
||||
Matched: false,
|
||||
@@ -139,7 +141,8 @@ internal sealed record PolicyEvaluationResult(
|
||||
Annotations: ImmutableDictionary<string, string>.Empty,
|
||||
Warnings: ImmutableArray<string>.Empty,
|
||||
AppliedException: null,
|
||||
Confidence: null);
|
||||
Confidence: null,
|
||||
EvidenceWeightedScore: null);
|
||||
}
|
||||
|
||||
internal enum PolicyFailureReason
|
||||
|
||||
@@ -10,10 +10,15 @@ using StellaOps.Policy;
|
||||
using StellaOps.Policy.Confidence.Configuration;
|
||||
using StellaOps.Policy.Confidence.Models;
|
||||
using StellaOps.Policy.Confidence.Services;
|
||||
using StellaOps.Policy.Engine.Scoring.EvidenceWeightedScore;
|
||||
using StellaOps.Policy.Unknowns.Models;
|
||||
using StellaOps.Policy.Unknowns.Services;
|
||||
using StellaOps.PolicyDsl;
|
||||
|
||||
// Alias Confidence types to avoid ambiguity with EWS types
|
||||
using ConfidenceReachabilityState = StellaOps.Policy.Confidence.Models.ReachabilityState;
|
||||
using ConfidenceRuntimePosture = StellaOps.Policy.Confidence.Models.RuntimePosture;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Evaluation;
|
||||
|
||||
/// <summary>
|
||||
@@ -23,15 +28,18 @@ internal sealed class PolicyEvaluator
|
||||
{
|
||||
private readonly IConfidenceCalculator _confidenceCalculator;
|
||||
private readonly IUnknownBudgetService? _budgetService;
|
||||
private readonly IFindingScoreEnricher? _scoreEnricher;
|
||||
|
||||
public PolicyEvaluator(
|
||||
IConfidenceCalculator? confidenceCalculator = null,
|
||||
IUnknownBudgetService? budgetService = null)
|
||||
IUnknownBudgetService? budgetService = null,
|
||||
IFindingScoreEnricher? scoreEnricher = null)
|
||||
{
|
||||
_confidenceCalculator = confidenceCalculator
|
||||
?? new ConfidenceCalculator(
|
||||
new StaticOptionsMonitor<ConfidenceWeightOptions>(new ConfidenceWeightOptions()));
|
||||
_budgetService = budgetService;
|
||||
_scoreEnricher = scoreEnricher;
|
||||
}
|
||||
|
||||
public PolicyEvaluationResult Evaluate(PolicyEvaluationRequest request)
|
||||
@@ -46,7 +54,10 @@ internal sealed class PolicyEvaluator
|
||||
throw new ArgumentNullException(nameof(request.Document));
|
||||
}
|
||||
|
||||
var evaluator = new PolicyExpressionEvaluator(request.Context);
|
||||
// Pre-compute EWS so it's available during rule evaluation for score-based rules
|
||||
var precomputedScore = PrecomputeEvidenceWeightedScore(request.Context);
|
||||
|
||||
var evaluator = new PolicyExpressionEvaluator(request.Context, precomputedScore);
|
||||
var orderedRules = request.Document.Rules
|
||||
.Select(static (rule, index) => new { rule, index })
|
||||
.OrderBy(x => x.rule.Priority)
|
||||
@@ -85,13 +96,15 @@ internal sealed class PolicyEvaluator
|
||||
|
||||
var result = ApplyExceptions(request, baseResult);
|
||||
var budgeted = ApplyUnknownBudget(request.Context, result);
|
||||
return ApplyConfidence(request.Context, budgeted);
|
||||
var withConfidence = ApplyConfidence(request.Context, budgeted);
|
||||
return ApplyEvidenceWeightedScore(request.Context, withConfidence, precomputedScore);
|
||||
}
|
||||
|
||||
var defaultResult = PolicyEvaluationResult.CreateDefault(request.Context.Severity.Normalized);
|
||||
var defaultWithExceptions = ApplyExceptions(request, defaultResult);
|
||||
var budgetedDefault = ApplyUnknownBudget(request.Context, defaultWithExceptions);
|
||||
return ApplyConfidence(request.Context, budgetedDefault);
|
||||
var defaultWithConfidence = ApplyConfidence(request.Context, budgetedDefault);
|
||||
return ApplyEvidenceWeightedScore(request.Context, defaultWithConfidence, precomputedScore);
|
||||
}
|
||||
|
||||
private static void ApplyAction(
|
||||
@@ -513,6 +526,139 @@ internal sealed class PolicyEvaluator
|
||||
return baseResult with { Confidence = confidence };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-computes the Evidence-Weighted Score before rule evaluation so it's available
|
||||
/// for score-based policy rules (e.g., "when score >= 80 then block").
|
||||
/// </summary>
|
||||
private global::StellaOps.Signals.EvidenceWeightedScore.EvidenceWeightedScoreResult? PrecomputeEvidenceWeightedScore(
|
||||
PolicyEvaluationContext context)
|
||||
{
|
||||
// Skip if no enricher configured
|
||||
if (_scoreEnricher is null || !_scoreEnricher.IsEnabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Generate finding ID from context
|
||||
var findingId = GenerateFindingIdFromContext(context);
|
||||
|
||||
// Extract evidence from context
|
||||
var evidence = context.ExtractEwsEvidence(
|
||||
findingId,
|
||||
epssScore: context.Advisory.Metadata.TryGetValue("epss.score", out var epssStr)
|
||||
? double.TryParse(epssStr, out var epss) ? epss : null
|
||||
: null,
|
||||
epssPercentile: context.Advisory.Metadata.TryGetValue("epss.percentile", out var epssPercStr)
|
||||
? double.TryParse(epssPercStr, out var epssPerc) ? epssPerc : null
|
||||
: null,
|
||||
isInKev: context.Advisory.Metadata.TryGetValue("kev.status", out var kevStatus)
|
||||
&& kevStatus.Equals("true", StringComparison.OrdinalIgnoreCase),
|
||||
kevAddedDate: context.Advisory.Metadata.TryGetValue("kev.added", out var kevAddedStr)
|
||||
? DateTimeOffset.TryParse(kevAddedStr, out var kevAdded) ? kevAdded : null
|
||||
: null);
|
||||
|
||||
// Calculate score synchronously
|
||||
var enrichmentResult = _scoreEnricher.Enrich(evidence);
|
||||
|
||||
return enrichmentResult.IsSuccess ? enrichmentResult.Score : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Pre-computation should not fail the evaluation
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a deterministic finding ID from context (without requiring result).
|
||||
/// </summary>
|
||||
private static string GenerateFindingIdFromContext(PolicyEvaluationContext context)
|
||||
{
|
||||
var source = context.Advisory.Source ?? "unknown";
|
||||
var severity = context.Severity.Normalized ?? "unknown";
|
||||
|
||||
// Use advisory metadata CVE ID if available
|
||||
if (context.Advisory.Metadata.TryGetValue("cve", out var cve) && !string.IsNullOrEmpty(cve))
|
||||
{
|
||||
return $"finding:{cve}:{source}";
|
||||
}
|
||||
|
||||
// Fall back to deterministic hash
|
||||
var input = $"{source}|{severity}|{context.Now:O}";
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(input), hash);
|
||||
return $"finding:sha256:{Convert.ToHexString(hash).ToLowerInvariant()[..16]}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies Evidence-Weighted Score enrichment if the enricher is available and enabled.
|
||||
/// Uses pre-computed score if available to avoid recalculation.
|
||||
/// </summary>
|
||||
private PolicyEvaluationResult ApplyEvidenceWeightedScore(
|
||||
PolicyEvaluationContext context,
|
||||
PolicyEvaluationResult baseResult,
|
||||
global::StellaOps.Signals.EvidenceWeightedScore.EvidenceWeightedScoreResult? precomputedScore = null)
|
||||
{
|
||||
// Use precomputed score if available
|
||||
var score = precomputedScore;
|
||||
|
||||
// If no precomputed score and enricher is enabled, compute now
|
||||
if (score is null && _scoreEnricher is not null && _scoreEnricher.IsEnabled)
|
||||
{
|
||||
score = PrecomputeEvidenceWeightedScore(context);
|
||||
}
|
||||
|
||||
// Skip if no score available
|
||||
if (score is null)
|
||||
{
|
||||
return baseResult;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Add score to annotations for DSL access
|
||||
var annotations = baseResult.Annotations.ToBuilder();
|
||||
annotations["ews.score"] = score.Score.ToString("F2", CultureInfo.InvariantCulture);
|
||||
annotations["ews.bucket"] = score.Bucket.ToString();
|
||||
|
||||
return baseResult with
|
||||
{
|
||||
EvidenceWeightedScore = score,
|
||||
Annotations = annotations.ToImmutable()
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Score enrichment should not fail the evaluation
|
||||
// Return base result unchanged
|
||||
return baseResult;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a deterministic finding ID from evaluation context.
|
||||
/// </summary>
|
||||
private static string GenerateFindingId(PolicyEvaluationContext context, PolicyEvaluationResult result)
|
||||
{
|
||||
var source = context.Advisory.Source ?? "unknown";
|
||||
var severity = context.Severity.Normalized ?? "unknown";
|
||||
var ruleName = result.RuleName ?? "default";
|
||||
|
||||
// Use advisory metadata CVE ID if available
|
||||
if (context.Advisory.Metadata.TryGetValue("cve", out var cve) && !string.IsNullOrEmpty(cve))
|
||||
{
|
||||
return $"finding:{cve}:{source}";
|
||||
}
|
||||
|
||||
// Fall back to deterministic hash
|
||||
var input = $"{source}|{severity}|{ruleName}|{context.Now:O}";
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(input), hash);
|
||||
return $"finding:sha256:{Convert.ToHexString(hash).ToLowerInvariant()[..16]}";
|
||||
}
|
||||
|
||||
private static ConfidenceInput BuildConfidenceInput(PolicyEvaluationContext context, PolicyEvaluationResult result)
|
||||
{
|
||||
return new ConfidenceInput
|
||||
@@ -535,10 +681,10 @@ internal sealed class PolicyEvaluator
|
||||
}
|
||||
|
||||
var state = reachability.IsReachable
|
||||
? (reachability.HasRuntimeEvidence ? ReachabilityState.ConfirmedReachable : ReachabilityState.StaticReachable)
|
||||
? (reachability.HasRuntimeEvidence ? ConfidenceReachabilityState.ConfirmedReachable : ConfidenceReachabilityState.StaticReachable)
|
||||
: reachability.IsUnreachable
|
||||
? (reachability.HasRuntimeEvidence ? ReachabilityState.ConfirmedUnreachable : ReachabilityState.StaticUnreachable)
|
||||
: ReachabilityState.Unknown;
|
||||
? (reachability.HasRuntimeEvidence ? ConfidenceReachabilityState.ConfirmedUnreachable : ConfidenceReachabilityState.StaticUnreachable)
|
||||
: ConfidenceReachabilityState.Unknown;
|
||||
|
||||
var digests = string.IsNullOrWhiteSpace(reachability.EvidenceRef)
|
||||
? Array.Empty<string>()
|
||||
@@ -560,8 +706,8 @@ internal sealed class PolicyEvaluator
|
||||
}
|
||||
|
||||
var posture = context.Reachability.IsReachable || context.Reachability.IsUnreachable
|
||||
? RuntimePosture.Supports
|
||||
: RuntimePosture.Unknown;
|
||||
? ConfidenceRuntimePosture.Supports
|
||||
: ConfidenceRuntimePosture.Unknown;
|
||||
|
||||
return new RuntimeEvidence
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using StellaOps.PolicyDsl;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Evaluation;
|
||||
|
||||
@@ -23,10 +24,14 @@ internal sealed class PolicyExpressionEvaluator
|
||||
};
|
||||
|
||||
private readonly PolicyEvaluationContext context;
|
||||
private readonly EvidenceWeightedScoreResult? _evidenceWeightedScore;
|
||||
|
||||
public PolicyExpressionEvaluator(PolicyEvaluationContext context)
|
||||
public PolicyExpressionEvaluator(
|
||||
PolicyEvaluationContext context,
|
||||
EvidenceWeightedScoreResult? evidenceWeightedScore = null)
|
||||
{
|
||||
this.context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_evidenceWeightedScore = evidenceWeightedScore;
|
||||
}
|
||||
|
||||
public EvaluationValue Evaluate(PolicyExpression expression, EvaluationScope? scope = null)
|
||||
@@ -65,6 +70,9 @@ internal sealed class PolicyExpressionEvaluator
|
||||
"sbom" => new EvaluationValue(new SbomScope(context.Sbom)),
|
||||
"reachability" => new EvaluationValue(new ReachabilityScope(context.Reachability)),
|
||||
"entropy" => new EvaluationValue(new EntropyScope(context.Entropy)),
|
||||
"score" => _evidenceWeightedScore is not null
|
||||
? new EvaluationValue(new ScoreScope(_evidenceWeightedScore))
|
||||
: EvaluationValue.Null,
|
||||
"now" => new EvaluationValue(context.Now),
|
||||
"true" => EvaluationValue.True,
|
||||
"false" => EvaluationValue.False,
|
||||
@@ -111,6 +119,11 @@ internal sealed class PolicyExpressionEvaluator
|
||||
return entropy.Get(member.Member);
|
||||
}
|
||||
|
||||
if (raw is ScoreScope scoreScope)
|
||||
{
|
||||
return scoreScope.Get(member.Member);
|
||||
}
|
||||
|
||||
if (raw is ComponentScope componentScope)
|
||||
{
|
||||
return componentScope.Get(member.Member);
|
||||
@@ -202,6 +215,22 @@ internal sealed class PolicyExpressionEvaluator
|
||||
{
|
||||
return advisoryScope.Invoke(member.Member, invocation.Arguments, scope, this);
|
||||
}
|
||||
|
||||
if (root.Name == "score" && targetRaw is ScoreScope scoreScope)
|
||||
{
|
||||
return member.Member.ToLowerInvariant() switch
|
||||
{
|
||||
"has_flag" or "hasflag" => invocation.Arguments.Length > 0
|
||||
? scoreScope.HasFlag(Evaluate(invocation.Arguments[0], scope).AsString() ?? "")
|
||||
: EvaluationValue.False,
|
||||
"between" => invocation.Arguments.Length >= 2
|
||||
? scoreScope.Between(
|
||||
Evaluate(invocation.Arguments[0], scope).AsDecimal() ?? 0m,
|
||||
Evaluate(invocation.Arguments[1], scope).AsDecimal() ?? 100m)
|
||||
: EvaluationValue.False,
|
||||
_ => EvaluationValue.Null,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -915,6 +944,94 @@ internal sealed class PolicyExpressionEvaluator
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SPL scope for Evidence-Weighted Score predicates.
|
||||
/// Provides access to score value, bucket, flags, and individual dimensions.
|
||||
/// </summary>
|
||||
/// <example>
|
||||
/// SPL predicates supported:
|
||||
/// - score >= 80
|
||||
/// - score.value >= 80
|
||||
/// - score.bucket == "ActNow"
|
||||
/// - score.is_act_now == true
|
||||
/// - score.rch > 0.8
|
||||
/// - score.runt > 0.5
|
||||
/// - score.has_flag("live-signal")
|
||||
/// - score.flags contains "kev"
|
||||
/// </example>
|
||||
private sealed class ScoreScope
|
||||
{
|
||||
private readonly EvidenceWeightedScoreResult score;
|
||||
|
||||
public ScoreScope(EvidenceWeightedScoreResult score)
|
||||
{
|
||||
this.score = score;
|
||||
}
|
||||
|
||||
public EvaluationValue Get(string member) => member.ToLowerInvariant() switch
|
||||
{
|
||||
// Core score value (allows direct comparison: score >= 80)
|
||||
"value" => new EvaluationValue(score.Score),
|
||||
|
||||
// Bucket access
|
||||
"bucket" => new EvaluationValue(score.Bucket.ToString()),
|
||||
"is_act_now" or "isactnow" => new EvaluationValue(score.Bucket == ScoreBucket.ActNow),
|
||||
"is_schedule_next" or "isschedulenext" => new EvaluationValue(score.Bucket == ScoreBucket.ScheduleNext),
|
||||
"is_investigate" or "isinvestigate" => new EvaluationValue(score.Bucket == ScoreBucket.Investigate),
|
||||
"is_watchlist" or "iswatchlist" => new EvaluationValue(score.Bucket == ScoreBucket.Watchlist),
|
||||
|
||||
// Individual dimension scores (0-1 normalized) - using Breakdown
|
||||
"rch" or "reachability" => new EvaluationValue(GetDimensionInput("RCH")),
|
||||
"rts" or "runtime" => new EvaluationValue(GetDimensionInput("RTS")),
|
||||
"bkp" or "backport" => new EvaluationValue(GetDimensionInput("BKP")),
|
||||
"xpl" or "exploit" => new EvaluationValue(GetDimensionInput("XPL")),
|
||||
"src" or "source_trust" => new EvaluationValue(GetDimensionInput("SRC")),
|
||||
"mit" or "mitigation" => new EvaluationValue(GetDimensionInput("MIT")),
|
||||
|
||||
// Flags as array
|
||||
"flags" => new EvaluationValue(score.Flags.Select(f => (object?)f).ToImmutableArray()),
|
||||
|
||||
// Policy info
|
||||
"policy_digest" or "policydigest" => new EvaluationValue(score.PolicyDigest),
|
||||
|
||||
// Calculation metadata
|
||||
"calculated_at" or "calculatedat" => new EvaluationValue(score.CalculatedAt),
|
||||
|
||||
// Explanations
|
||||
"explanations" => new EvaluationValue(score.Explanations.Select(e => (object?)e).ToImmutableArray()),
|
||||
|
||||
_ => EvaluationValue.Null,
|
||||
};
|
||||
|
||||
private double GetDimensionInput(string symbol)
|
||||
{
|
||||
var contribution = score.Breakdown.FirstOrDefault(c =>
|
||||
c.Symbol.Equals(symbol, StringComparison.OrdinalIgnoreCase));
|
||||
return contribution?.InputValue ?? 0.0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if score has a specific flag.
|
||||
/// </summary>
|
||||
public EvaluationValue HasFlag(string flagName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(flagName))
|
||||
{
|
||||
return EvaluationValue.False;
|
||||
}
|
||||
|
||||
return new EvaluationValue(score.Flags.Contains(flagName, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if score is between min and max (inclusive).
|
||||
/// </summary>
|
||||
public EvaluationValue Between(decimal min, decimal max)
|
||||
{
|
||||
return new EvaluationValue(score.Score >= min && score.Score <= max);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SPL scope for macOS component predicates.
|
||||
/// Provides access to bundle signing, entitlements, sandboxing, and package receipt information.
|
||||
|
||||
323
src/Policy/StellaOps.Policy.Engine/Evaluation/VerdictSummary.cs
Normal file
323
src/Policy/StellaOps.Policy.Engine/Evaluation/VerdictSummary.cs
Normal file
@@ -0,0 +1,323 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VerdictSummary.cs
|
||||
// Sprint: SPRINT_8200_0012_0003_policy_engine_integration
|
||||
// Task: PINT-8200-024
|
||||
// Description: VerdictSummary extension for including EWS bucket and top factors
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Evaluation;
|
||||
|
||||
/// <summary>
|
||||
/// A summarized view of a policy evaluation result, including evidence-weighted
|
||||
/// score bucket and top contributing factors for quick triage visualization.
|
||||
/// </summary>
|
||||
public sealed record VerdictSummary
|
||||
{
|
||||
/// <summary>The overall verdict status (e.g., "affected", "not_affected").</summary>
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>The severity level (Critical, High, Medium, Low, Info).</summary>
|
||||
public string? Severity { get; init; }
|
||||
|
||||
/// <summary>Whether a rule matched this finding.</summary>
|
||||
public bool RuleMatched { get; init; }
|
||||
|
||||
/// <summary>Name of the matching rule, if any.</summary>
|
||||
public string? RuleName { get; init; }
|
||||
|
||||
/// <summary>Rule priority, if applicable.</summary>
|
||||
public int? Priority { get; init; }
|
||||
|
||||
/// <summary>Evidence-weighted score bucket for quick triage.</summary>
|
||||
public string? ScoreBucket { get; init; }
|
||||
|
||||
/// <summary>Numeric score (0-100) from evidence-weighted scoring.</summary>
|
||||
public int? Score { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Top contributing factors from EWS breakdown, ordered by contribution magnitude.
|
||||
/// Each entry contains the dimension name and its contribution.
|
||||
/// </summary>
|
||||
public ImmutableArray<VerdictFactor> TopFactors { get; init; } = [];
|
||||
|
||||
/// <summary>Active flags from EWS (e.g., "live-signal", "kev", "vendor-na").</summary>
|
||||
public ImmutableArray<string> Flags { get; init; } = [];
|
||||
|
||||
/// <summary>Human-readable explanations for the score.</summary>
|
||||
public ImmutableArray<string> Explanations { get; init; } = [];
|
||||
|
||||
/// <summary>Whether guardrails (caps/floors) were applied to the score.</summary>
|
||||
public bool GuardrailsApplied { get; init; }
|
||||
|
||||
/// <summary>Warnings emitted during evaluation.</summary>
|
||||
public ImmutableArray<string> Warnings { get; init; } = [];
|
||||
|
||||
/// <summary>Whether an exception was applied to this finding.</summary>
|
||||
public bool ExceptionApplied { get; init; }
|
||||
|
||||
/// <summary>Legacy confidence score, if available.</summary>
|
||||
public decimal? ConfidenceScore { get; init; }
|
||||
|
||||
/// <summary>Legacy confidence band, if available.</summary>
|
||||
public string? ConfidenceBand { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single contributing factor to the evidence-weighted score.
|
||||
/// </summary>
|
||||
public sealed record VerdictFactor
|
||||
{
|
||||
/// <summary>Full dimension name (e.g., "Reachability", "Runtime Signal").</summary>
|
||||
public required string Dimension { get; init; }
|
||||
|
||||
/// <summary>Short symbol (e.g., "RCH", "RTS", "XPL").</summary>
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
/// <summary>Contribution to the score (positive for additive, negative for subtractive).</summary>
|
||||
public required double Contribution { get; init; }
|
||||
|
||||
/// <summary>Weight applied to this dimension.</summary>
|
||||
public required double Weight { get; init; }
|
||||
|
||||
/// <summary>Normalized input value [0, 1].</summary>
|
||||
public required double InputValue { get; init; }
|
||||
|
||||
/// <summary>Whether this is a subtractive factor (like Mitigation).</summary>
|
||||
public bool IsSubtractive { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for creating <see cref="VerdictSummary"/> from evaluation results.
|
||||
/// </summary>
|
||||
internal static class VerdictSummaryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum number of top factors to include in the summary.
|
||||
/// </summary>
|
||||
private const int MaxTopFactors = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="VerdictSummary"/> from a <see cref="PolicyEvaluationResult"/>.
|
||||
/// </summary>
|
||||
/// <param name="result">The policy evaluation result.</param>
|
||||
/// <returns>A summarized view of the verdict including EWS bucket and top factors.</returns>
|
||||
internal static VerdictSummary ToSummary(this PolicyEvaluationResult result)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
|
||||
var ews = result.EvidenceWeightedScore;
|
||||
|
||||
return new VerdictSummary
|
||||
{
|
||||
Status = result.Status,
|
||||
Severity = result.Severity,
|
||||
RuleMatched = result.Matched,
|
||||
RuleName = result.RuleName,
|
||||
Priority = result.Priority,
|
||||
ScoreBucket = ews?.Bucket.ToString(),
|
||||
Score = ews?.Score,
|
||||
TopFactors = ExtractTopFactors(ews),
|
||||
Flags = ews?.Flags.ToImmutableArray() ?? [],
|
||||
Explanations = ews?.Explanations.ToImmutableArray() ?? [],
|
||||
GuardrailsApplied = ews?.Caps.AnyApplied ?? false,
|
||||
Warnings = result.Warnings,
|
||||
ExceptionApplied = result.AppliedException is not null,
|
||||
ConfidenceScore = result.Confidence?.Value,
|
||||
ConfidenceBand = result.Confidence?.Tier.ToString(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a minimal <see cref="VerdictSummary"/> with only status and rule info.
|
||||
/// Use this for quick serialization when EWS details are not needed.
|
||||
/// </summary>
|
||||
/// <param name="result">The policy evaluation result.</param>
|
||||
/// <returns>A minimal summarized view.</returns>
|
||||
internal static VerdictSummary ToMinimalSummary(this PolicyEvaluationResult result)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
|
||||
return new VerdictSummary
|
||||
{
|
||||
Status = result.Status,
|
||||
Severity = result.Severity,
|
||||
RuleMatched = result.Matched,
|
||||
RuleName = result.RuleName,
|
||||
Priority = result.Priority,
|
||||
ScoreBucket = result.EvidenceWeightedScore?.Bucket.ToString(),
|
||||
Score = result.EvidenceWeightedScore?.Score,
|
||||
Warnings = result.Warnings,
|
||||
ExceptionApplied = result.AppliedException is not null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the top contributing factors from the EWS breakdown,
|
||||
/// ordered by absolute contribution magnitude (descending).
|
||||
/// </summary>
|
||||
private static ImmutableArray<VerdictFactor> ExtractTopFactors(EvidenceWeightedScoreResult? ews)
|
||||
{
|
||||
if (ews?.Breakdown is null || ews.Breakdown.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return ews.Breakdown
|
||||
.OrderByDescending(d => Math.Abs(d.Contribution))
|
||||
.Take(MaxTopFactors)
|
||||
.Select(d => new VerdictFactor
|
||||
{
|
||||
Dimension = d.Dimension,
|
||||
Symbol = d.Symbol,
|
||||
Contribution = d.Contribution,
|
||||
Weight = d.Weight,
|
||||
InputValue = d.InputValue,
|
||||
IsSubtractive = d.IsSubtractive,
|
||||
})
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the primary contributing factor from the EWS breakdown.
|
||||
/// Returns null if no breakdown is available.
|
||||
/// </summary>
|
||||
/// <param name="ews">The evidence-weighted score result.</param>
|
||||
/// <returns>The highest-contributing factor, or null.</returns>
|
||||
public static VerdictFactor? GetPrimaryFactor(this EvidenceWeightedScoreResult? ews)
|
||||
{
|
||||
if (ews?.Breakdown is null || ews.Breakdown.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var primary = ews.Breakdown
|
||||
.OrderByDescending(d => Math.Abs(d.Contribution))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (primary is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new VerdictFactor
|
||||
{
|
||||
Dimension = primary.Dimension,
|
||||
Symbol = primary.Symbol,
|
||||
Contribution = primary.Contribution,
|
||||
Weight = primary.Weight,
|
||||
InputValue = primary.InputValue,
|
||||
IsSubtractive = primary.IsSubtractive,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats the verdict summary as a single-line triage string.
|
||||
/// Example: "[ActNow 92] CVE-2024-1234: RCH(+35), XPL(+28), RTS(+20) | live-signal"
|
||||
/// </summary>
|
||||
/// <param name="summary">The verdict summary.</param>
|
||||
/// <param name="findingId">Optional finding ID to include.</param>
|
||||
/// <returns>A formatted triage string.</returns>
|
||||
public static string FormatTriageLine(this VerdictSummary summary, string? findingId = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(summary);
|
||||
|
||||
var parts = new List<string>();
|
||||
|
||||
// Score bucket and value
|
||||
if (summary.Score.HasValue)
|
||||
{
|
||||
parts.Add($"[{summary.ScoreBucket ?? "?"} {summary.Score}]");
|
||||
}
|
||||
|
||||
// Finding ID if provided
|
||||
if (!string.IsNullOrEmpty(findingId))
|
||||
{
|
||||
parts.Add($"{findingId}:");
|
||||
}
|
||||
|
||||
// Top factors
|
||||
if (summary.TopFactors.Length > 0)
|
||||
{
|
||||
var factors = summary.TopFactors
|
||||
.Take(3)
|
||||
.Select(f => $"{f.Symbol}({(f.Contribution >= 0 ? "+" : "")}{f.Contribution:F0})")
|
||||
.ToArray();
|
||||
parts.Add(string.Join(", ", factors));
|
||||
}
|
||||
|
||||
// Flags
|
||||
if (summary.Flags.Length > 0)
|
||||
{
|
||||
parts.Add($"| {string.Join(", ", summary.Flags.Take(3))}");
|
||||
}
|
||||
|
||||
return string.Join(" ", parts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a brief explanation of why this verdict received its score bucket.
|
||||
/// </summary>
|
||||
/// <param name="summary">The verdict summary.</param>
|
||||
/// <returns>A human-readable explanation.</returns>
|
||||
public static string GetBucketExplanation(this VerdictSummary summary)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(summary);
|
||||
|
||||
if (!summary.Score.HasValue)
|
||||
{
|
||||
return "No evidence-weighted score available.";
|
||||
}
|
||||
|
||||
var bucket = summary.ScoreBucket;
|
||||
var score = summary.Score.Value;
|
||||
|
||||
var explanation = bucket switch
|
||||
{
|
||||
"ActNow" => $"Score {score}/100: Strong evidence of exploitable risk. Immediate action recommended.",
|
||||
"ScheduleNext" => $"Score {score}/100: Likely real risk. Schedule remediation for next sprint.",
|
||||
"Investigate" => $"Score {score}/100: Moderate evidence. Investigate when working on this component.",
|
||||
"Watchlist" => $"Score {score}/100: Insufficient evidence. Monitor for changes.",
|
||||
_ => $"Score {score}/100."
|
||||
};
|
||||
|
||||
// Add primary factor context
|
||||
if (summary.TopFactors.Length > 0)
|
||||
{
|
||||
var primary = summary.TopFactors[0];
|
||||
var factorContext = primary.Symbol switch
|
||||
{
|
||||
"RCH" => "Reachability analysis is the primary driver.",
|
||||
"RTS" => "Runtime signals detected exploitation activity.",
|
||||
"XPL" => "Known exploit evidence is significant.",
|
||||
"BKP" => "Backport information affects the score.",
|
||||
"SRC" => "Source trust levels impact the assessment.",
|
||||
"MIT" => "Mitigations reduce the effective risk.",
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (factorContext is not null)
|
||||
{
|
||||
explanation = $"{explanation} {factorContext}";
|
||||
}
|
||||
}
|
||||
|
||||
// Add flag context
|
||||
if (summary.Flags.Contains("live-signal"))
|
||||
{
|
||||
explanation = $"{explanation} ALERT: Live exploitation signal detected!";
|
||||
}
|
||||
else if (summary.Flags.Contains("kev"))
|
||||
{
|
||||
explanation = $"{explanation} This is a Known Exploited Vulnerability (KEV).";
|
||||
}
|
||||
else if (summary.Flags.Contains("vendor-na"))
|
||||
{
|
||||
explanation = $"{explanation} Vendor has confirmed not affected.";
|
||||
}
|
||||
|
||||
return explanation;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user