using System.Collections.Generic; 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 Microsoft.Extensions.Logging; 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; namespace StellaOps.Scanner.WebService.Endpoints; #pragma warning disable ASPDEPR002 internal static class ReportEndpoints { private const string PayloadType = "application/vnd.stellaops.report+json"; private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Converters = { new JsonStringEnumConverter() } }; public static void MapReportEndpoints(this RouteGroupBuilder apiGroup, string reportsSegment) { ArgumentNullException.ThrowIfNull(apiGroup); var reports = apiGroup .MapGroup(NormalizeSegment(reportsSegment)) .WithTags("Reports"); reports.MapPost("/", HandleCreateReportAsync) .WithName("scanner.reports.create") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status503ServiceUnavailable) .RequireAuthorization(ScannerPolicies.Reports) .WithOpenApi(operation => { operation.Summary = "Assemble a signed scan report."; operation.Description = "Aggregates latest findings with the active policy snapshot, returning verdicts plus an optional DSSE envelope."; return operation; }); } private static async Task HandleCreateReportAsync( ReportRequestDto request, PolicyPreviewService previewService, IReportSigner signer, TimeProvider timeProvider, IReportEventDispatcher eventDispatcher, ISurfacePointerService surfacePointerService, ILinksetResolver linksetResolver, ILoggerFactory loggerFactory, HttpContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(previewService); ArgumentNullException.ThrowIfNull(signer); ArgumentNullException.ThrowIfNull(timeProvider); ArgumentNullException.ThrowIfNull(eventDispatcher); ArgumentNullException.ThrowIfNull(surfacePointerService); ArgumentNullException.ThrowIfNull(linksetResolver); ArgumentNullException.ThrowIfNull(loggerFactory); var logger = loggerFactory.CreateLogger("Scanner.WebService.Reports"); if (string.IsNullOrWhiteSpace(request.ImageDigest)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid report request", StatusCodes.Status400BadRequest, detail: "imageDigest is required."); } if (!request.ImageDigest.Contains(':', StringComparison.Ordinal)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid report request", StatusCodes.Status400BadRequest, detail: "imageDigest must include algorithm prefix (e.g. sha256:...)."); } if (request.Findings is not null && request.Findings.Any(f => string.IsNullOrWhiteSpace(f.Id))) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid report request", StatusCodes.Status400BadRequest, detail: "All findings must include an id value."); } var previewDto = new PolicyPreviewRequestDto { ImageDigest = request.ImageDigest, Findings = request.Findings, Baseline = request.Baseline, Policy = null }; var domainRequest = PolicyDtoMapper.ToDomain(previewDto) with { ProposedPolicy = null }; var preview = await previewService.PreviewAsync(domainRequest, cancellationToken).ConfigureAwait(false); if (!preview.Success) { var issues = preview.Issues.Select(PolicyDtoMapper.ToIssueDto).ToArray(); var extensions = new Dictionary(StringComparer.Ordinal) { ["issues"] = issues }; return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Unable to assemble report", StatusCodes.Status503ServiceUnavailable, detail: "No policy snapshot is available or validation failed.", extensions: extensions); } var projectedVerdicts = preview.Diffs .Select(diff => PolicyDtoMapper.ToVerdictDto(diff.Projected)) .ToArray(); var issuesDto = preview.Issues.Select(PolicyDtoMapper.ToIssueDto).ToArray(); var summary = BuildSummary(projectedVerdicts); var verdict = ComputeVerdict(projectedVerdicts); var reportId = CreateReportId(request.ImageDigest!, preview.PolicyDigest); var generatedAt = timeProvider.GetUtcNow(); var linksets = await linksetResolver.ResolveAsync(request.Findings, cancellationToken).ConfigureAwait(false); SurfacePointersDto? surfacePointers = null; try { surfacePointers = await surfacePointerService .TryBuildAsync(request.ImageDigest!, context.RequestAborted) .ConfigureAwait(false); } catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) { throw; } catch (Exception ex) { if (!context.RequestAborted.IsCancellationRequested) { logger.LogDebug(ex, "Failed to build surface pointers for digest {Digest}.", request.ImageDigest); } } var document = new ReportDocumentDto { ReportId = reportId, ImageDigest = request.ImageDigest!, GeneratedAt = generatedAt, Verdict = verdict, Policy = new ReportPolicyDto { RevisionId = preview.RevisionId, Digest = preview.PolicyDigest }, Summary = summary, Verdicts = projectedVerdicts, Issues = issuesDto, Surface = surfacePointers, Linksets = linksets.Count == 0 ? null : linksets }; var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(document, SerializerOptions); var signature = signer.Sign(payloadBytes); DsseEnvelopeDto? envelope = null; if (signature is not null) { envelope = new DsseEnvelopeDto { PayloadType = PayloadType, Payload = Convert.ToBase64String(payloadBytes), Signatures = new[] { new DsseSignatureDto { KeyId = signature.KeyId, Algorithm = signature.Algorithm, Signature = signature.Signature } } }; } var response = new ReportResponseDto { Report = document, Dsse = envelope }; await eventDispatcher .PublishAsync(request, preview, document, envelope, context, cancellationToken) .ConfigureAwait(false); return Json(response); } private static ReportSummaryDto BuildSummary(IReadOnlyList verdicts) { if (verdicts.Count == 0) { return new ReportSummaryDto { Total = 0 }; } var blocked = verdicts.Count(v => string.Equals(v.Status, nameof(PolicyVerdictStatus.Blocked), StringComparison.OrdinalIgnoreCase)); var warned = verdicts.Count(v => string.Equals(v.Status, nameof(PolicyVerdictStatus.Warned), StringComparison.OrdinalIgnoreCase) || string.Equals(v.Status, nameof(PolicyVerdictStatus.Deferred), StringComparison.OrdinalIgnoreCase) || string.Equals(v.Status, nameof(PolicyVerdictStatus.RequiresVex), StringComparison.OrdinalIgnoreCase) || string.Equals(v.Status, nameof(PolicyVerdictStatus.Escalated), StringComparison.OrdinalIgnoreCase)); var ignored = verdicts.Count(v => string.Equals(v.Status, nameof(PolicyVerdictStatus.Ignored), StringComparison.OrdinalIgnoreCase)); var quieted = verdicts.Count(v => v.Quiet is true); return new ReportSummaryDto { Total = verdicts.Count, Blocked = blocked, Warned = warned, Ignored = ignored, Quieted = quieted }; } private static string ComputeVerdict(IReadOnlyList verdicts) { if (verdicts.Count == 0) { return "unknown"; } if (verdicts.Any(v => string.Equals(v.Status, nameof(PolicyVerdictStatus.Blocked), StringComparison.OrdinalIgnoreCase))) { return "blocked"; } if (verdicts.Any(v => string.Equals(v.Status, nameof(PolicyVerdictStatus.Escalated), StringComparison.OrdinalIgnoreCase))) { return "escalated"; } if (verdicts.Any(v => string.Equals(v.Status, nameof(PolicyVerdictStatus.Warned), StringComparison.OrdinalIgnoreCase) || string.Equals(v.Status, nameof(PolicyVerdictStatus.Deferred), StringComparison.OrdinalIgnoreCase) || string.Equals(v.Status, nameof(PolicyVerdictStatus.RequiresVex), StringComparison.OrdinalIgnoreCase))) { return "warn"; } return "pass"; } private static string CreateReportId(string imageDigest, string policyDigest) { var builder = new StringBuilder(); builder.Append(imageDigest.Trim()); builder.Append('|'); builder.Append(policyDigest ?? string.Empty); using var sha256 = SHA256.Create(); var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(builder.ToString())); var hex = Convert.ToHexString(hash.AsSpan(0, 10)).ToLowerInvariant(); return $"report-{hex}"; } private static string NormalizeSegment(string segment) { if (string.IsNullOrWhiteSpace(segment)) { return "/reports"; } 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); } } #pragma warning restore ASPDEPR002