using System.Collections.Generic; using System.Collections.Immutable; using System.Collections.ObjectModel; using System.Linq; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using StellaOps.Policy; using StellaOps.Scanner.WebService.Constants; using StellaOps.Scanner.WebService.Contracts; using StellaOps.Scanner.WebService.Infrastructure; using StellaOps.Scanner.WebService.Security; using StellaOps.Scanner.WebService.Services; using StellaOps.Zastava.Core.Contracts; using RuntimePolicyVerdict = StellaOps.Zastava.Core.Contracts.PolicyVerdict; namespace StellaOps.Scanner.WebService.Endpoints; internal static class PolicyEndpoints { private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; public static void MapPolicyEndpoints(this RouteGroupBuilder apiGroup, string policySegment) { ArgumentNullException.ThrowIfNull(apiGroup); var policyGroup = apiGroup .MapGroup(NormalizeSegment(policySegment)) .WithTags("Policy"); policyGroup.MapGet("/schema", HandleSchemaAsync) .WithName("scanner.policy.schema") .Produces(StatusCodes.Status200OK) .RequireAuthorization(ScannerPolicies.Reports) .WithOpenApi(operation => { operation.Summary = "Retrieve the embedded policy JSON schema."; operation.Description = "Returns the policy schema (`policy-schema@1`) used to validate YAML or JSON rulesets."; return operation; }); policyGroup.MapPost("/diagnostics", HandleDiagnosticsAsync) .WithName("scanner.policy.diagnostics") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .RequireAuthorization(ScannerPolicies.Reports) .WithOpenApi(operation => { operation.Summary = "Run policy diagnostics."; operation.Description = "Accepts YAML or JSON policy content and returns normalization issues plus recommendations (ignore rules, VEX include/exclude, vendor precedence)."; return operation; }); policyGroup.MapPost("/preview", HandlePreviewAsync) .WithName("scanner.policy.preview") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .RequireAuthorization(ScannerPolicies.Reports) .WithOpenApi(operation => { operation.Summary = "Preview policy impact against findings."; operation.Description = "Evaluates the supplied findings against the active or proposed policy, returning diffs, quieted verdicts, and actionable validation messages."; return operation; }); policyGroup.MapPost("/runtime", HandleRuntimePolicyAsync) .WithName("scanner.policy.runtime") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .RequireAuthorization(ScannerPolicies.Reports) .WithOpenApi(operation => { operation.Summary = "Evaluate runtime policy for digests."; operation.Description = "Returns per-image policy verdicts, signature and SBOM metadata, and cache hints for admission controllers."; return operation; }); } private static IResult HandleSchemaAsync(HttpContext context) { var schema = PolicySchemaResource.ReadSchemaJson(); return Results.Text(schema, "application/schema+json", Encoding.UTF8); } private static IResult HandleDiagnosticsAsync( PolicyDiagnosticsRequestDto request, TimeProvider timeProvider, HttpContext context) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(timeProvider); if (request.Policy is null || string.IsNullOrWhiteSpace(request.Policy.Content)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid policy diagnostics request", StatusCodes.Status400BadRequest, detail: "Policy content is required for diagnostics."); } var format = PolicyDtoMapper.ParsePolicyFormat(request.Policy.Format); var binding = PolicyBinder.Bind(request.Policy.Content, format); var diagnostics = PolicyDiagnostics.Create(binding, timeProvider); var response = new PolicyDiagnosticsResponseDto { Success = diagnostics.ErrorCount == 0, Version = diagnostics.Version, RuleCount = diagnostics.RuleCount, ErrorCount = diagnostics.ErrorCount, WarningCount = diagnostics.WarningCount, GeneratedAt = diagnostics.GeneratedAt, Issues = diagnostics.Issues.Select(PolicyDtoMapper.ToIssueDto).ToImmutableArray(), Recommendations = diagnostics.Recommendations }; return Json(response); } private static async Task HandlePreviewAsync( PolicyPreviewRequestDto request, PolicyPreviewService previewService, HttpContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(previewService); if (string.IsNullOrWhiteSpace(request.ImageDigest)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid policy preview request", StatusCodes.Status400BadRequest, detail: "imageDigest is required."); } if (!request.ImageDigest.Contains(':', StringComparison.Ordinal)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid policy preview request", StatusCodes.Status400BadRequest, detail: "imageDigest must include algorithm prefix (e.g. sha256:...)."); } if (request.Findings is not null) { var missingIds = request.Findings.Any(f => string.IsNullOrWhiteSpace(f.Id)); if (missingIds) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid policy preview request", StatusCodes.Status400BadRequest, detail: "All findings must include an id value."); } } var domainRequest = PolicyDtoMapper.ToDomain(request); var response = await previewService.PreviewAsync(domainRequest, cancellationToken).ConfigureAwait(false); var payload = PolicyDtoMapper.ToDto(response); return Json(payload); } private static async Task HandleRuntimePolicyAsync( RuntimePolicyRequestDto request, IRuntimePolicyService runtimePolicyService, HttpContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(runtimePolicyService); if (request.Images is null || request.Images.Count == 0) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid runtime policy request", StatusCodes.Status400BadRequest, detail: "images collection must include at least one digest."); } var normalizedImages = new List(); var seen = new HashSet(StringComparer.Ordinal); foreach (var image in request.Images) { if (string.IsNullOrWhiteSpace(image)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid runtime policy request", StatusCodes.Status400BadRequest, detail: "Image digests must be non-empty."); } var trimmed = image.Trim(); if (!trimmed.Contains(':', StringComparison.Ordinal)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid runtime policy request", StatusCodes.Status400BadRequest, detail: "Image digests must include an algorithm prefix (e.g. sha256:...)."); } if (seen.Add(trimmed)) { normalizedImages.Add(trimmed); } } if (normalizedImages.Count == 0) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid runtime policy request", StatusCodes.Status400BadRequest, detail: "images collection must include at least one unique digest."); } var namespaceValue = string.IsNullOrWhiteSpace(request.Namespace) ? null : request.Namespace.Trim(); var normalizedLabels = new Dictionary(StringComparer.Ordinal); if (request.Labels is not null) { foreach (var pair in request.Labels) { if (string.IsNullOrWhiteSpace(pair.Key)) { continue; } var key = pair.Key.Trim(); var value = pair.Value?.Trim() ?? string.Empty; normalizedLabels[key] = value; } } var evaluationRequest = new RuntimePolicyEvaluationRequest( namespaceValue, new ReadOnlyDictionary(normalizedLabels), normalizedImages); var evaluation = await runtimePolicyService.EvaluateAsync(evaluationRequest, cancellationToken).ConfigureAwait(false); var resultPayload = MapRuntimePolicyResponse(evaluation); return Json(resultPayload); } private static string NormalizeSegment(string segment) { if (string.IsNullOrWhiteSpace(segment)) { return "/policy"; } var trimmed = segment.Trim('/'); return "/" + trimmed; } private static IResult Json(T value) { var payload = JsonSerializer.Serialize(value, SerializerOptions); return Results.Content(payload, "application/json", Encoding.UTF8); } private static RuntimePolicyResponseDto MapRuntimePolicyResponse(RuntimePolicyEvaluationResult evaluation) { var results = new Dictionary(evaluation.Results.Count, StringComparer.Ordinal); foreach (var pair in evaluation.Results) { var decision = pair.Value; RuntimePolicyRekorDto? rekor = null; if (decision.Rekor is not null) { rekor = new RuntimePolicyRekorDto { Uuid = decision.Rekor.Uuid, Url = decision.Rekor.Url, Verified = decision.Rekor.Verified }; } string? metadata = null; if (decision.Metadata is not null && decision.Metadata.Count > 0) { metadata = JsonSerializer.Serialize(decision.Metadata, SerializerOptions); } results[pair.Key] = new RuntimePolicyImageResponseDto { PolicyVerdict = ToCamelCase(decision.PolicyVerdict), Signed = decision.Signed, HasSbomReferrers = decision.HasSbomReferrers, HasSbomLegacy = decision.HasSbomReferrers, Reasons = decision.Reasons.ToArray(), Rekor = rekor, Confidence = Math.Round(decision.Confidence, 6, MidpointRounding.AwayFromZero), Quieted = decision.Quieted, QuietedBy = decision.QuietedBy, Metadata = metadata }; } return new RuntimePolicyResponseDto { TtlSeconds = evaluation.TtlSeconds, ExpiresAtUtc = evaluation.ExpiresAtUtc, PolicyRevision = evaluation.PolicyRevision, Results = results }; } private static string ToCamelCase(RuntimePolicyVerdict verdict) => verdict switch { RuntimePolicyVerdict.Pass => "pass", RuntimePolicyVerdict.Warn => "warn", RuntimePolicyVerdict.Fail => "fail", RuntimePolicyVerdict.Error => "error", _ => "unknown" }; }