Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
351
src/__Libraries/StellaOps.Verdict/Api/VerdictEndpoints.cs
Normal file
351
src/__Libraries/StellaOps.Verdict/Api/VerdictEndpoints.cs
Normal file
@@ -0,0 +1,351 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
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;
|
||||
|
||||
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();
|
||||
|
||||
// GET /v1/verdicts/{id} - Get verdict by ID
|
||||
group.MapGet("/{id}", HandleGet)
|
||||
.WithName("verdict.get")
|
||||
.Produces<StellaVerdict>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization();
|
||||
|
||||
// GET /v1/verdicts - Query verdicts
|
||||
group.MapGet("/", HandleQuery)
|
||||
.WithName("verdict.query")
|
||||
.Produces<VerdictQueryResponse>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization();
|
||||
|
||||
// POST /v1/verdicts/{id}/verify - Verify verdict signature
|
||||
group.MapPost("/{id}/verify", HandleVerify)
|
||||
.WithName("verdict.verify")
|
||||
.Produces<VerdictVerifyResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization();
|
||||
|
||||
// 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();
|
||||
|
||||
// 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();
|
||||
|
||||
// DELETE /v1/verdicts/expired - Clean up expired verdicts
|
||||
group.MapDelete("/expired", HandleDeleteExpired)
|
||||
.WithName("verdict.deleteExpired")
|
||||
.Produces<ExpiredDeleteResponse>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization("verdict: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,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
var deletedCount = await store.DeleteExpiredAsync(tenantId, DateTimeOffset.UtcNow, cancellationToken);
|
||||
|
||||
logger.LogInformation("Deleted {Count} expired verdicts for tenant {TenantId}", deletedCount, tenantId);
|
||||
|
||||
return Json(new ExpiredDeleteResponse { DeletedCount = deletedCount }, StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
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 { }
|
||||
Reference in New Issue
Block a user