Files
git.stella-ops.org/src/__Libraries/StellaOps.Verdict/Api/VerdictEndpoints.cs

469 lines
17 KiB
C#

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;
/// <summary>
/// REST API endpoints for StellaVerdict operations.
/// </summary>
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,
};
/// <summary>
/// Maps verdict endpoints to the route builder.
/// </summary>
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<VerdictResponse>(StatusCodes.Status201Created)
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest)
.RequireAuthorization(VerdictPolicies.Create);
// GET /v1/verdicts/{id} - Get verdict by ID
group.MapGet("/{id}", HandleGet)
.WithName("verdict.get")
.Produces<StellaVerdict>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(VerdictPolicies.Read);
// GET /v1/verdicts - Query verdicts
group.MapGet("/", HandleQuery)
.WithName("verdict.query")
.Produces<VerdictQueryResponse>(StatusCodes.Status200OK)
.RequireAuthorization(VerdictPolicies.Read);
// POST /v1/verdicts/build - Build deterministic verdict with CGS (CGS-003)
group.MapPost("/build", HandleBuild)
.WithName("verdict.build")
.Produces<CgsVerdictResult>(StatusCodes.Status200OK)
.Produces<ErrorResponse>(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<CgsVerdictResult>(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<VerdictDelta>(StatusCodes.Status200OK)
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest)
.RequireAuthorization(VerdictPolicies.Read);
// POST /v1/verdicts/{id}/verify - Verify verdict signature
group.MapPost("/{id}/verify", HandleVerify)
.WithName("verdict.verify")
.Produces<VerdictVerifyResponse>(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<StellaVerdict>(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<StellaVerdict>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(VerdictPolicies.Read);
// DELETE /v1/verdicts/expired - Clean up expired verdicts
group.MapDelete("/expired", HandleDeleteExpired)
.WithName("verdict.deleteExpired")
.Produces<ExpiredDeleteResponse>(StatusCodes.Status200OK)
.RequireAuthorization(VerdictPolicies.Admin);
}
private static async Task<IResult> HandleCreate(
VerdictCreateRequest request,
IVerdictAssemblyService assemblyService,
IVerdictStore store,
HttpContext context,
ILogger<VerdictEndpointsLogger> 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<IResult> 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<IResult> 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<VerdictStatus>(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<IResult> HandleVerify(
string id,
VerdictVerifyRequest? request,
IVerdictStore store,
IVerdictSigningService signingService,
HttpContext context,
ILogger<VerdictEndpointsLogger> 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<IResult> 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<IResult> 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<IResult> HandleDeleteExpired(
IVerdictStore store,
HttpContext context,
ILogger<VerdictEndpointsLogger> 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<IResult> HandleBuild(
BuildVerdictRequest request,
IVerdictBuilder verdictBuilder,
HttpContext context,
ILogger<VerdictEndpointsLogger> 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<IResult> HandleReplay(
string cgsHash,
IVerdictBuilder verdictBuilder,
ILogger<VerdictEndpointsLogger> 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<IResult> HandleDiff(
VerdictDiffRequest request,
IVerdictBuilder verdictBuilder,
ILogger<VerdictEndpointsLogger> 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>(T value, int statusCode)
{
var payload = JsonSerializer.Serialize(value, JsonOptions);
return Results.Content(payload, "application/json", Encoding.UTF8, statusCode);
}
}
/// <summary>
/// Marker class for logger category.
/// </summary>
internal sealed class VerdictEndpointsLogger { }