Files
git.stella-ops.org/src/Scanner/StellaOps.Scanner.WebService/Endpoints/PolicyEndpoints.cs
StellaOps Bot 1c6730a1d2
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
up
2025-11-28 00:45:16 +02:00

485 lines
19 KiB
C#

using System.Collections.Generic;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.Linq;
using System.Security.Cryptography;
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.Surface.Env;
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;
#pragma warning disable ASPDEPR002
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<PolicyDiagnosticsResponseDto>(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<PolicyPreviewResponseDto>(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<RuntimePolicyResponseDto>(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;
});
policyGroup.MapPost("/overlay", HandlePolicyOverlayAsync)
.WithName("scanner.policy.overlay")
.Produces<PolicyOverlayResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.Reports)
.WithOpenApi(operation =>
{
operation.Summary = "Request policy overlays for graph nodes.";
operation.Description = "Returns deterministic policy overlays with runtime evidence for graph nodes (Cartographer integration). Overlay IDs are computed as sha256(tenant|nodeId|overlayKind).";
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<IResult> 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<IResult> 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<string>();
var seen = new HashSet<string>(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<string, string>(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<string, string>(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>(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<string, RuntimePolicyImageResponseDto>(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,
BuildIds = decision.BuildIds is { Count: > 0 } ? decision.BuildIds.ToArray() : null
};
}
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"
};
private static async Task<IResult> HandlePolicyOverlayAsync(
PolicyOverlayRequestDto request,
IRuntimePolicyService runtimePolicyService,
ISurfaceEnvironment surfaceEnvironment,
TimeProvider timeProvider,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(runtimePolicyService);
ArgumentNullException.ThrowIfNull(surfaceEnvironment);
ArgumentNullException.ThrowIfNull(timeProvider);
if (request.Nodes is null || request.Nodes.Count == 0)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid policy overlay request",
StatusCodes.Status400BadRequest,
detail: "nodes collection must include at least one node.");
}
var tenant = !string.IsNullOrWhiteSpace(request.Tenant)
? request.Tenant.Trim()
: surfaceEnvironment.Settings.Tenant;
var overlayKind = !string.IsNullOrWhiteSpace(request.OverlayKind)
? request.OverlayKind.Trim()
: "policy.overlay.v1";
var imageDigests = request.Nodes
.Where(n => !string.IsNullOrWhiteSpace(n.ImageDigest))
.Select(n => n.ImageDigest!.Trim())
.Distinct(StringComparer.Ordinal)
.ToList();
RuntimePolicyEvaluationResult? evaluation = null;
if (imageDigests.Count > 0)
{
var evalRequest = new RuntimePolicyEvaluationRequest(
null,
new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(StringComparer.Ordinal)),
imageDigests);
evaluation = await runtimePolicyService.EvaluateAsync(evalRequest, cancellationToken).ConfigureAwait(false);
}
var overlays = new List<PolicyOverlayDto>(request.Nodes.Count);
foreach (var node in request.Nodes)
{
if (string.IsNullOrWhiteSpace(node.NodeId))
{
continue;
}
var nodeId = node.NodeId.Trim();
var overlayId = ComputeOverlayId(tenant, nodeId, overlayKind);
string verdict = "unknown";
IReadOnlyList<string> reasons = Array.Empty<string>();
double? confidence = null;
bool? quieted = null;
PolicyOverlayEvidenceDto? evidence = null;
if (!string.IsNullOrWhiteSpace(node.ImageDigest) &&
evaluation?.Results.TryGetValue(node.ImageDigest.Trim(), out var decision) == true)
{
verdict = ToCamelCase(decision.PolicyVerdict);
reasons = decision.Reasons.ToArray();
confidence = Math.Round(decision.Confidence, 6, MidpointRounding.AwayFromZero);
quieted = decision.Quieted;
if (request.IncludeEvidence)
{
RuntimePolicyRekorDto? rekor = null;
if (decision.Rekor is not null)
{
rekor = new RuntimePolicyRekorDto
{
Uuid = decision.Rekor.Uuid,
Url = decision.Rekor.Url,
Verified = decision.Rekor.Verified
};
}
evidence = new PolicyOverlayEvidenceDto
{
Signed = decision.Signed,
HasSbomReferrers = decision.HasSbomReferrers,
Rekor = rekor,
BuildIds = decision.BuildIds is { Count: > 0 } ? decision.BuildIds.ToArray() : null,
Metadata = decision.Metadata is { Count: > 0 }
? new ReadOnlyDictionary<string, string>(decision.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal))
: null
};
}
}
overlays.Add(new PolicyOverlayDto
{
OverlayId = overlayId,
NodeId = nodeId,
OverlayKind = overlayKind,
Verdict = verdict,
Reasons = reasons,
Confidence = confidence,
Quieted = quieted,
Evidence = evidence
});
}
var response = new PolicyOverlayResponseDto
{
Tenant = tenant,
GeneratedAt = timeProvider.GetUtcNow(),
PolicyRevision = evaluation?.PolicyRevision,
Overlays = overlays.OrderBy(o => o.NodeId, StringComparer.Ordinal).ToArray()
};
return Json(response);
}
private static string ComputeOverlayId(string tenant, string nodeId, string overlayKind)
{
var input = $"{tenant}|{nodeId}|{overlayKind}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}
#pragma warning restore ASPDEPR002