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

This commit is contained in:
StellaOps Bot
2025-11-28 00:45:16 +02:00
parent 3b96b2e3ea
commit 1c6730a1d2
95 changed files with 14504 additions and 463 deletions

View File

@@ -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()
};
}
}

View 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
};
}
}

View File

@@ -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);
}