469 lines
17 KiB
C#
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 { }
|