using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging; using StellaOps.Verdict.Persistence; using StellaOps.Verdict.Schema; using StellaOps.Verdict.Services; using System.Text; using System.Text.Json; namespace StellaOps.Verdict.Api; /// /// REST API endpoints for StellaVerdict operations. /// public static class VerdictEndpoints { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false, }; private static readonly JsonSerializerOptions JsonLdOptions = new(JsonSerializerDefaults.Web) { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true, }; /// /// Maps verdict endpoints to the route builder. /// public static void MapVerdictEndpoints(this IEndpointRouteBuilder endpoints) { ArgumentNullException.ThrowIfNull(endpoints); var group = endpoints.MapGroup("/v1/verdicts"); // POST /v1/verdicts - Create and store a verdict group.MapPost("/", HandleCreate) .WithName("verdict.create") .Produces(StatusCodes.Status201Created) .Produces(StatusCodes.Status400BadRequest) .RequireAuthorization(VerdictPolicies.Create); // GET /v1/verdicts/{id} - Get verdict by ID group.MapGet("/{id}", HandleGet) .WithName("verdict.get") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(VerdictPolicies.Read); // GET /v1/verdicts - Query verdicts group.MapGet("/", HandleQuery) .WithName("verdict.query") .Produces(StatusCodes.Status200OK) .RequireAuthorization(VerdictPolicies.Read); // POST /v1/verdicts/build - Build deterministic verdict with CGS (CGS-003) group.MapPost("/build", HandleBuild) .WithName("verdict.build") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .RequireAuthorization(VerdictPolicies.Create); // GET /v1/verdicts/cgs/{cgsHash} - Replay verdict by CGS hash (CGS-004) group.MapGet("/cgs/{cgsHash}", HandleReplay) .WithName("verdict.replay") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(VerdictPolicies.Read); // POST /v1/verdicts/diff - Compute verdict delta (CGS-005) group.MapPost("/diff", HandleDiff) .WithName("verdict.diff") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .RequireAuthorization(VerdictPolicies.Read); // POST /v1/verdicts/{id}/verify - Verify verdict signature group.MapPost("/{id}/verify", HandleVerify) .WithName("verdict.verify") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(VerdictPolicies.Read); // GET /v1/verdicts/{id}/download - Download signed JSON-LD group.MapGet("/{id}/download", HandleDownload) .WithName("verdict.download") .Produces(StatusCodes.Status200OK, "application/ld+json") .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(VerdictPolicies.Read); // GET /v1/verdicts/latest - Get latest verdict for PURL+CVE group.MapGet("/latest", HandleGetLatest) .WithName("verdict.latest") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(VerdictPolicies.Read); // DELETE /v1/verdicts/expired - Clean up expired verdicts group.MapDelete("/expired", HandleDeleteExpired) .WithName("verdict.deleteExpired") .Produces(StatusCodes.Status200OK) .RequireAuthorization(VerdictPolicies.Admin); } private static async Task HandleCreate( VerdictCreateRequest request, IVerdictAssemblyService assemblyService, IVerdictStore store, HttpContext context, ILogger logger, CancellationToken cancellationToken) { if (request is null) { return Results.BadRequest(new ErrorResponse("Request body is required")); } var tenantId = GetTenantId(context); try { // Assemble the verdict from the request var assemblyContext = new VerdictAssemblyContext { VulnerabilityId = request.VulnerabilityId, Purl = request.Purl, ComponentName = request.ComponentName, ComponentVersion = request.ComponentVersion, ImageDigest = request.ImageDigest, PolicyVerdict = request.PolicyVerdict, ProofBundle = null, // Could be enhanced to accept proof bundle reference Knowledge = request.Knowledge, Generator = request.Generator ?? "StellaOps", GeneratorVersion = request.GeneratorVersion, RunId = request.RunId, }; var verdict = assemblyService.AssembleVerdict(assemblyContext); // Store the verdict var storeResult = await store.StoreAsync(verdict, tenantId, cancellationToken); if (!storeResult.Success) { logger.LogError("Failed to store verdict: {Error}", storeResult.Error); return Results.BadRequest(new ErrorResponse(storeResult.Error ?? "Storage failed")); } var response = new VerdictResponse { VerdictId = verdict.VerdictId, Status = verdict.Claim.Status.ToString(), Disposition = verdict.Result.Disposition, Score = verdict.Result.Score, CreatedAt = verdict.Provenance.CreatedAt, }; return Results.Created($"/v1/verdicts/{Uri.EscapeDataString(verdict.VerdictId)}", response); } catch (Exception ex) { logger.LogError(ex, "Failed to create verdict for {Purl}/{Cve}", request.Purl, request.VulnerabilityId); return Results.BadRequest(new ErrorResponse($"Failed to create verdict: {ex.Message}")); } } private static async Task HandleGet( string id, IVerdictStore store, HttpContext context, CancellationToken cancellationToken) { var tenantId = GetTenantId(context); var verdictId = Uri.UnescapeDataString(id); var verdict = await store.GetAsync(verdictId, tenantId, cancellationToken); if (verdict is null) { return Results.NotFound(); } return Json(verdict, StatusCodes.Status200OK); } private static async Task HandleQuery( string? purl, string? cve, string? status, string? imageDigest, int? limit, int? offset, IVerdictStore store, HttpContext context, CancellationToken cancellationToken) { var tenantId = GetTenantId(context); VerdictStatus? statusFilter = null; if (!string.IsNullOrEmpty(status) && Enum.TryParse(status, true, out var parsed)) { statusFilter = parsed; } var query = new VerdictQuery { TenantId = tenantId, Purl = purl, CveId = cve, Status = statusFilter, ImageDigest = imageDigest, Limit = Math.Min(limit ?? 50, 100), Offset = offset ?? 0, }; var result = await store.QueryAsync(query, cancellationToken); var response = new VerdictQueryResponse { Verdicts = result.Verdicts.Select(v => new VerdictSummary { VerdictId = v.VerdictId, VulnerabilityId = v.Subject.VulnerabilityId, Purl = v.Subject.Purl, Status = v.Claim.Status.ToString(), Disposition = v.Result.Disposition, Score = v.Result.Score, CreatedAt = v.Provenance.CreatedAt, }).ToList(), TotalCount = result.TotalCount, Offset = result.Offset, Limit = result.Limit, HasMore = result.HasMore, }; return Json(response, StatusCodes.Status200OK); } private static async Task HandleVerify( string id, VerdictVerifyRequest? request, IVerdictStore store, IVerdictSigningService signingService, HttpContext context, ILogger logger, CancellationToken cancellationToken) { var tenantId = GetTenantId(context); var verdictId = Uri.UnescapeDataString(id); var verdict = await store.GetAsync(verdictId, tenantId, cancellationToken); if (verdict is null) { return Results.NotFound(); } var response = new VerdictVerifyResponse { VerdictId = verdictId, HasSignatures = !verdict.Signatures.IsDefaultOrEmpty, SignatureCount = verdict.Signatures.IsDefaultOrEmpty ? 0 : verdict.Signatures.Length, // Full verification would require trusted keys from request or key store Verified = false, VerificationMessage = verdict.Signatures.IsDefaultOrEmpty ? "Verdict has no signatures" : "Signature verification requires trusted keys", }; // Verify content-addressable ID var expectedId = verdict.ComputeVerdictId(); response.ContentIdValid = string.Equals(verdict.VerdictId, expectedId, StringComparison.Ordinal); if (!response.ContentIdValid) { response.VerificationMessage = "Content ID mismatch - verdict may have been tampered with"; } return Json(response, StatusCodes.Status200OK); } private static async Task HandleDownload( string id, IVerdictStore store, HttpContext context, CancellationToken cancellationToken) { var tenantId = GetTenantId(context); var verdictId = Uri.UnescapeDataString(id); var verdict = await store.GetAsync(verdictId, tenantId, cancellationToken); if (verdict is null) { return Results.NotFound(); } var json = JsonSerializer.Serialize(verdict, JsonLdOptions); context.Response.Headers.ContentDisposition = $"attachment; filename=\"verdict-{verdict.Subject.VulnerabilityId}.jsonld\""; return Results.Content(json, "application/ld+json", Encoding.UTF8, StatusCodes.Status200OK); } private static async Task HandleGetLatest( string purl, string cve, IVerdictStore store, HttpContext context, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(purl) || string.IsNullOrEmpty(cve)) { return Results.BadRequest(new ErrorResponse("Both purl and cve query parameters are required")); } var tenantId = GetTenantId(context); var verdict = await store.GetLatestAsync(purl, cve, tenantId, cancellationToken); if (verdict is null) { return Results.NotFound(); } return Json(verdict, StatusCodes.Status200OK); } private static async Task HandleDeleteExpired( IVerdictStore store, HttpContext context, ILogger logger, TimeProvider timeProvider, CancellationToken cancellationToken) { var tenantId = GetTenantId(context); var deletedCount = await store.DeleteExpiredAsync(tenantId, timeProvider.GetUtcNow(), cancellationToken); logger.LogInformation("Deleted {Count} expired verdicts for tenant {TenantId}", deletedCount, tenantId); return Json(new ExpiredDeleteResponse { DeletedCount = deletedCount }, StatusCodes.Status200OK); } // ----------------------------------------------------------------------------- // CGS-specific handlers (SPRINT_20251229_001_001_BE_cgs_infrastructure) // ----------------------------------------------------------------------------- private static async Task HandleBuild( BuildVerdictRequest request, IVerdictBuilder verdictBuilder, HttpContext context, ILogger logger, CancellationToken cancellationToken) { if (request is null || request.Evidence is null || request.PolicyLock is null) { return Results.BadRequest(new ErrorResponse("Evidence and PolicyLock are required")); } try { var result = await verdictBuilder.BuildAsync(request.Evidence, request.PolicyLock, cancellationToken); logger.LogInformation( "Verdict built successfully: cgs={CgsHash}, status={Status}", result.CgsHash, result.Verdict.Status); return Json(result, StatusCodes.Status200OK); } catch (Exception ex) { logger.LogError(ex, "Failed to build verdict"); return Results.BadRequest(new ErrorResponse($"Failed to build verdict: {ex.Message}")); } } private static async Task HandleReplay( string cgsHash, IVerdictBuilder verdictBuilder, ILogger logger, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(cgsHash)) { return Results.BadRequest(new ErrorResponse("CGS hash is required")); } try { var result = await verdictBuilder.ReplayAsync(cgsHash, cancellationToken); if (result is null) { logger.LogWarning("Verdict not found for CGS hash: {CgsHash}", cgsHash); return Results.NotFound(); } return Json(result, StatusCodes.Status200OK); } catch (Exception ex) { logger.LogError(ex, "Failed to replay verdict for cgs={CgsHash}", cgsHash); return Results.BadRequest(new ErrorResponse($"Failed to replay verdict: {ex.Message}")); } } private static async Task HandleDiff( VerdictDiffRequest request, IVerdictBuilder verdictBuilder, ILogger logger, CancellationToken cancellationToken) { if (request is null || string.IsNullOrWhiteSpace(request.FromCgs) || string.IsNullOrWhiteSpace(request.ToCgs)) { return Results.BadRequest(new ErrorResponse("FromCgs and ToCgs are required")); } try { var delta = await verdictBuilder.DiffAsync(request.FromCgs, request.ToCgs, cancellationToken); logger.LogInformation( "Verdict diff computed: from={From}, to={To}, changes={ChangeCount}", request.FromCgs, request.ToCgs, delta.Changes.Count); return Json(delta, StatusCodes.Status200OK); } catch (Exception ex) { logger.LogError(ex, "Failed to diff verdicts: from={From}, to={To}", request.FromCgs, request.ToCgs); return Results.BadRequest(new ErrorResponse($"Failed to diff verdicts: {ex.Message}")); } } private static Guid GetTenantId(HttpContext context) { // Try to get tenant ID from claims or header var tenantClaim = context.User.FindFirst("tenant_id")?.Value; if (!string.IsNullOrEmpty(tenantClaim) && Guid.TryParse(tenantClaim, out var claimTenantId)) { return claimTenantId; } // Fallback to header if (context.Request.Headers.TryGetValue("X-Tenant-Id", out var headerValue) && Guid.TryParse(headerValue.FirstOrDefault(), out var headerTenantId)) { return headerTenantId; } // Default tenant for development return Guid.Empty; } private static IResult Json(T value, int statusCode) { var payload = JsonSerializer.Serialize(value, JsonOptions); return Results.Content(payload, "application/json", Encoding.UTF8, statusCode); } } /// /// Marker class for logger category. /// internal sealed class VerdictEndpointsLogger { }