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
485 lines
19 KiB
C#
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
|