Files
git.stella-ops.org/src/StellaOps.Scanner.WebService/Endpoints/PolicyEndpoints.cs
Vladimir Moushkov b51037a9b8
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
up
2025-10-24 19:19:23 +03:00

337 lines
13 KiB
C#

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