337 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			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"
 | |
|         };
 | |
| }
 |