Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
159
src/__Libraries/StellaOps.Verdict/Api/VerdictContracts.cs
Normal file
159
src/__Libraries/StellaOps.Verdict/Api/VerdictContracts.cs
Normal file
@@ -0,0 +1,159 @@
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Verdict.Services;
|
||||
|
||||
namespace StellaOps.Verdict.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a new verdict.
|
||||
/// </summary>
|
||||
public sealed record VerdictCreateRequest
|
||||
{
|
||||
/// <summary>Vulnerability ID (CVE, GHSA, etc.).</summary>
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>Component PURL.</summary>
|
||||
public required string Purl { get; init; }
|
||||
|
||||
/// <summary>Component name.</summary>
|
||||
public string? ComponentName { get; init; }
|
||||
|
||||
/// <summary>Component version.</summary>
|
||||
public string? ComponentVersion { get; init; }
|
||||
|
||||
/// <summary>Image digest if in container context.</summary>
|
||||
public string? ImageDigest { get; init; }
|
||||
|
||||
/// <summary>Policy verdict result.</summary>
|
||||
public required PolicyVerdict PolicyVerdict { get; init; }
|
||||
|
||||
/// <summary>Knowledge inputs.</summary>
|
||||
public VerdictKnowledgeInputs? Knowledge { get; init; }
|
||||
|
||||
/// <summary>Generator name.</summary>
|
||||
public string? Generator { get; init; }
|
||||
|
||||
/// <summary>Generator version.</summary>
|
||||
public string? GeneratorVersion { get; init; }
|
||||
|
||||
/// <summary>Scan/run ID.</summary>
|
||||
public string? RunId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for verdict creation.
|
||||
/// </summary>
|
||||
public sealed record VerdictResponse
|
||||
{
|
||||
/// <summary>The generated verdict ID.</summary>
|
||||
public required string VerdictId { get; init; }
|
||||
|
||||
/// <summary>Verdict status.</summary>
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>Result disposition.</summary>
|
||||
public required string Disposition { get; init; }
|
||||
|
||||
/// <summary>Risk score.</summary>
|
||||
public double Score { get; init; }
|
||||
|
||||
/// <summary>Creation timestamp.</summary>
|
||||
public required string CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a verdict for list queries.
|
||||
/// </summary>
|
||||
public sealed record VerdictSummary
|
||||
{
|
||||
/// <summary>Verdict ID.</summary>
|
||||
public required string VerdictId { get; init; }
|
||||
|
||||
/// <summary>Vulnerability ID.</summary>
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>Component PURL.</summary>
|
||||
public required string Purl { get; init; }
|
||||
|
||||
/// <summary>Verdict status.</summary>
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>Result disposition.</summary>
|
||||
public required string Disposition { get; init; }
|
||||
|
||||
/// <summary>Risk score.</summary>
|
||||
public double Score { get; init; }
|
||||
|
||||
/// <summary>Creation timestamp.</summary>
|
||||
public required string CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for verdict queries.
|
||||
/// </summary>
|
||||
public sealed record VerdictQueryResponse
|
||||
{
|
||||
/// <summary>List of verdict summaries.</summary>
|
||||
public required List<VerdictSummary> Verdicts { get; init; }
|
||||
|
||||
/// <summary>Total count of matching verdicts.</summary>
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
/// <summary>Offset used in query.</summary>
|
||||
public int Offset { get; init; }
|
||||
|
||||
/// <summary>Limit used in query.</summary>
|
||||
public int Limit { get; init; }
|
||||
|
||||
/// <summary>Whether there are more results.</summary>
|
||||
public bool HasMore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to verify a verdict.
|
||||
/// </summary>
|
||||
public sealed record VerdictVerifyRequest
|
||||
{
|
||||
/// <summary>Optional trusted key IDs for signature verification.</summary>
|
||||
public List<string>? TrustedKeyIds { get; init; }
|
||||
|
||||
/// <summary>Optional inputs hash to verify against.</summary>
|
||||
public string? ExpectedInputsHash { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for verdict verification.
|
||||
/// </summary>
|
||||
public sealed record VerdictVerifyResponse
|
||||
{
|
||||
/// <summary>Verdict ID that was verified.</summary>
|
||||
public required string VerdictId { get; init; }
|
||||
|
||||
/// <summary>Whether the verdict has signatures.</summary>
|
||||
public bool HasSignatures { get; set; }
|
||||
|
||||
/// <summary>Number of signatures on the verdict.</summary>
|
||||
public int SignatureCount { get; set; }
|
||||
|
||||
/// <summary>Whether all signatures are valid.</summary>
|
||||
public bool Verified { get; set; }
|
||||
|
||||
/// <summary>Whether the content-addressable ID is valid.</summary>
|
||||
public bool ContentIdValid { get; set; }
|
||||
|
||||
/// <summary>Verification message or error.</summary>
|
||||
public string? VerificationMessage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for deleting expired verdicts.
|
||||
/// </summary>
|
||||
public sealed record ExpiredDeleteResponse
|
||||
{
|
||||
/// <summary>Number of deleted verdicts.</summary>
|
||||
public int DeletedCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generic error response.
|
||||
/// </summary>
|
||||
public sealed record ErrorResponse(string Message);
|
||||
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