work
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
public static class PathScopeSimulationEndpoint
|
||||
{
|
||||
public static IEndpointRouteBuilder MapPathScopeSimulation(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
routes.MapPost("/simulation/path-scope", HandleAsync)
|
||||
.WithName("PolicyEngine.PathScopeSimulation")
|
||||
.WithOpenApi();
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleAsync(
|
||||
[FromBody] PathScopeSimulationRequest request,
|
||||
PathScopeSimulationService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var stream = service.StreamAsync(request, cancellationToken);
|
||||
var responseBuilder = new StringBuilder();
|
||||
|
||||
await foreach (var line in stream.ConfigureAwait(false))
|
||||
{
|
||||
responseBuilder.AppendLine(line);
|
||||
}
|
||||
|
||||
return Results.Text(responseBuilder.ToString(), "application/x-ndjson", Encoding.UTF8);
|
||||
}
|
||||
catch (PathScopeSimulationException ex)
|
||||
{
|
||||
var errorLine = JsonSerializer.Serialize(ex.Error);
|
||||
return Results.Text(errorLine + "\n", "application/x-ndjson", Encoding.UTF8, StatusCodes.Status400BadRequest);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,11 @@ using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Policy.Engine.Hosting;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.Policy.Engine.Endpoints;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.Policy.Engine.Endpoints;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Workers;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
using StellaOps.AirGap.Policy;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -105,9 +106,10 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<PolicyEngineO
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<PolicyEngineStartupDiagnostics>();
|
||||
builder.Services.AddHostedService<PolicyEngineBootstrapWorker>();
|
||||
builder.Services.AddSingleton<PolicyCompiler>();
|
||||
builder.Services.AddSingleton<PolicyCompilationService>();
|
||||
builder.Services.AddSingleton<PolicyEvaluationService>();
|
||||
builder.Services.AddSingleton<PolicyCompiler>();
|
||||
builder.Services.AddSingleton<PolicyCompilationService>();
|
||||
builder.Services.AddSingleton<PolicyEvaluationService>();
|
||||
builder.Services.AddSingleton<PathScopeSimulationService>();
|
||||
builder.Services.AddSingleton<IPolicyPackRepository, InMemoryPolicyPackRepository>();
|
||||
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
@@ -144,16 +146,17 @@ var app = builder.Build();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapHealthChecks("/healthz");
|
||||
app.MapGet("/readyz", (PolicyEngineStartupDiagnostics diagnostics) =>
|
||||
diagnostics.IsReady
|
||||
? Results.Ok(new { status = "ready" })
|
||||
: Results.StatusCode(StatusCodes.Status503ServiceUnavailable))
|
||||
.WithName("Readiness");
|
||||
|
||||
app.MapGet("/", () => Results.Redirect("/healthz"));
|
||||
|
||||
app.MapPolicyCompilation();
|
||||
app.MapPolicyPacks();
|
||||
|
||||
app.Run();
|
||||
app.MapHealthChecks("/healthz");
|
||||
app.MapGet("/readyz", (PolicyEngineStartupDiagnostics diagnostics) =>
|
||||
diagnostics.IsReady
|
||||
? Results.Ok(new { status = "ready" })
|
||||
: Results.StatusCode(StatusCodes.Status503ServiceUnavailable))
|
||||
.WithName("Readiness");
|
||||
|
||||
app.MapGet("/", () => Results.Redirect("/healthz"));
|
||||
|
||||
app.MapPolicyCompilation();
|
||||
app.MapPolicyPacks();
|
||||
app.MapPathScopeSimulation();
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
public sealed partial class PolicyEvaluationService
|
||||
{
|
||||
public Task<JsonObject> EvaluatePathScopeAsync(
|
||||
PathScopeSimulationRequest request,
|
||||
PathScopeTarget target,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var stableKey = string.Create(CultureInfo.InvariantCulture, $"{request.BasePolicyRef}|{request.CandidatePolicyRef}|{target.FilePath}|{target.Pattern}");
|
||||
var verdictDelta = ComputeDelta(stableKey);
|
||||
|
||||
var finding = new JsonObject
|
||||
{
|
||||
["id"] = target.EvidenceHash ?? "stub-ghsa",
|
||||
["ruleId"] = "policy.rules.path-scope.stub",
|
||||
["severity"] = "info",
|
||||
["verdict"] = new JsonObject
|
||||
{
|
||||
["base"] = verdictDelta.baseVerdict,
|
||||
["candidate"] = verdictDelta.candidateVerdict,
|
||||
["delta"] = verdictDelta.delta
|
||||
},
|
||||
["evidence"] = new JsonObject
|
||||
{
|
||||
["locator"] = new JsonObject
|
||||
{
|
||||
["filePath"] = target.FilePath,
|
||||
["digest"] = target.Digest
|
||||
},
|
||||
["provenance"] = new JsonObject
|
||||
{
|
||||
["ingestedAt"] = target.IngestedAt?.ToString("O", CultureInfo.InvariantCulture),
|
||||
["connectorId"] = target.ConnectorId
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var envelope = new JsonObject
|
||||
{
|
||||
["tenant"] = request.Tenant,
|
||||
["subject"] = JsonSerializer.SerializeToNode(request.Subject, SerializerOptions),
|
||||
["target"] = new JsonObject
|
||||
{
|
||||
["filePath"] = target.FilePath,
|
||||
["pattern"] = target.Pattern,
|
||||
["pathMatch"] = target.PathMatch,
|
||||
["confidence"] = target.Confidence,
|
||||
["evidenceHash"] = target.EvidenceHash
|
||||
},
|
||||
["finding"] = finding,
|
||||
["trace"] = new JsonArray
|
||||
{
|
||||
new JsonObject { ["step"] = "match", ["path"] = target.FilePath },
|
||||
new JsonObject { ["step"] = "decision", ["effect"] = verdictDelta.candidateVerdict }
|
||||
},
|
||||
["metrics"] = new JsonObject
|
||||
{
|
||||
["evalTicks"] = stableKey.Length,
|
||||
["rulesEvaluated"] = 1,
|
||||
["bindings"] = 1
|
||||
}
|
||||
};
|
||||
|
||||
return Task.FromResult(envelope);
|
||||
}
|
||||
|
||||
private static (string baseVerdict, string candidateVerdict, string delta) ComputeDelta(string stableKey)
|
||||
{
|
||||
// Deterministic pseudo verdict using SHA-256 over the stable key.
|
||||
Span<byte> hashBytes = stackalloc byte[32];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(stableKey), hashBytes);
|
||||
|
||||
// Use lowest byte to determine delta.
|
||||
var flag = hashBytes[0];
|
||||
var baseVerdict = "deny";
|
||||
var candidateVerdict = (flag & 1) == 0 ? "warn" : "deny";
|
||||
var delta = baseVerdict == candidateVerdict ? "unchanged" : "softened";
|
||||
return (baseVerdict, candidateVerdict, delta);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,27 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.Policy.Engine.Evaluation;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
internal sealed class PolicyEvaluationService
|
||||
{
|
||||
private readonly PolicyEvaluator evaluator = new();
|
||||
|
||||
public PolicyEvaluationResult Evaluate(PolicyIrDocument document, PolicyEvaluationContext context)
|
||||
{
|
||||
if (document is null)
|
||||
{
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.Policy.Engine.Evaluation;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
internal sealed class PolicyEvaluationService
|
||||
{
|
||||
private readonly PolicyEvaluator evaluator = new();
|
||||
private readonly PathScopeMetrics _pathMetrics;
|
||||
|
||||
public PolicyEvaluationService() : this(new PathScopeMetrics())
|
||||
{
|
||||
}
|
||||
|
||||
public PolicyEvaluationService(PathScopeMetrics pathMetrics)
|
||||
{
|
||||
_pathMetrics = pathMetrics ?? throw new ArgumentNullException(nameof(pathMetrics));
|
||||
}
|
||||
|
||||
public PolicyEvaluationResult Evaluate(PolicyIrDocument document, PolicyEvaluationContext context)
|
||||
{
|
||||
if (document is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(document));
|
||||
}
|
||||
|
||||
@@ -19,8 +29,10 @@ internal sealed class PolicyEvaluationService
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
var request = new PolicyEvaluationRequest(document, context);
|
||||
return evaluator.Evaluate(request);
|
||||
}
|
||||
}
|
||||
|
||||
var request = new PolicyEvaluationRequest(document, context);
|
||||
return evaluator.Evaluate(request);
|
||||
}
|
||||
|
||||
// PathScopeSimulationService partial class relies on _pathMetrics.
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// Request contract for POLICY-ENGINE-29-002/003 streaming simulations.
|
||||
/// </summary>
|
||||
public sealed record PathScopeSimulationRequest
|
||||
(
|
||||
string SchemaVersion,
|
||||
string Tenant,
|
||||
string BasePolicyRef,
|
||||
string CandidatePolicyRef,
|
||||
PathScopeSubject Subject,
|
||||
IReadOnlyList<PathScopeTarget> Targets,
|
||||
SimulationOptions Options
|
||||
);
|
||||
|
||||
public sealed record PathScopeSubject(
|
||||
string? Purl,
|
||||
string? Cpe,
|
||||
string? PackagePath,
|
||||
string? OsImage
|
||||
)
|
||||
{
|
||||
public bool HasCoordinates => !string.IsNullOrWhiteSpace(Purl) || !string.IsNullOrWhiteSpace(Cpe);
|
||||
}
|
||||
|
||||
public sealed record PathScopeTarget(
|
||||
string FilePath,
|
||||
string Pattern,
|
||||
string PathMatch,
|
||||
double Confidence,
|
||||
int? DepthLimit,
|
||||
string? Digest,
|
||||
string? TreeDigest,
|
||||
string? EvidenceHash,
|
||||
DateTimeOffset? IngestedAt,
|
||||
string? ConnectorId
|
||||
);
|
||||
|
||||
public sealed record SimulationOptions(
|
||||
string Sort,
|
||||
int? MaxFindings,
|
||||
bool IncludeTrace,
|
||||
bool Deterministic
|
||||
);
|
||||
|
||||
public sealed record PathScopeSimulationResult(
|
||||
string Tenant,
|
||||
PathScopeSubject Subject,
|
||||
PathScopeResultTarget Target,
|
||||
PathScopeFinding Finding,
|
||||
IReadOnlyList<TraceStep> Trace,
|
||||
PathScopeMetrics Metrics
|
||||
);
|
||||
|
||||
public sealed record PathScopeResultTarget(
|
||||
string FilePath,
|
||||
string Pattern,
|
||||
string PathMatch,
|
||||
double Confidence,
|
||||
string? EvidenceHash
|
||||
);
|
||||
|
||||
public sealed record PathScopeFinding(
|
||||
string Id,
|
||||
string RuleId,
|
||||
string Severity,
|
||||
FindingVerdict Verdict,
|
||||
FindingEvidence Evidence
|
||||
);
|
||||
|
||||
public sealed record FindingVerdict(string Base, string Candidate, string Delta);
|
||||
|
||||
public sealed record FindingEvidence(FindingLocator Locator, FindingProvenance Provenance);
|
||||
|
||||
public sealed record FindingLocator(string FilePath, string? Digest);
|
||||
|
||||
public sealed record FindingProvenance(DateTimeOffset? IngestedAt, string? ConnectorId);
|
||||
|
||||
public sealed record TraceStep(string Step, string? Rule, string? Path, string? Effect);
|
||||
|
||||
public sealed record PathScopeMetrics(int EvalTicks, int RulesEvaluated, int Bindings);
|
||||
|
||||
/// <summary>
|
||||
/// Error envelope for NDJSON streaming.
|
||||
/// </summary>
|
||||
public sealed record PathScopeSimulationError(
|
||||
[property: JsonPropertyName("type")] string Type,
|
||||
[property: JsonPropertyName("code")] string Code,
|
||||
[property: JsonPropertyName("message")] string Message
|
||||
)
|
||||
{
|
||||
public static PathScopeSimulationError Schema(string message) => new("error", "POLICY_29_002_SCHEMA", message);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal, deterministic implementation of path/scope-aware streaming simulation (POLICY-ENGINE-29-003).
|
||||
/// 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
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly PolicyEvaluationService _evaluationService;
|
||||
|
||||
public PathScopeSimulationService(PolicyEvaluationService evaluationService)
|
||||
{
|
||||
_evaluationService = evaluationService ?? throw new ArgumentNullException(nameof(evaluationService));
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<string> StreamAsync(PathScopeSimulationRequest request, CancellationToken ct = default)
|
||||
{
|
||||
ValidateRequest(request);
|
||||
|
||||
var orderedTargets = request.Targets
|
||||
.OrderBy(t => t.FilePath, StringComparer.Ordinal)
|
||||
.ThenBy(t => t.Pattern, StringComparer.Ordinal)
|
||||
.ThenByDescending(t => t.Confidence)
|
||||
.ToList();
|
||||
|
||||
return StreamResultsAsync(request, orderedTargets, ct);
|
||||
}
|
||||
|
||||
private static void ValidateRequest(PathScopeSimulationRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.SchemaVersion))
|
||||
{
|
||||
throw new PathScopeSimulationException(PathScopeSimulationError.Schema("schemaVersion is required"));
|
||||
}
|
||||
|
||||
if (request.Targets is null || request.Targets.Count == 0)
|
||||
{
|
||||
throw new PathScopeSimulationException(PathScopeSimulationError.Schema("At least one target is required"));
|
||||
}
|
||||
|
||||
if (!request.Subject.HasCoordinates)
|
||||
{
|
||||
throw new PathScopeSimulationException(PathScopeSimulationError.Schema("subject.purl or subject.cpe is required"));
|
||||
}
|
||||
|
||||
foreach (var target in request.Targets)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(target.FilePath))
|
||||
{
|
||||
throw new PathScopeSimulationException(PathScopeSimulationError.Schema("target.filePath is required"));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(target.Pattern))
|
||||
{
|
||||
throw new PathScopeSimulationException(PathScopeSimulationError.Schema("target.pattern is required"));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(target.PathMatch))
|
||||
{
|
||||
throw new PathScopeSimulationException(PathScopeSimulationError.Schema("target.pathMatch is required"));
|
||||
}
|
||||
}
|
||||
|
||||
if (!request.Options.Deterministic)
|
||||
{
|
||||
throw new PathScopeSimulationException(PathScopeSimulationError.Schema("options.deterministic must be true"));
|
||||
}
|
||||
}
|
||||
|
||||
private async IAsyncEnumerable<string> StreamResultsAsync(
|
||||
PathScopeSimulationRequest request,
|
||||
IReadOnlyList<PathScopeTarget> orderedTargets,
|
||||
[EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
foreach (var target in orderedTargets)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var evaluation = await _evaluationService.EvaluatePathScopeAsync(
|
||||
request, target, ct).ConfigureAwait(false);
|
||||
|
||||
yield return evaluation.ToJsonString(SerializerOptions);
|
||||
await Task.Yield();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PathScopeSimulationException : Exception
|
||||
{
|
||||
public PathScopeSimulationException(PathScopeSimulationError error)
|
||||
: base(error.Message)
|
||||
{
|
||||
Error = error;
|
||||
}
|
||||
|
||||
public PathScopeSimulationError Error { get; }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class PathScopeSimulationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task StreamAsync_ReturnsDeterministicOrdering()
|
||||
{
|
||||
var service = new PathScopeSimulationService();
|
||||
|
||||
var request = new PathScopeSimulationRequest(
|
||||
SchemaVersion: "1.0.0",
|
||||
Tenant: "acme",
|
||||
BasePolicyRef: "policy://acme/main@sha256:1",
|
||||
CandidatePolicyRef: "policy://acme/feat@sha256:2",
|
||||
Subject: new PathScopeSubject("pkg:npm/lodash@4.17.21", null, "lib.js", null),
|
||||
Targets: new[]
|
||||
{
|
||||
new PathScopeTarget("b/file.js", "b/", "prefix", 0.5, null, null, null, "e2", null, null),
|
||||
new PathScopeTarget("a/file.js", "a/", "prefix", 0.9, null, null, null, "e1", null, null)
|
||||
},
|
||||
Options: new SimulationOptions("path,finding,verdict", 100, IncludeTrace: true, Deterministic: true));
|
||||
|
||||
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\""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StreamAsync_ThrowsOnMissingTarget()
|
||||
{
|
||||
var service = new PathScopeSimulationService();
|
||||
var request = new PathScopeSimulationRequest(
|
||||
SchemaVersion: "1.0.0",
|
||||
Tenant: "acme",
|
||||
BasePolicyRef: "policy://acme/main@sha256:1",
|
||||
CandidatePolicyRef: "policy://acme/feat@sha256:2",
|
||||
Subject: new PathScopeSubject("pkg:npm/lodash@4.17.21", null, null, null),
|
||||
Targets: Array.Empty<PathScopeTarget>(),
|
||||
Options: new SimulationOptions("path,finding,verdict", 100, IncludeTrace: true, Deterministic: true));
|
||||
|
||||
await Assert.ThrowsAsync<PathScopeSimulationException>(() => service.StreamAsync(request).ToListAsync());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user