Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View 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);

View 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 { }