work work ... haaaard work
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
@@ -10,8 +11,7 @@ public static class PathScopeSimulationEndpoint
|
||||
public static IEndpointRouteBuilder MapPathScopeSimulation(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
routes.MapPost("/simulation/path-scope", HandleAsync)
|
||||
.WithName("PolicyEngine.PathScopeSimulation")
|
||||
.WithOpenApi();
|
||||
.WithName("PolicyEngine.PathScopeSimulation");
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
@@ -104,13 +104,14 @@ builder.Services.AddOptions<PolicyEngineOptions>()
|
||||
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<PolicyEngineOptions>>().Value);
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<PolicyEngineStartupDiagnostics>();
|
||||
builder.Services.AddHostedService<PolicyEngineBootstrapWorker>();
|
||||
builder.Services.AddSingleton<PolicyEngineStartupDiagnostics>();
|
||||
builder.Services.AddHostedService<PolicyEngineBootstrapWorker>();
|
||||
builder.Services.AddSingleton<PolicyCompiler>();
|
||||
builder.Services.AddSingleton<PolicyCompilationService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PathScopeMetrics>();
|
||||
builder.Services.AddSingleton<PolicyEvaluationService>();
|
||||
builder.Services.AddSingleton<PathScopeSimulationService>();
|
||||
builder.Services.AddSingleton<IPolicyPackRepository, InMemoryPolicyPackRepository>();
|
||||
builder.Services.AddSingleton<IPolicyPackRepository, InMemoryPolicyPackRepository>();
|
||||
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
||||
|
||||
185
src/Policy/StellaOps.Policy.Engine/Services/PathScopeMetrics.cs
Normal file
185
src/Policy/StellaOps.Policy.Engine/Services/PathScopeMetrics.cs
Normal file
@@ -0,0 +1,185 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Metrics sink for path/scope-aware policy evaluation (POLICY-ENGINE-29-004).
|
||||
/// Mirrors the prep contract at docs/modules/policy/prep/2025-11-20-policy-engine-29-004-prep.md.
|
||||
/// </summary>
|
||||
internal sealed class PathScopeMetrics : IDisposable
|
||||
{
|
||||
private readonly Meter _meter;
|
||||
private readonly Counter<long> _evaluations;
|
||||
private readonly Histogram<double> _evaluationDurationMs;
|
||||
private readonly Counter<long> _cacheHit;
|
||||
private readonly Counter<long> _scopeMismatch;
|
||||
private readonly ConcurrentDictionary<(string Tenant, string Source), CoverageState> _coverage = new();
|
||||
private readonly ObservableGauge<double> _coverageGauge;
|
||||
|
||||
public PathScopeMetrics()
|
||||
{
|
||||
_meter = new Meter("StellaOps.Policy.Engine", "1.0.0");
|
||||
|
||||
_evaluations = _meter.CreateCounter<long>(
|
||||
name: "policy.path.eval.total",
|
||||
unit: "count",
|
||||
description: "Total path/scope-aware evaluations processed.");
|
||||
|
||||
_evaluationDurationMs = _meter.CreateHistogram<double>(
|
||||
name: "policy.path.eval.duration.ms",
|
||||
unit: "ms",
|
||||
description: "Latency distribution for path/scope-aware evaluations.");
|
||||
|
||||
_cacheHit = _meter.CreateCounter<long>(
|
||||
name: "policy.path.eval.cache.hit",
|
||||
unit: "count",
|
||||
description: "Cache hit/miss counts for path/scope rule lookups.");
|
||||
|
||||
_scopeMismatch = _meter.CreateCounter<long>(
|
||||
name: "policy.path.eval.scope.mismatch",
|
||||
unit: "count",
|
||||
description: "Counts of scope mismatches (depth/confidence/coverage).");
|
||||
|
||||
Func<IEnumerable<Measurement<double>>> observe = ObserveCoverage;
|
||||
_coverageGauge = _meter.CreateObservableGauge<double>(
|
||||
name: "policy.path.eval.coverage",
|
||||
observeValues: observe,
|
||||
unit: "percent",
|
||||
description: "Share of observations with matching scope.");
|
||||
}
|
||||
|
||||
public void RecordEvaluation(
|
||||
string tenant,
|
||||
string subject,
|
||||
string ruleId,
|
||||
string pathMatch,
|
||||
string result,
|
||||
double durationMs,
|
||||
bool scopeMatched = true)
|
||||
{
|
||||
var evalTags = new[]
|
||||
{
|
||||
new KeyValuePair<string, object?>("tenant", NormalizeTenant(tenant)),
|
||||
new KeyValuePair<string, object?>("subject", NormalizeSubject(subject)),
|
||||
new KeyValuePair<string, object?>("result", NormalizeResult(result)),
|
||||
new KeyValuePair<string, object?>("ruleId", TruncateRule(ruleId)),
|
||||
new KeyValuePair<string, object?>("pathMatch", NormalizePathMatch(pathMatch))
|
||||
};
|
||||
|
||||
_evaluations.Add(1, evalTags);
|
||||
|
||||
var durationTags = new[]
|
||||
{
|
||||
new KeyValuePair<string, object?>("tenant", NormalizeTenant(tenant)),
|
||||
new KeyValuePair<string, object?>("subject", NormalizeSubject(subject)),
|
||||
new KeyValuePair<string, object?>("ruleId", TruncateRule(ruleId))
|
||||
};
|
||||
|
||||
_evaluationDurationMs.Record(durationMs, durationTags);
|
||||
RecordCoverage(tenant, "path-scope", scopeMatched);
|
||||
}
|
||||
|
||||
public void RecordCacheHit(string tenant, string cache, bool hit)
|
||||
{
|
||||
var tags = new[]
|
||||
{
|
||||
new KeyValuePair<string, object?>("tenant", NormalizeTenant(tenant)),
|
||||
new KeyValuePair<string, object?>("cache", NormalizeCache(cache)),
|
||||
new KeyValuePair<string, object?>("hit", hit ? "true" : "false")
|
||||
};
|
||||
|
||||
_cacheHit.Add(1, tags);
|
||||
}
|
||||
|
||||
public void RecordScopeMismatch(string tenant, string reason)
|
||||
{
|
||||
var tags = new[]
|
||||
{
|
||||
new KeyValuePair<string, object?>("tenant", NormalizeTenant(tenant)),
|
||||
new KeyValuePair<string, object?>("reason", NormalizeScopeReason(reason))
|
||||
};
|
||||
|
||||
_scopeMismatch.Add(1, tags);
|
||||
RecordCoverage(tenant, "path-scope", matched: false);
|
||||
}
|
||||
|
||||
private void RecordCoverage(string tenant, string source, bool matched)
|
||||
{
|
||||
var key = (NormalizeTenant(tenant), NormalizeSource(source));
|
||||
|
||||
_coverage.AddOrUpdate(
|
||||
key,
|
||||
_ => matched ? new CoverageState(1, 1) : new CoverageState(0, 1),
|
||||
(_, state) => matched
|
||||
? new CoverageState(state.Matched + 1, state.Total + 1)
|
||||
: new CoverageState(state.Matched, state.Total + 1));
|
||||
}
|
||||
|
||||
private IEnumerable<Measurement<double>> ObserveCoverage()
|
||||
{
|
||||
foreach (var kvp in _coverage)
|
||||
{
|
||||
var state = kvp.Value;
|
||||
if (state.Total == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var percentage = state.Matched * 100.0 / state.Total;
|
||||
yield return new Measurement<double>(
|
||||
percentage,
|
||||
new[]
|
||||
{
|
||||
new KeyValuePair<string, object?>("tenant", kvp.Key.Tenant),
|
||||
new KeyValuePair<string, object?>("source", kvp.Key.Source)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeTenant(string tenant) =>
|
||||
string.IsNullOrWhiteSpace(tenant) ? "unknown" : tenant;
|
||||
|
||||
private static string NormalizeSubject(string subject) =>
|
||||
string.IsNullOrWhiteSpace(subject) ? "unknown" : subject;
|
||||
|
||||
private static string NormalizeResult(string result) =>
|
||||
result switch
|
||||
{
|
||||
"allow" or "deny" or "error" => result,
|
||||
_ => "deny"
|
||||
};
|
||||
|
||||
private static string NormalizePathMatch(string pathMatch) =>
|
||||
pathMatch switch
|
||||
{
|
||||
"exact" or "prefix" or "glob" => pathMatch,
|
||||
_ => "unknown"
|
||||
};
|
||||
|
||||
private static string NormalizeCache(string cache) =>
|
||||
string.IsNullOrWhiteSpace(cache) ? "decision" : cache;
|
||||
|
||||
private static string NormalizeScopeReason(string reason) =>
|
||||
string.IsNullOrWhiteSpace(reason) ? "no-scope" : reason;
|
||||
|
||||
private static string NormalizeSource(string source) =>
|
||||
string.IsNullOrWhiteSpace(source) ? "path-scope" : source;
|
||||
|
||||
private static string TruncateRule(string ruleId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ruleId))
|
||||
{
|
||||
return "unspecified";
|
||||
}
|
||||
|
||||
return ruleId.Length <= 32 ? ruleId : ruleId[..32];
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_meter.Dispose();
|
||||
}
|
||||
|
||||
private readonly record struct CoverageState(long Matched, long Total);
|
||||
}
|
||||
@@ -1,10 +1,23 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
public sealed partial class PolicyEvaluationService
|
||||
internal sealed partial class PolicyEvaluationService
|
||||
{
|
||||
private const string StubRuleId = "policy.rules.path-scope.stub";
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public Task<JsonObject> EvaluatePathScopeAsync(
|
||||
PathScopeSimulationRequest request,
|
||||
PathScopeTarget target,
|
||||
@@ -12,13 +25,16 @@ public sealed partial class PolicyEvaluationService
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var start = Stopwatch.GetTimestamp();
|
||||
var stableKey = string.Create(CultureInfo.InvariantCulture, $"{request.BasePolicyRef}|{request.CandidatePolicyRef}|{target.FilePath}|{target.Pattern}");
|
||||
var verdictDelta = ComputeDelta(stableKey);
|
||||
var result = NormalizeResult(verdictDelta.candidateVerdict);
|
||||
var correlationId = ComputeCorrelationId(stableKey);
|
||||
|
||||
var finding = new JsonObject
|
||||
{
|
||||
["id"] = target.EvidenceHash ?? "stub-ghsa",
|
||||
["ruleId"] = "policy.rules.path-scope.stub",
|
||||
["ruleId"] = StubRuleId,
|
||||
["severity"] = "info",
|
||||
["verdict"] = new JsonObject
|
||||
{
|
||||
@@ -67,6 +83,34 @@ public sealed partial class PolicyEvaluationService
|
||||
}
|
||||
};
|
||||
|
||||
var durationMs = ElapsedMilliseconds(start);
|
||||
((JsonObject)envelope["metrics"]!)[@"durationMs"] = Math.Round(durationMs, 3, MidpointRounding.ToZero);
|
||||
|
||||
_pathMetrics.RecordEvaluation(
|
||||
tenant: request.Tenant,
|
||||
subject: SimplifySubject(request.Subject),
|
||||
ruleId: StubRuleId,
|
||||
pathMatch: target.PathMatch,
|
||||
result: result,
|
||||
durationMs: durationMs,
|
||||
scopeMatched: true);
|
||||
|
||||
_pathMetrics.RecordCacheHit(request.Tenant, cache: "rule", hit: false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Policy.PathEval {@Tenant} {@RuleId} {@Subject} {@FilePath} {@PathMatch} {@Pattern} {@Confidence} {@Decision} {@DurationMs} {@EvidenceHash} {@CorrelationId}",
|
||||
request.Tenant,
|
||||
StubRuleId,
|
||||
SimplifySubject(request.Subject),
|
||||
target.FilePath,
|
||||
target.PathMatch,
|
||||
target.Pattern,
|
||||
target.Confidence,
|
||||
verdictDelta.candidateVerdict,
|
||||
durationMs,
|
||||
target.EvidenceHash ?? string.Empty,
|
||||
correlationId);
|
||||
|
||||
return Task.FromResult(envelope);
|
||||
}
|
||||
|
||||
@@ -83,4 +127,56 @@ public sealed partial class PolicyEvaluationService
|
||||
var delta = baseVerdict == candidateVerdict ? "unchanged" : "softened";
|
||||
return (baseVerdict, candidateVerdict, delta);
|
||||
}
|
||||
|
||||
private static string NormalizeResult(string candidateVerdict) =>
|
||||
string.Equals(candidateVerdict, "deny", StringComparison.OrdinalIgnoreCase) ? "deny" : "allow";
|
||||
|
||||
private static double ElapsedMilliseconds(long startTimestamp)
|
||||
{
|
||||
var elapsedTicks = Stopwatch.GetTimestamp() - startTimestamp;
|
||||
return elapsedTicks * 1000.0 / Stopwatch.Frequency;
|
||||
}
|
||||
|
||||
private static string ComputeCorrelationId(string stableKey)
|
||||
{
|
||||
Span<byte> hashBytes = stackalloc byte[16];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(stableKey), hashBytes);
|
||||
return Convert.ToHexString(hashBytes);
|
||||
}
|
||||
|
||||
private static string SimplifySubject(PathScopeSubject subject)
|
||||
{
|
||||
if (subject is null)
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(subject.Purl))
|
||||
{
|
||||
var purl = subject.Purl;
|
||||
var trimmed = purl.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase) ? purl[4..] : purl;
|
||||
var slashIndex = trimmed.IndexOf('/', StringComparison.Ordinal);
|
||||
if (slashIndex >= 0 && slashIndex + 1 < trimmed.Length)
|
||||
{
|
||||
var remainder = trimmed[(slashIndex + 1)..];
|
||||
var atIndex = remainder.IndexOf('@');
|
||||
var withoutVersion = atIndex >= 0 ? remainder[..atIndex] : remainder;
|
||||
var lastSlash = withoutVersion.LastIndexOf('/');
|
||||
return lastSlash >= 0 ? withoutVersion[(lastSlash + 1)..] : withoutVersion;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(subject.Cpe))
|
||||
{
|
||||
var parts = subject.Cpe.Split(':', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length > 4)
|
||||
{
|
||||
return parts[4];
|
||||
}
|
||||
|
||||
return parts.LastOrDefault() ?? "unknown";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.Policy.Engine.Evaluation;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
internal sealed class PolicyEvaluationService
|
||||
internal sealed partial class PolicyEvaluationService
|
||||
{
|
||||
private readonly PolicyEvaluator evaluator = new();
|
||||
private readonly PathScopeMetrics _pathMetrics;
|
||||
private readonly ILogger<PolicyEvaluationService> _logger;
|
||||
|
||||
public PolicyEvaluationService() : this(new PathScopeMetrics())
|
||||
public PolicyEvaluationService()
|
||||
: this(new PathScopeMetrics(), NullLogger<PolicyEvaluationService>.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
public PolicyEvaluationService(PathScopeMetrics pathMetrics)
|
||||
public PolicyEvaluationService(PathScopeMetrics pathMetrics, ILogger<PolicyEvaluationService> logger)
|
||||
{
|
||||
_pathMetrics = pathMetrics ?? throw new ArgumentNullException(nameof(pathMetrics));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public PolicyEvaluationResult Evaluate(PolicyIrDocument document, PolicyEvaluationContext context)
|
||||
internal PolicyEvaluationResult Evaluate(PolicyIrDocument document, PolicyEvaluationContext context)
|
||||
{
|
||||
if (document is null)
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Runtime.CompilerServices;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Streaming;
|
||||
@@ -10,7 +11,7 @@ namespace StellaOps.Policy.Engine.Streaming;
|
||||
/// Current behaviour emits no findings but enforces request validation, canonical ordering,
|
||||
/// and NDJSON framing so downstream consumers can integrate without schema drift.
|
||||
/// </summary>
|
||||
public sealed class PathScopeSimulationService
|
||||
internal sealed class PathScopeSimulationService
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
@@ -19,6 +20,10 @@ public sealed class PathScopeSimulationService
|
||||
|
||||
private readonly PolicyEvaluationService _evaluationService;
|
||||
|
||||
public PathScopeSimulationService() : this(new PolicyEvaluationService())
|
||||
{
|
||||
}
|
||||
|
||||
public PathScopeSimulationService(PolicyEvaluationService evaluationService)
|
||||
{
|
||||
_evaluationService = evaluationService ?? throw new ArgumentNullException(nameof(evaluationService));
|
||||
|
||||
@@ -27,8 +27,8 @@ public sealed class PathScopeSimulationServiceTests
|
||||
var lines = await service.StreamAsync(request).ToListAsync();
|
||||
|
||||
Assert.Equal(2, lines.Count);
|
||||
Assert.Contains(lines[0], s => s.Contains("\"filePath\":\"a/file.js\""));
|
||||
Assert.Contains(lines[1], s => s.Contains("\"filePath\":\"b/file.js\""));
|
||||
Assert.Contains("\"filePath\":\"a/file.js\"", lines[0]);
|
||||
Assert.Contains("\"filePath\":\"b/file.js\"", lines[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -44,6 +44,7 @@ public sealed class PathScopeSimulationServiceTests
|
||||
Targets: Array.Empty<PathScopeTarget>(),
|
||||
Options: new SimulationOptions("path,finding,verdict", 100, IncludeTrace: true, Deterministic: true));
|
||||
|
||||
await Assert.ThrowsAsync<PathScopeSimulationException>(() => service.StreamAsync(request).ToListAsync());
|
||||
await Assert.ThrowsAsync<PathScopeSimulationException>(async () =>
|
||||
await service.StreamAsync(request).ToListAsync());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user