Files
git.stella-ops.org/src/Scanner/StellaOps.Scanner.WebService/Endpoints/ReportEndpoints.cs
StellaOps Bot 564df71bfb
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
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
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
up
2025-12-13 00:20:26 +02:00

301 lines
11 KiB
C#

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<ReportResponseDto>(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<IResult> 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<string, object?>(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<PolicyPreviewVerdictDto> 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<PolicyPreviewVerdictDto> 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>(T value)
{
var payload = JsonSerializer.Serialize(value, SerializerOptions);
return Results.Content(payload, "application/json", Encoding.UTF8);
}
}
#pragma warning restore ASPDEPR002