up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,701 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Export format for explain traces.
|
||||
/// </summary>
|
||||
public enum ExplainTraceFormat
|
||||
{
|
||||
/// <summary>JSON format.</summary>
|
||||
Json,
|
||||
|
||||
/// <summary>NDJSON format (newline-delimited JSON).</summary>
|
||||
Ndjson,
|
||||
|
||||
/// <summary>Human-readable text format.</summary>
|
||||
Text,
|
||||
|
||||
/// <summary>Markdown format for documentation.</summary>
|
||||
Markdown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Complete explain trace for a policy evaluation.
|
||||
/// </summary>
|
||||
public sealed record ExplainTrace
|
||||
{
|
||||
/// <summary>
|
||||
/// Run identifier.
|
||||
/// </summary>
|
||||
public required string RunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant context.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy identifier.
|
||||
/// </summary>
|
||||
public required string PolicyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy version.
|
||||
/// </summary>
|
||||
public int? PolicyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evaluation timestamp (deterministic).
|
||||
/// </summary>
|
||||
public required DateTimeOffset EvaluationTimestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total evaluation duration in milliseconds.
|
||||
/// </summary>
|
||||
public required long EvaluationDurationMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Final outcome of the evaluation.
|
||||
/// </summary>
|
||||
public required string FinalOutcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Input context summary.
|
||||
/// </summary>
|
||||
public required ExplainTraceInputContext InputContext { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule evaluation steps in order.
|
||||
/// </summary>
|
||||
public required ImmutableArray<ExplainTraceRuleStep> RuleSteps { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX evidence applied.
|
||||
/// </summary>
|
||||
public required ImmutableArray<ExplainTraceVexEvidence> VexEvidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Statistics summary.
|
||||
/// </summary>
|
||||
public required RuleHitStatistics Statistics { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Determinism hash for reproducibility verification.
|
||||
/// </summary>
|
||||
public string? DeterminismHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trace metadata.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input context for explain trace.
|
||||
/// </summary>
|
||||
public sealed record ExplainTraceInputContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Component PURL.
|
||||
/// </summary>
|
||||
public string? ComponentPurl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component name.
|
||||
/// </summary>
|
||||
public string? ComponentName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component version.
|
||||
/// </summary>
|
||||
public string? ComponentVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Advisory ID.
|
||||
/// </summary>
|
||||
public string? AdvisoryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability ID.
|
||||
/// </summary>
|
||||
public string? VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Input severity.
|
||||
/// </summary>
|
||||
public string? InputSeverity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Input CVSS score.
|
||||
/// </summary>
|
||||
public decimal? InputCvssScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Environment variables available.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Environment { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// SBOM tags.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> SbomTags { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Reachability state.
|
||||
/// </summary>
|
||||
public string? ReachabilityState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability confidence.
|
||||
/// </summary>
|
||||
public double? ReachabilityConfidence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single rule evaluation step in the explain trace.
|
||||
/// </summary>
|
||||
public sealed record ExplainTraceRuleStep
|
||||
{
|
||||
/// <summary>
|
||||
/// Step number (1-based).
|
||||
/// </summary>
|
||||
public required int StepNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule name.
|
||||
/// </summary>
|
||||
public required string RuleName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule priority.
|
||||
/// </summary>
|
||||
public int RulePriority { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule category.
|
||||
/// </summary>
|
||||
public string? RuleCategory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expression that was evaluated.
|
||||
/// </summary>
|
||||
public string? Expression { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the expression matched.
|
||||
/// </summary>
|
||||
public required bool Matched { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Outcome if the rule matched.
|
||||
/// </summary>
|
||||
public string? Outcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Assigned severity if the rule matched.
|
||||
/// </summary>
|
||||
public string? AssignedSeverity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this was the final matching rule.
|
||||
/// </summary>
|
||||
public bool IsFinalMatch { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Why the rule did or did not match.
|
||||
/// </summary>
|
||||
public string? Explanation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evaluation time in microseconds.
|
||||
/// </summary>
|
||||
public long EvaluationMicroseconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Intermediate values during evaluation.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> IntermediateValues { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX evidence in the explain trace.
|
||||
/// </summary>
|
||||
public sealed record ExplainTraceVexEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// VEX provider/vendor.
|
||||
/// </summary>
|
||||
public required string Vendor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX status.
|
||||
/// </summary>
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX justification.
|
||||
/// </summary>
|
||||
public string? Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score.
|
||||
/// </summary>
|
||||
public double? Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this VEX was applied.
|
||||
/// </summary>
|
||||
public required bool WasApplied { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Why the VEX was or was not applied.
|
||||
/// </summary>
|
||||
public string? Explanation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for building and exporting explain traces.
|
||||
/// </summary>
|
||||
public sealed class ExplainTraceExportService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions NdjsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Exports an explain trace to the specified format.
|
||||
/// </summary>
|
||||
public string Export(ExplainTrace trace, ExplainTraceFormat format)
|
||||
{
|
||||
return format switch
|
||||
{
|
||||
ExplainTraceFormat.Json => ExportJson(trace),
|
||||
ExplainTraceFormat.Ndjson => ExportNdjson(trace),
|
||||
ExplainTraceFormat.Text => ExportText(trace),
|
||||
ExplainTraceFormat.Markdown => ExportMarkdown(trace),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(format))
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports to JSON format.
|
||||
/// </summary>
|
||||
public string ExportJson(ExplainTrace trace)
|
||||
{
|
||||
return JsonSerializer.Serialize(trace, JsonOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports to NDJSON format (each rule step on its own line).
|
||||
/// </summary>
|
||||
public string ExportNdjson(ExplainTrace trace)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
|
||||
// Header line
|
||||
var header = new
|
||||
{
|
||||
type = "header",
|
||||
run_id = trace.RunId,
|
||||
tenant_id = trace.TenantId,
|
||||
policy_id = trace.PolicyId,
|
||||
policy_version = trace.PolicyVersion,
|
||||
evaluation_timestamp = trace.EvaluationTimestamp,
|
||||
final_outcome = trace.FinalOutcome
|
||||
};
|
||||
builder.AppendLine(JsonSerializer.Serialize(header, NdjsonOptions));
|
||||
|
||||
// Input context line
|
||||
var context = new { type = "context", context = trace.InputContext };
|
||||
builder.AppendLine(JsonSerializer.Serialize(context, NdjsonOptions));
|
||||
|
||||
// Rule steps
|
||||
foreach (var step in trace.RuleSteps)
|
||||
{
|
||||
var stepRecord = new { type = "rule_step", step };
|
||||
builder.AppendLine(JsonSerializer.Serialize(stepRecord, NdjsonOptions));
|
||||
}
|
||||
|
||||
// VEX evidence
|
||||
foreach (var vex in trace.VexEvidence)
|
||||
{
|
||||
var vexRecord = new { type = "vex_evidence", evidence = vex };
|
||||
builder.AppendLine(JsonSerializer.Serialize(vexRecord, NdjsonOptions));
|
||||
}
|
||||
|
||||
// Statistics line
|
||||
var stats = new { type = "statistics", statistics = trace.Statistics };
|
||||
builder.AppendLine(JsonSerializer.Serialize(stats, NdjsonOptions));
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports to human-readable text format.
|
||||
/// </summary>
|
||||
public string ExportText(ExplainTrace trace)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
|
||||
builder.AppendLine("================================================================================");
|
||||
builder.AppendLine("POLICY EVALUATION EXPLAIN TRACE");
|
||||
builder.AppendLine("================================================================================");
|
||||
builder.AppendLine();
|
||||
|
||||
builder.AppendLine("RUN INFORMATION:");
|
||||
builder.AppendLine($" Run ID: {trace.RunId}");
|
||||
builder.AppendLine($" Tenant: {trace.TenantId}");
|
||||
builder.AppendLine($" Policy: {trace.PolicyId}");
|
||||
if (trace.PolicyVersion.HasValue)
|
||||
{
|
||||
builder.AppendLine($" Policy Version: {trace.PolicyVersion}");
|
||||
}
|
||||
builder.AppendLine($" Evaluation Time: {trace.EvaluationTimestamp:O}");
|
||||
builder.AppendLine($" Duration: {trace.EvaluationDurationMs}ms");
|
||||
builder.AppendLine($" Final Outcome: {trace.FinalOutcome}");
|
||||
builder.AppendLine();
|
||||
|
||||
builder.AppendLine("INPUT CONTEXT:");
|
||||
if (!string.IsNullOrWhiteSpace(trace.InputContext.ComponentPurl))
|
||||
{
|
||||
builder.AppendLine($" Component PURL: {trace.InputContext.ComponentPurl}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(trace.InputContext.VulnerabilityId))
|
||||
{
|
||||
builder.AppendLine($" Vulnerability: {trace.InputContext.VulnerabilityId}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(trace.InputContext.InputSeverity))
|
||||
{
|
||||
builder.AppendLine($" Input Severity: {trace.InputContext.InputSeverity}");
|
||||
}
|
||||
if (trace.InputContext.InputCvssScore.HasValue)
|
||||
{
|
||||
builder.AppendLine($" CVSS Score: {trace.InputContext.InputCvssScore:F1}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(trace.InputContext.ReachabilityState))
|
||||
{
|
||||
builder.AppendLine($" Reachability: {trace.InputContext.ReachabilityState} ({trace.InputContext.ReachabilityConfidence:P0})");
|
||||
}
|
||||
builder.AppendLine();
|
||||
|
||||
builder.AppendLine("RULE EVALUATION STEPS:");
|
||||
builder.AppendLine("--------------------------------------------------------------------------------");
|
||||
foreach (var step in trace.RuleSteps)
|
||||
{
|
||||
var matchIndicator = step.Matched ? "[MATCH]" : "[ ]";
|
||||
var finalIndicator = step.IsFinalMatch ? " *FINAL*" : "";
|
||||
builder.AppendLine($" {step.StepNumber,3}. {matchIndicator} {step.RuleName}{finalIndicator}");
|
||||
builder.AppendLine($" Priority: {step.RulePriority}");
|
||||
if (!string.IsNullOrWhiteSpace(step.Expression))
|
||||
{
|
||||
var expr = step.Expression.Length > 60
|
||||
? step.Expression[..57] + "..."
|
||||
: step.Expression;
|
||||
builder.AppendLine($" Expression: {expr}");
|
||||
}
|
||||
if (step.Matched)
|
||||
{
|
||||
builder.AppendLine($" Outcome: {step.Outcome}");
|
||||
if (!string.IsNullOrWhiteSpace(step.AssignedSeverity))
|
||||
{
|
||||
builder.AppendLine($" Severity: {step.AssignedSeverity}");
|
||||
}
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(step.Explanation))
|
||||
{
|
||||
builder.AppendLine($" Reason: {step.Explanation}");
|
||||
}
|
||||
builder.AppendLine();
|
||||
}
|
||||
|
||||
if (!trace.VexEvidence.IsDefaultOrEmpty)
|
||||
{
|
||||
builder.AppendLine("VEX EVIDENCE:");
|
||||
builder.AppendLine("--------------------------------------------------------------------------------");
|
||||
foreach (var vex in trace.VexEvidence)
|
||||
{
|
||||
var appliedIndicator = vex.WasApplied ? "[APPLIED]" : "[IGNORED]";
|
||||
builder.AppendLine($" {appliedIndicator} {vex.Vendor}: {vex.Status}");
|
||||
if (!string.IsNullOrWhiteSpace(vex.Justification))
|
||||
{
|
||||
builder.AppendLine($" Justification: {vex.Justification}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(vex.Explanation))
|
||||
{
|
||||
builder.AppendLine($" Reason: {vex.Explanation}");
|
||||
}
|
||||
}
|
||||
builder.AppendLine();
|
||||
}
|
||||
|
||||
builder.AppendLine("STATISTICS:");
|
||||
builder.AppendLine("--------------------------------------------------------------------------------");
|
||||
builder.AppendLine($" Rules Evaluated: {trace.Statistics.TotalRulesEvaluated}");
|
||||
builder.AppendLine($" Rules Fired: {trace.Statistics.TotalRulesFired}");
|
||||
builder.AppendLine($" VEX Overrides: {trace.Statistics.TotalVexOverrides}");
|
||||
builder.AppendLine($" Total Duration: {trace.Statistics.TotalEvaluationMs}ms");
|
||||
builder.AppendLine($" Avg Rule Time: {trace.Statistics.AverageRuleEvaluationMicroseconds:F1}us");
|
||||
builder.AppendLine();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(trace.DeterminismHash))
|
||||
{
|
||||
builder.AppendLine($"Determinism Hash: {trace.DeterminismHash}");
|
||||
}
|
||||
|
||||
builder.AppendLine("================================================================================");
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports to Markdown format.
|
||||
/// </summary>
|
||||
public string ExportMarkdown(ExplainTrace trace)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
|
||||
builder.AppendLine("# Policy Evaluation Explain Trace");
|
||||
builder.AppendLine();
|
||||
|
||||
builder.AppendLine("## Run Information");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("| Property | Value |");
|
||||
builder.AppendLine("|----------|-------|");
|
||||
builder.AppendLine($"| Run ID | `{trace.RunId}` |");
|
||||
builder.AppendLine($"| Tenant | `{trace.TenantId}` |");
|
||||
builder.AppendLine($"| Policy | `{trace.PolicyId}` |");
|
||||
if (trace.PolicyVersion.HasValue)
|
||||
{
|
||||
builder.AppendLine($"| Version | `{trace.PolicyVersion}` |");
|
||||
}
|
||||
builder.AppendLine($"| Evaluation Time | `{trace.EvaluationTimestamp:O}` |");
|
||||
builder.AppendLine($"| Duration | {trace.EvaluationDurationMs}ms |");
|
||||
builder.AppendLine($"| **Final Outcome** | **{trace.FinalOutcome}** |");
|
||||
builder.AppendLine();
|
||||
|
||||
builder.AppendLine("## Input Context");
|
||||
builder.AppendLine();
|
||||
if (!string.IsNullOrWhiteSpace(trace.InputContext.ComponentPurl))
|
||||
{
|
||||
builder.AppendLine($"- **Component**: `{trace.InputContext.ComponentPurl}`");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(trace.InputContext.VulnerabilityId))
|
||||
{
|
||||
builder.AppendLine($"- **Vulnerability**: `{trace.InputContext.VulnerabilityId}`");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(trace.InputContext.InputSeverity))
|
||||
{
|
||||
builder.AppendLine($"- **Severity**: {trace.InputContext.InputSeverity}");
|
||||
}
|
||||
if (trace.InputContext.InputCvssScore.HasValue)
|
||||
{
|
||||
builder.AppendLine($"- **CVSS Score**: {trace.InputContext.InputCvssScore:F1}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(trace.InputContext.ReachabilityState))
|
||||
{
|
||||
builder.AppendLine($"- **Reachability**: {trace.InputContext.ReachabilityState} ({trace.InputContext.ReachabilityConfidence:P0} confidence)");
|
||||
}
|
||||
builder.AppendLine();
|
||||
|
||||
builder.AppendLine("## Rule Evaluation Steps");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("| # | Rule | Priority | Matched | Outcome | Severity |");
|
||||
builder.AppendLine("|---|------|----------|---------|---------|----------|");
|
||||
foreach (var step in trace.RuleSteps)
|
||||
{
|
||||
var matched = step.Matched ? (step.IsFinalMatch ? "**YES** (final)" : "YES") : "no";
|
||||
var outcome = step.Matched ? step.Outcome ?? "-" : "-";
|
||||
var severity = step.AssignedSeverity ?? "-";
|
||||
builder.AppendLine($"| {step.StepNumber} | `{step.RuleName}` | {step.RulePriority} | {matched} | {outcome} | {severity} |");
|
||||
}
|
||||
builder.AppendLine();
|
||||
|
||||
if (!trace.VexEvidence.IsDefaultOrEmpty)
|
||||
{
|
||||
builder.AppendLine("## VEX Evidence");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("| Vendor | Status | Applied | Justification |");
|
||||
builder.AppendLine("|--------|--------|---------|---------------|");
|
||||
foreach (var vex in trace.VexEvidence)
|
||||
{
|
||||
var applied = vex.WasApplied ? "**YES**" : "no";
|
||||
var justification = vex.Justification ?? "-";
|
||||
builder.AppendLine($"| {vex.Vendor} | {vex.Status} | {applied} | {justification} |");
|
||||
}
|
||||
builder.AppendLine();
|
||||
}
|
||||
|
||||
builder.AppendLine("## Statistics");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine($"- **Rules Evaluated**: {trace.Statistics.TotalRulesEvaluated}");
|
||||
builder.AppendLine($"- **Rules Fired**: {trace.Statistics.TotalRulesFired}");
|
||||
builder.AppendLine($"- **VEX Overrides**: {trace.Statistics.TotalVexOverrides}");
|
||||
builder.AppendLine($"- **Total Duration**: {trace.Statistics.TotalEvaluationMs}ms");
|
||||
builder.AppendLine($"- **Avg Rule Time**: {trace.Statistics.AverageRuleEvaluationMicroseconds:F1}μs");
|
||||
builder.AppendLine();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(trace.DeterminismHash))
|
||||
{
|
||||
builder.AppendLine("---");
|
||||
builder.AppendLine($"*Determinism Hash: `{trace.DeterminismHash}`*");
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for constructing explain traces from evaluation results.
|
||||
/// </summary>
|
||||
public sealed class ExplainTraceBuilder
|
||||
{
|
||||
private string? _runId;
|
||||
private string? _tenantId;
|
||||
private string? _policyId;
|
||||
private int? _policyVersion;
|
||||
private DateTimeOffset _evaluationTimestamp;
|
||||
private long _evaluationDurationMs;
|
||||
private string? _finalOutcome;
|
||||
private ExplainTraceInputContext? _inputContext;
|
||||
private readonly List<ExplainTraceRuleStep> _ruleSteps = new();
|
||||
private readonly List<ExplainTraceVexEvidence> _vexEvidence = new();
|
||||
private RuleHitStatistics? _statistics;
|
||||
private string? _determinismHash;
|
||||
private readonly Dictionary<string, string> _metadata = new();
|
||||
|
||||
public ExplainTraceBuilder WithRunId(string runId)
|
||||
{
|
||||
_runId = runId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ExplainTraceBuilder WithTenant(string tenantId)
|
||||
{
|
||||
_tenantId = tenantId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ExplainTraceBuilder WithPolicy(string policyId, int? version = null)
|
||||
{
|
||||
_policyId = policyId;
|
||||
_policyVersion = version;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ExplainTraceBuilder WithEvaluationTimestamp(DateTimeOffset timestamp)
|
||||
{
|
||||
_evaluationTimestamp = timestamp;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ExplainTraceBuilder WithDuration(long milliseconds)
|
||||
{
|
||||
_evaluationDurationMs = milliseconds;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ExplainTraceBuilder WithFinalOutcome(string outcome)
|
||||
{
|
||||
_finalOutcome = outcome;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ExplainTraceBuilder WithInputContext(ExplainTraceInputContext context)
|
||||
{
|
||||
_inputContext = context;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ExplainTraceBuilder AddRuleStep(ExplainTraceRuleStep step)
|
||||
{
|
||||
_ruleSteps.Add(step);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ExplainTraceBuilder AddVexEvidence(ExplainTraceVexEvidence evidence)
|
||||
{
|
||||
_vexEvidence.Add(evidence);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ExplainTraceBuilder WithStatistics(RuleHitStatistics statistics)
|
||||
{
|
||||
_statistics = statistics;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ExplainTraceBuilder WithDeterminismHash(string hash)
|
||||
{
|
||||
_determinismHash = hash;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ExplainTraceBuilder AddMetadata(string key, string value)
|
||||
{
|
||||
_metadata[key] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ExplainTrace Build()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_runId))
|
||||
throw new InvalidOperationException("Run ID is required");
|
||||
if (string.IsNullOrWhiteSpace(_tenantId))
|
||||
throw new InvalidOperationException("Tenant ID is required");
|
||||
if (string.IsNullOrWhiteSpace(_policyId))
|
||||
throw new InvalidOperationException("Policy ID is required");
|
||||
if (string.IsNullOrWhiteSpace(_finalOutcome))
|
||||
throw new InvalidOperationException("Final outcome is required");
|
||||
|
||||
_inputContext ??= new ExplainTraceInputContext();
|
||||
|
||||
_statistics ??= new RuleHitStatistics
|
||||
{
|
||||
RunId = _runId,
|
||||
PolicyId = _policyId,
|
||||
TotalRulesEvaluated = _ruleSteps.Count,
|
||||
TotalRulesFired = _ruleSteps.Count(s => s.Matched),
|
||||
TotalVexOverrides = _vexEvidence.Count(v => v.WasApplied),
|
||||
RulesFiredByCategory = ImmutableDictionary<string, int>.Empty,
|
||||
RulesFiredByOutcome = ImmutableDictionary<string, int>.Empty,
|
||||
VexOverridesByVendor = ImmutableDictionary<string, int>.Empty,
|
||||
VexOverridesByStatus = ImmutableDictionary<string, int>.Empty,
|
||||
TopRulesByHitCount = ImmutableArray<RuleHitCount>.Empty,
|
||||
TotalEvaluationMs = _evaluationDurationMs
|
||||
};
|
||||
|
||||
return new ExplainTrace
|
||||
{
|
||||
RunId = _runId,
|
||||
TenantId = _tenantId,
|
||||
PolicyId = _policyId,
|
||||
PolicyVersion = _policyVersion,
|
||||
EvaluationTimestamp = _evaluationTimestamp,
|
||||
EvaluationDurationMs = _evaluationDurationMs,
|
||||
FinalOutcome = _finalOutcome,
|
||||
InputContext = _inputContext,
|
||||
RuleSteps = _ruleSteps.ToImmutableArray(),
|
||||
VexEvidence = _vexEvidence.ToImmutableArray(),
|
||||
Statistics = _statistics,
|
||||
DeterminismHash = _determinismHash,
|
||||
Metadata = _metadata.ToImmutableDictionary()
|
||||
};
|
||||
}
|
||||
}
|
||||
424
src/Policy/StellaOps.Policy.Engine/Telemetry/RuleHitTrace.cs
Normal file
424
src/Policy/StellaOps.Policy.Engine/Telemetry/RuleHitTrace.cs
Normal file
@@ -0,0 +1,424 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a structured trace record for a policy rule hit.
|
||||
/// </summary>
|
||||
public sealed record RuleHitTrace
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique trace identifier.
|
||||
/// </summary>
|
||||
public required string TraceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Span identifier within the trace.
|
||||
/// </summary>
|
||||
public required string SpanId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Parent span identifier (if any).
|
||||
/// </summary>
|
||||
public string? ParentSpanId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant context.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy identifier.
|
||||
/// </summary>
|
||||
public required string PolicyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy version.
|
||||
/// </summary>
|
||||
public int? PolicyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Run identifier.
|
||||
/// </summary>
|
||||
public required string RunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule that fired.
|
||||
/// </summary>
|
||||
public required string RuleName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule priority (lower = higher priority).
|
||||
/// </summary>
|
||||
public int RulePriority { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule category/type.
|
||||
/// </summary>
|
||||
public string? RuleCategory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of the rule (allow, deny, suppress, etc.).
|
||||
/// </summary>
|
||||
public required string Outcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity assigned by the rule.
|
||||
/// </summary>
|
||||
public string? AssignedSeverity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component PURL that triggered the rule.
|
||||
/// </summary>
|
||||
public string? ComponentPurl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Advisory ID that triggered the rule.
|
||||
/// </summary>
|
||||
public string? AdvisoryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability ID (CVE, GHSA, etc.).
|
||||
/// </summary>
|
||||
public string? VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX status that influenced the rule (if any).
|
||||
/// </summary>
|
||||
public string? VexStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX justification (if VEX was applied).
|
||||
/// </summary>
|
||||
public string? VexJustification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX vendor that provided the status.
|
||||
/// </summary>
|
||||
public string? VexVendor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this was a VEX override.
|
||||
/// </summary>
|
||||
public bool IsVexOverride { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Input CVSS score (if applicable).
|
||||
/// </summary>
|
||||
public decimal? InputCvssScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability state (if applicable).
|
||||
/// </summary>
|
||||
public string? ReachabilityState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability confidence (0.0-1.0).
|
||||
/// </summary>
|
||||
public double? ReachabilityConfidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expression that was evaluated.
|
||||
/// </summary>
|
||||
public string? Expression { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expression evaluation result.
|
||||
/// </summary>
|
||||
public bool ExpressionResult { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evaluation timestamp (deterministic).
|
||||
/// </summary>
|
||||
public required DateTimeOffset EvaluationTimestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Wall-clock timestamp when trace was recorded.
|
||||
/// </summary>
|
||||
public required DateTimeOffset RecordedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evaluation duration in microseconds.
|
||||
/// </summary>
|
||||
public long EvaluationMicroseconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this trace was sampled (vs. full capture).
|
||||
/// </summary>
|
||||
public bool IsSampled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional context attributes.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Attributes { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a trace ID from the current activity or generates a new one.
|
||||
/// </summary>
|
||||
public static string GetOrCreateTraceId()
|
||||
{
|
||||
var activity = Activity.Current;
|
||||
if (activity is not null)
|
||||
{
|
||||
return activity.TraceId.ToString();
|
||||
}
|
||||
|
||||
Span<byte> bytes = stackalloc byte[16];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
return Convert.ToHexStringLower(bytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a span ID from the current activity or generates a new one.
|
||||
/// </summary>
|
||||
public static string GetOrCreateSpanId()
|
||||
{
|
||||
var activity = Activity.Current;
|
||||
if (activity is not null)
|
||||
{
|
||||
return activity.SpanId.ToString();
|
||||
}
|
||||
|
||||
Span<byte> bytes = stackalloc byte[8];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
return Convert.ToHexStringLower(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated rule hit statistics for a policy run.
|
||||
/// </summary>
|
||||
public sealed record RuleHitStatistics
|
||||
{
|
||||
/// <summary>
|
||||
/// Run identifier.
|
||||
/// </summary>
|
||||
public required string RunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy identifier.
|
||||
/// </summary>
|
||||
public required string PolicyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total rules evaluated.
|
||||
/// </summary>
|
||||
public required int TotalRulesEvaluated { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total rules that fired (matched).
|
||||
/// </summary>
|
||||
public required int TotalRulesFired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total VEX overrides applied.
|
||||
/// </summary>
|
||||
public required int TotalVexOverrides { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rules fired by category.
|
||||
/// </summary>
|
||||
public required ImmutableDictionary<string, int> RulesFiredByCategory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rules fired by outcome.
|
||||
/// </summary>
|
||||
public required ImmutableDictionary<string, int> RulesFiredByOutcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX overrides by vendor.
|
||||
/// </summary>
|
||||
public required ImmutableDictionary<string, int> VexOverridesByVendor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX overrides by status.
|
||||
/// </summary>
|
||||
public required ImmutableDictionary<string, int> VexOverridesByStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Top rules by hit count.
|
||||
/// </summary>
|
||||
public required ImmutableArray<RuleHitCount> TopRulesByHitCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total evaluation duration in milliseconds.
|
||||
/// </summary>
|
||||
public required long TotalEvaluationMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Average rule evaluation time in microseconds.
|
||||
/// </summary>
|
||||
public double AverageRuleEvaluationMicroseconds =>
|
||||
TotalRulesEvaluated > 0 ? (double)TotalEvaluationMs * 1000 / TotalRulesEvaluated : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule hit count entry.
|
||||
/// </summary>
|
||||
public sealed record RuleHitCount(string RuleName, int HitCount, string Outcome);
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating rule hit traces.
|
||||
/// </summary>
|
||||
public static class RuleHitTraceFactory
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a rule hit trace from evaluation context.
|
||||
/// </summary>
|
||||
public static RuleHitTrace Create(
|
||||
string tenantId,
|
||||
string policyId,
|
||||
int? policyVersion,
|
||||
string runId,
|
||||
string ruleName,
|
||||
int rulePriority,
|
||||
string outcome,
|
||||
DateTimeOffset evaluationTimestamp,
|
||||
TimeProvider? timeProvider = null,
|
||||
string? ruleCategory = null,
|
||||
string? assignedSeverity = null,
|
||||
string? componentPurl = null,
|
||||
string? advisoryId = null,
|
||||
string? vulnerabilityId = null,
|
||||
string? vexStatus = null,
|
||||
string? vexJustification = null,
|
||||
string? vexVendor = null,
|
||||
bool isVexOverride = false,
|
||||
decimal? inputCvssScore = null,
|
||||
string? reachabilityState = null,
|
||||
double? reachabilityConfidence = null,
|
||||
string? expression = null,
|
||||
bool expressionResult = false,
|
||||
long evaluationMicroseconds = 0,
|
||||
bool isSampled = false,
|
||||
ImmutableDictionary<string, string>? attributes = null)
|
||||
{
|
||||
var time = timeProvider ?? TimeProvider.System;
|
||||
var traceId = RuleHitTrace.GetOrCreateTraceId();
|
||||
var spanId = RuleHitTrace.GetOrCreateSpanId();
|
||||
var parentSpanId = Activity.Current?.ParentSpanId.ToString();
|
||||
|
||||
return new RuleHitTrace
|
||||
{
|
||||
TraceId = traceId,
|
||||
SpanId = spanId,
|
||||
ParentSpanId = parentSpanId,
|
||||
TenantId = tenantId.ToLowerInvariant(),
|
||||
PolicyId = policyId,
|
||||
PolicyVersion = policyVersion,
|
||||
RunId = runId,
|
||||
RuleName = ruleName,
|
||||
RulePriority = rulePriority,
|
||||
RuleCategory = ruleCategory,
|
||||
Outcome = outcome,
|
||||
AssignedSeverity = assignedSeverity,
|
||||
ComponentPurl = componentPurl,
|
||||
AdvisoryId = advisoryId,
|
||||
VulnerabilityId = vulnerabilityId,
|
||||
VexStatus = vexStatus,
|
||||
VexJustification = vexJustification,
|
||||
VexVendor = vexVendor,
|
||||
IsVexOverride = isVexOverride,
|
||||
InputCvssScore = inputCvssScore,
|
||||
ReachabilityState = reachabilityState,
|
||||
ReachabilityConfidence = reachabilityConfidence,
|
||||
Expression = expression,
|
||||
ExpressionResult = expressionResult,
|
||||
EvaluationTimestamp = evaluationTimestamp,
|
||||
RecordedAt = time.GetUtcNow(),
|
||||
EvaluationMicroseconds = evaluationMicroseconds,
|
||||
IsSampled = isSampled,
|
||||
Attributes = attributes ?? ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a rule hit trace to JSON.
|
||||
/// </summary>
|
||||
public static string ToJson(RuleHitTrace trace)
|
||||
{
|
||||
return JsonSerializer.Serialize(trace, JsonOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes multiple rule hit traces to NDJSON.
|
||||
/// </summary>
|
||||
public static string ToNdjson(IEnumerable<RuleHitTrace> traces)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
foreach (var trace in traces)
|
||||
{
|
||||
builder.AppendLine(JsonSerializer.Serialize(trace, JsonOptions));
|
||||
}
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates aggregated statistics from a collection of traces.
|
||||
/// </summary>
|
||||
public static RuleHitStatistics CreateStatistics(
|
||||
string runId,
|
||||
string policyId,
|
||||
IEnumerable<RuleHitTrace> traces,
|
||||
int totalRulesEvaluated,
|
||||
long totalEvaluationMs)
|
||||
{
|
||||
var traceList = traces.ToList();
|
||||
|
||||
var rulesFiredByCategory = traceList
|
||||
.Where(t => !string.IsNullOrWhiteSpace(t.RuleCategory))
|
||||
.GroupBy(t => t.RuleCategory!)
|
||||
.ToImmutableDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var rulesFiredByOutcome = traceList
|
||||
.GroupBy(t => t.Outcome)
|
||||
.ToImmutableDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var vexOverrides = traceList.Where(t => t.IsVexOverride).ToList();
|
||||
|
||||
var vexOverridesByVendor = vexOverrides
|
||||
.Where(t => !string.IsNullOrWhiteSpace(t.VexVendor))
|
||||
.GroupBy(t => t.VexVendor!)
|
||||
.ToImmutableDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var vexOverridesByStatus = vexOverrides
|
||||
.Where(t => !string.IsNullOrWhiteSpace(t.VexStatus))
|
||||
.GroupBy(t => t.VexStatus!)
|
||||
.ToImmutableDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var topRules = traceList
|
||||
.GroupBy(t => (t.RuleName, t.Outcome))
|
||||
.Select(g => new RuleHitCount(g.Key.RuleName, g.Count(), g.Key.Outcome))
|
||||
.OrderByDescending(r => r.HitCount)
|
||||
.Take(10)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new RuleHitStatistics
|
||||
{
|
||||
RunId = runId,
|
||||
PolicyId = policyId,
|
||||
TotalRulesEvaluated = totalRulesEvaluated,
|
||||
TotalRulesFired = traceList.Count,
|
||||
TotalVexOverrides = vexOverrides.Count,
|
||||
RulesFiredByCategory = rulesFiredByCategory,
|
||||
RulesFiredByOutcome = rulesFiredByOutcome,
|
||||
VexOverridesByVendor = vexOverridesByVendor,
|
||||
VexOverridesByStatus = vexOverridesByStatus,
|
||||
TopRulesByHitCount = topRules,
|
||||
TotalEvaluationMs = totalEvaluationMs
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for rule hit trace sampling.
|
||||
/// </summary>
|
||||
public sealed record RuleHitSamplingOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Base sampling rate (0.0 to 1.0). Default is 0.1 (10%).
|
||||
/// </summary>
|
||||
public double BaseSamplingRate { get; init; } = 0.1;
|
||||
|
||||
/// <summary>
|
||||
/// Sampling rate for VEX overrides (usually higher). Default is 1.0 (100%).
|
||||
/// </summary>
|
||||
public double VexOverrideSamplingRate { get; init; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Sampling rate for high-severity outcomes. Default is 0.5 (50%).
|
||||
/// </summary>
|
||||
public double HighSeveritySamplingRate { get; init; } = 0.5;
|
||||
|
||||
/// <summary>
|
||||
/// Sampling rate during incident mode. Default is 1.0 (100%).
|
||||
/// </summary>
|
||||
public double IncidentModeSamplingRate { get; init; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum traces to buffer per run before flushing.
|
||||
/// </summary>
|
||||
public int MaxBufferSizePerRun { get; init; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum total traces to buffer before forced flush.
|
||||
/// </summary>
|
||||
public int MaxTotalBufferSize { get; init; } = 10000;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include full expression text in traces.
|
||||
/// </summary>
|
||||
public bool IncludeExpressions { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum expression length to include (truncated if longer).
|
||||
/// </summary>
|
||||
public int MaxExpressionLength { get; init; } = 500;
|
||||
|
||||
/// <summary>
|
||||
/// High-severity outcomes that trigger elevated sampling.
|
||||
/// </summary>
|
||||
public ImmutableHashSet<string> HighSeverityOutcomes { get; init; } =
|
||||
ImmutableHashSet.Create(StringComparer.OrdinalIgnoreCase, "deny", "block", "critical", "high");
|
||||
|
||||
/// <summary>
|
||||
/// Rules to always sample (by name pattern).
|
||||
/// </summary>
|
||||
public ImmutableArray<string> AlwaysSampleRules { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Default options.
|
||||
/// </summary>
|
||||
public static RuleHitSamplingOptions Default { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Full sampling (for debugging/testing).
|
||||
/// </summary>
|
||||
public static RuleHitSamplingOptions FullSampling { get; } = new()
|
||||
{
|
||||
BaseSamplingRate = 1.0,
|
||||
VexOverrideSamplingRate = 1.0,
|
||||
HighSeveritySamplingRate = 1.0
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for rule hit trace collection.
|
||||
/// </summary>
|
||||
public interface IRuleHitTraceCollector
|
||||
{
|
||||
/// <summary>
|
||||
/// Records a rule hit trace.
|
||||
/// </summary>
|
||||
void Record(RuleHitTrace trace);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all traces for a run.
|
||||
/// </summary>
|
||||
IReadOnlyList<RuleHitTrace> GetTraces(string runId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets statistics for a run.
|
||||
/// </summary>
|
||||
RuleHitStatistics? GetStatistics(string runId);
|
||||
|
||||
/// <summary>
|
||||
/// Flushes traces for a run.
|
||||
/// </summary>
|
||||
Task FlushAsync(string runId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Completes a run and returns final statistics.
|
||||
/// </summary>
|
||||
RuleHitStatistics CompleteRun(string runId, int totalRulesEvaluated, long totalEvaluationMs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for exporting rule hit traces.
|
||||
/// </summary>
|
||||
public interface IRuleHitTraceExporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Exports traces for a run.
|
||||
/// </summary>
|
||||
Task ExportAsync(
|
||||
string runId,
|
||||
IReadOnlyList<RuleHitTrace> traces,
|
||||
RuleHitStatistics statistics,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects and manages rule hit traces with sampling controls.
|
||||
/// </summary>
|
||||
public sealed class RuleHitTraceCollector : IRuleHitTraceCollector, IDisposable
|
||||
{
|
||||
private readonly RuleHitSamplingOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IReadOnlyList<IRuleHitTraceExporter> _exporters;
|
||||
private readonly ConcurrentDictionary<string, RunTraceBuffer> _runBuffers = new();
|
||||
private readonly Random _sampler;
|
||||
private readonly object _samplerLock = new();
|
||||
private volatile bool _incidentMode;
|
||||
private bool _disposed;
|
||||
|
||||
public RuleHitTraceCollector(
|
||||
RuleHitSamplingOptions? options = null,
|
||||
TimeProvider? timeProvider = null,
|
||||
IEnumerable<IRuleHitTraceExporter>? exporters = null)
|
||||
{
|
||||
_options = options ?? RuleHitSamplingOptions.Default;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_exporters = exporters?.ToList() ?? new List<IRuleHitTraceExporter>();
|
||||
_sampler = new Random();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enables or disables incident mode (100% sampling).
|
||||
/// </summary>
|
||||
public bool IncidentMode
|
||||
{
|
||||
get => _incidentMode;
|
||||
set => _incidentMode = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a rule hit trace with sampling.
|
||||
/// </summary>
|
||||
public void Record(RuleHitTrace trace)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(trace);
|
||||
|
||||
// Determine if this trace should be sampled
|
||||
if (!ShouldSample(trace))
|
||||
{
|
||||
// Still record metrics even if not sampled
|
||||
RecordMetrics(trace);
|
||||
return;
|
||||
}
|
||||
|
||||
var buffer = _runBuffers.GetOrAdd(trace.RunId, _ => new RunTraceBuffer());
|
||||
buffer.Add(trace with { IsSampled = true });
|
||||
|
||||
// Record metrics
|
||||
RecordMetrics(trace);
|
||||
|
||||
// Check if we need to force flush
|
||||
if (buffer.Count >= _options.MaxBufferSizePerRun)
|
||||
{
|
||||
// Async flush without blocking
|
||||
_ = FlushAsync(trace.RunId, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all sampled traces for a run.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RuleHitTrace> GetTraces(string runId)
|
||||
{
|
||||
if (_runBuffers.TryGetValue(runId, out var buffer))
|
||||
{
|
||||
return buffer.GetTraces();
|
||||
}
|
||||
|
||||
return Array.Empty<RuleHitTrace>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets current statistics for a run.
|
||||
/// </summary>
|
||||
public RuleHitStatistics? GetStatistics(string runId)
|
||||
{
|
||||
if (!_runBuffers.TryGetValue(runId, out var buffer))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var traces = buffer.GetTraces();
|
||||
return RuleHitTraceFactory.CreateStatistics(
|
||||
runId,
|
||||
traces.FirstOrDefault()?.PolicyId ?? "unknown",
|
||||
traces,
|
||||
buffer.TotalRulesEvaluated,
|
||||
buffer.TotalEvaluationMs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flushes traces for a run to exporters.
|
||||
/// </summary>
|
||||
public async Task FlushAsync(string runId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_runBuffers.TryGetValue(runId, out var buffer))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var traces = buffer.FlushAndGet();
|
||||
if (traces.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var statistics = RuleHitTraceFactory.CreateStatistics(
|
||||
runId,
|
||||
traces.FirstOrDefault()?.PolicyId ?? "unknown",
|
||||
traces,
|
||||
buffer.TotalRulesEvaluated,
|
||||
buffer.TotalEvaluationMs);
|
||||
|
||||
foreach (var exporter in _exporters)
|
||||
{
|
||||
try
|
||||
{
|
||||
await exporter.ExportAsync(runId, traces, statistics, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Log but don't fail
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes a run and returns final statistics.
|
||||
/// </summary>
|
||||
public RuleHitStatistics CompleteRun(string runId, int totalRulesEvaluated, long totalEvaluationMs)
|
||||
{
|
||||
if (!_runBuffers.TryRemove(runId, out var buffer))
|
||||
{
|
||||
return new RuleHitStatistics
|
||||
{
|
||||
RunId = runId,
|
||||
PolicyId = "unknown",
|
||||
TotalRulesEvaluated = totalRulesEvaluated,
|
||||
TotalRulesFired = 0,
|
||||
TotalVexOverrides = 0,
|
||||
RulesFiredByCategory = ImmutableDictionary<string, int>.Empty,
|
||||
RulesFiredByOutcome = ImmutableDictionary<string, int>.Empty,
|
||||
VexOverridesByVendor = ImmutableDictionary<string, int>.Empty,
|
||||
VexOverridesByStatus = ImmutableDictionary<string, int>.Empty,
|
||||
TopRulesByHitCount = ImmutableArray<RuleHitCount>.Empty,
|
||||
TotalEvaluationMs = totalEvaluationMs
|
||||
};
|
||||
}
|
||||
|
||||
buffer.TotalRulesEvaluated = totalRulesEvaluated;
|
||||
buffer.TotalEvaluationMs = totalEvaluationMs;
|
||||
|
||||
var traces = buffer.GetTraces();
|
||||
return RuleHitTraceFactory.CreateStatistics(
|
||||
runId,
|
||||
traces.FirstOrDefault()?.PolicyId ?? "unknown",
|
||||
traces,
|
||||
totalRulesEvaluated,
|
||||
totalEvaluationMs);
|
||||
}
|
||||
|
||||
private bool ShouldSample(RuleHitTrace trace)
|
||||
{
|
||||
// Incident mode = 100% sampling
|
||||
if (_incidentMode)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check always-sample rules
|
||||
if (!_options.AlwaysSampleRules.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var pattern in _options.AlwaysSampleRules)
|
||||
{
|
||||
if (trace.RuleName.Contains(pattern, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// VEX overrides get elevated sampling
|
||||
if (trace.IsVexOverride)
|
||||
{
|
||||
return Sample(_options.VexOverrideSamplingRate);
|
||||
}
|
||||
|
||||
// High-severity outcomes get elevated sampling
|
||||
if (_options.HighSeverityOutcomes.Contains(trace.Outcome))
|
||||
{
|
||||
return Sample(_options.HighSeveritySamplingRate);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(trace.AssignedSeverity) &&
|
||||
_options.HighSeverityOutcomes.Contains(trace.AssignedSeverity))
|
||||
{
|
||||
return Sample(_options.HighSeveritySamplingRate);
|
||||
}
|
||||
|
||||
// Base sampling rate
|
||||
return Sample(_options.BaseSamplingRate);
|
||||
}
|
||||
|
||||
private bool Sample(double rate)
|
||||
{
|
||||
if (rate >= 1.0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (rate <= 0.0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (_samplerLock)
|
||||
{
|
||||
return _sampler.NextDouble() < rate;
|
||||
}
|
||||
}
|
||||
|
||||
private static void RecordMetrics(RuleHitTrace trace)
|
||||
{
|
||||
// Record to existing telemetry counters
|
||||
PolicyEngineTelemetry.RecordRuleFired(trace.PolicyId, trace.RuleName);
|
||||
|
||||
if (trace.IsVexOverride && !string.IsNullOrWhiteSpace(trace.VexVendor))
|
||||
{
|
||||
PolicyEngineTelemetry.RecordVexOverride(trace.PolicyId, trace.VexVendor);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_runBuffers.Clear();
|
||||
}
|
||||
|
||||
private sealed class RunTraceBuffer
|
||||
{
|
||||
private readonly List<RuleHitTrace> _traces = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public int TotalRulesEvaluated { get; set; }
|
||||
public long TotalEvaluationMs { get; set; }
|
||||
|
||||
public int Count
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _traces.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Add(RuleHitTrace trace)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_traces.Add(trace);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<RuleHitTrace> GetTraces()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _traces.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<RuleHitTrace> FlushAndGet()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var result = _traces.ToList();
|
||||
_traces.Clear();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports rule hit traces to structured logs.
|
||||
/// </summary>
|
||||
public sealed class LoggingRuleHitTraceExporter : IRuleHitTraceExporter
|
||||
{
|
||||
private readonly Action<string, RuleHitTrace>? _logTrace;
|
||||
private readonly Action<string, RuleHitStatistics>? _logStatistics;
|
||||
|
||||
public LoggingRuleHitTraceExporter(
|
||||
Action<string, RuleHitTrace>? logTrace = null,
|
||||
Action<string, RuleHitStatistics>? logStatistics = null)
|
||||
{
|
||||
_logTrace = logTrace;
|
||||
_logStatistics = logStatistics;
|
||||
}
|
||||
|
||||
public Task ExportAsync(
|
||||
string runId,
|
||||
IReadOnlyList<RuleHitTrace> traces,
|
||||
RuleHitStatistics statistics,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_logTrace is not null)
|
||||
{
|
||||
foreach (var trace in traces)
|
||||
{
|
||||
_logTrace(runId, trace);
|
||||
}
|
||||
}
|
||||
|
||||
_logStatistics?.Invoke(runId, statistics);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports rule hit traces to the Activity/span for distributed tracing.
|
||||
/// </summary>
|
||||
public sealed class ActivityRuleHitTraceExporter : IRuleHitTraceExporter
|
||||
{
|
||||
public Task ExportAsync(
|
||||
string runId,
|
||||
IReadOnlyList<RuleHitTrace> traces,
|
||||
RuleHitStatistics statistics,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var activity = Activity.Current;
|
||||
if (activity is null)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Add statistics as activity tags
|
||||
activity.SetTag("policy.rules_evaluated", statistics.TotalRulesEvaluated);
|
||||
activity.SetTag("policy.rules_fired", statistics.TotalRulesFired);
|
||||
activity.SetTag("policy.vex_overrides", statistics.TotalVexOverrides);
|
||||
activity.SetTag("policy.evaluation_ms", statistics.TotalEvaluationMs);
|
||||
|
||||
// Add top rules as events
|
||||
foreach (var rule in statistics.TopRulesByHitCount.Take(5))
|
||||
{
|
||||
var tags = new ActivityTagsCollection
|
||||
{
|
||||
{ "rule.name", rule.RuleName },
|
||||
{ "rule.hits", rule.HitCount },
|
||||
{ "rule.outcome", rule.Outcome }
|
||||
};
|
||||
|
||||
activity.AddEvent(new ActivityEvent("policy.rule.fired", tags: tags));
|
||||
}
|
||||
|
||||
// Add VEX override summary
|
||||
if (statistics.TotalVexOverrides > 0)
|
||||
{
|
||||
foreach (var (vendor, count) in statistics.VexOverridesByVendor)
|
||||
{
|
||||
var tags = new ActivityTagsCollection
|
||||
{
|
||||
{ "vex.vendor", vendor },
|
||||
{ "vex.count", count }
|
||||
};
|
||||
|
||||
activity.AddEvent(new ActivityEvent("policy.vex.override", tags: tags));
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory exporter for testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryRuleHitTraceExporter : IRuleHitTraceExporter
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ExportedRun> _exports = new();
|
||||
|
||||
public Task ExportAsync(
|
||||
string runId,
|
||||
IReadOnlyList<RuleHitTrace> traces,
|
||||
RuleHitStatistics statistics,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_exports.AddOrUpdate(
|
||||
runId,
|
||||
_ => new ExportedRun(traces.ToList(), statistics),
|
||||
(_, existing) =>
|
||||
{
|
||||
existing.Traces.AddRange(traces);
|
||||
return existing with { Statistics = statistics };
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public ExportedRun? GetExport(string runId)
|
||||
{
|
||||
_exports.TryGetValue(runId, out var export);
|
||||
return export;
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, ExportedRun> GetAllExports() =>
|
||||
_exports.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
|
||||
|
||||
public void Clear() => _exports.Clear();
|
||||
|
||||
public sealed record ExportedRun(List<RuleHitTrace> Traces, RuleHitStatistics Statistics);
|
||||
}
|
||||
Reference in New Issue
Block a user