work work ... haaaard work

This commit is contained in:
StellaOps Bot
2025-11-24 00:34:20 +02:00
parent 0d4a986b7b
commit bb709b643e
36 changed files with 933 additions and 197 deletions

View File

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

View File

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

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

View File

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

View File

@@ -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)
{

View File

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

View File

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