using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using StellaOps.Scanner.WebService.Constants; using StellaOps.Scanner.WebService.Domain; using StellaOps.Scanner.WebService.Infrastructure; using StellaOps.Scanner.WebService.Security; using StellaOps.Scanner.WebService.Services; namespace StellaOps.Scanner.WebService.Endpoints; internal static class ExportEndpoints { private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Converters = { new JsonStringEnumConverter() }, WriteIndented = true }; public static void MapExportEndpoints(this RouteGroupBuilder scansGroup) { ArgumentNullException.ThrowIfNull(scansGroup); // GET /scans/{scanId}/exports/sarif scansGroup.MapGet("/{scanId}/exports/sarif", HandleExportSarifAsync) .WithName("scanner.scans.exports.sarif") .WithTags("Exports") .Produces(StatusCodes.Status200OK, contentType: "application/sarif+json") .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(ScannerPolicies.ScansRead); // GET /scans/{scanId}/exports/cdxr scansGroup.MapGet("/{scanId}/exports/cdxr", HandleExportCycloneDxRAsync) .WithName("scanner.scans.exports.cdxr") .WithTags("Exports") .Produces(StatusCodes.Status200OK, contentType: "application/vnd.cyclonedx+json; version=1.7") .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(ScannerPolicies.ScansRead); // GET /scans/{scanId}/exports/openvex scansGroup.MapGet("/{scanId}/exports/openvex", HandleExportOpenVexAsync) .WithName("scanner.scans.exports.openvex") .WithTags("Exports") .Produces(StatusCodes.Status200OK, contentType: "application/json") .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(ScannerPolicies.ScansRead); } private static async Task HandleExportSarifAsync( string scanId, IScanCoordinator coordinator, ISarifExportService exportService, HttpContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(coordinator); ArgumentNullException.ThrowIfNull(exportService); if (!ScanId.TryParse(scanId, out var parsed)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid scan identifier", StatusCodes.Status400BadRequest, detail: "Scan identifier is required."); } var snapshot = await coordinator.GetAsync(parsed, cancellationToken).ConfigureAwait(false); if (snapshot is null) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, "Scan not found", StatusCodes.Status404NotFound, detail: "Requested scan could not be located."); } var sarifDocument = await exportService.ExportAsync(parsed, cancellationToken).ConfigureAwait(false); if (sarifDocument is null) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, "No findings available", StatusCodes.Status404NotFound, detail: "No findings available for SARIF export."); } var json = JsonSerializer.Serialize(sarifDocument, SerializerOptions); return Results.Content(json, "application/sarif+json", System.Text.Encoding.UTF8, StatusCodes.Status200OK); } private static async Task HandleExportCycloneDxRAsync( string scanId, IScanCoordinator coordinator, ICycloneDxExportService exportService, HttpContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(coordinator); ArgumentNullException.ThrowIfNull(exportService); if (!ScanId.TryParse(scanId, out var parsed)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid scan identifier", StatusCodes.Status400BadRequest, detail: "Scan identifier is required."); } var snapshot = await coordinator.GetAsync(parsed, cancellationToken).ConfigureAwait(false); if (snapshot is null) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, "Scan not found", StatusCodes.Status404NotFound, detail: "Requested scan could not be located."); } var cdxDocument = await exportService.ExportWithReachabilityAsync(parsed, cancellationToken).ConfigureAwait(false); if (cdxDocument is null) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, "No findings available", StatusCodes.Status404NotFound, detail: "No findings available for CycloneDX export."); } var json = JsonSerializer.Serialize(cdxDocument, SerializerOptions); return Results.Content(json, "application/vnd.cyclonedx+json; version=1.7", System.Text.Encoding.UTF8, StatusCodes.Status200OK); } private static async Task HandleExportOpenVexAsync( string scanId, IScanCoordinator coordinator, IOpenVexExportService exportService, HttpContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(coordinator); ArgumentNullException.ThrowIfNull(exportService); if (!ScanId.TryParse(scanId, out var parsed)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid scan identifier", StatusCodes.Status400BadRequest, detail: "Scan identifier is required."); } var snapshot = await coordinator.GetAsync(parsed, cancellationToken).ConfigureAwait(false); if (snapshot is null) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, "Scan not found", StatusCodes.Status404NotFound, detail: "Requested scan could not be located."); } var vexDocument = await exportService.ExportAsync(parsed, cancellationToken).ConfigureAwait(false); if (vexDocument is null) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, "No VEX data available", StatusCodes.Status404NotFound, detail: "No VEX data available for export."); } var json = JsonSerializer.Serialize(vexDocument, SerializerOptions); return Results.Content(json, "application/json", System.Text.Encoding.UTF8, StatusCodes.Status200OK); } }