463 lines
18 KiB
C#
463 lines
18 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
|
using StellaOps.VexLens.Api;
|
|
using StellaOps.VexLens.Delta;
|
|
using StellaOps.VexLens.NoiseGate;
|
|
using StellaOps.VexLens.Storage;
|
|
|
|
namespace StellaOps.VexLens.WebService.Extensions;
|
|
|
|
/// <summary>
|
|
/// Extension methods for mapping VexLens API endpoints.
|
|
/// </summary>
|
|
public static class VexLensEndpointExtensions
|
|
{
|
|
private const string TenantHeader = "X-StellaOps-Tenant";
|
|
|
|
/// <summary>
|
|
/// Maps all VexLens API endpoints.
|
|
/// </summary>
|
|
public static IEndpointRouteBuilder MapVexLensEndpoints(this IEndpointRouteBuilder app)
|
|
{
|
|
var group = app.MapGroup("/api/v1/vexlens")
|
|
.WithTags("VexLens")
|
|
.WithOpenApi();
|
|
|
|
// Consensus endpoints
|
|
group.MapPost("/consensus", ComputeConsensusAsync)
|
|
.WithName("ComputeConsensus")
|
|
.WithDescription("Compute consensus for a vulnerability-product pair")
|
|
.Produces<ComputeConsensusResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status400BadRequest);
|
|
|
|
group.MapPost("/consensus:withProof", ComputeConsensusWithProofAsync)
|
|
.WithName("ComputeConsensusWithProof")
|
|
.WithDescription("Compute consensus with full proof object for audit trail")
|
|
.Produces<ComputeConsensusWithProofResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status400BadRequest);
|
|
|
|
group.MapPost("/consensus:batch", ComputeConsensusBatchAsync)
|
|
.WithName("ComputeConsensusBatch")
|
|
.WithDescription("Compute consensus for multiple vulnerability-product pairs")
|
|
.Produces<ComputeConsensusBatchResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status400BadRequest);
|
|
|
|
// Projection endpoints
|
|
group.MapGet("/projections", QueryProjectionsAsync)
|
|
.WithName("QueryProjections")
|
|
.WithDescription("Query consensus projections with filtering")
|
|
.Produces<QueryProjectionsResponse>(StatusCodes.Status200OK);
|
|
|
|
group.MapGet("/projections/{projectionId}", GetProjectionAsync)
|
|
.WithName("GetProjection")
|
|
.WithDescription("Get a specific consensus projection by ID")
|
|
.Produces<ProjectionDetailResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
group.MapGet("/projections/latest", GetLatestProjectionAsync)
|
|
.WithName("GetLatestProjection")
|
|
.WithDescription("Get the latest projection for a vulnerability-product pair")
|
|
.Produces<ProjectionDetailResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
group.MapGet("/projections/history", GetProjectionHistoryAsync)
|
|
.WithName("GetProjectionHistory")
|
|
.WithDescription("Get projection history for a vulnerability-product pair")
|
|
.Produces<ProjectionHistoryResponse>(StatusCodes.Status200OK);
|
|
|
|
// Statistics endpoint
|
|
group.MapGet("/stats", GetStatisticsAsync)
|
|
.WithName("GetVexLensStatistics")
|
|
.WithDescription("Get consensus projection statistics")
|
|
.Produces<ConsensusStatisticsResponse>(StatusCodes.Status200OK);
|
|
|
|
// Conflict endpoints
|
|
group.MapGet("/conflicts", GetConflictsAsync)
|
|
.WithName("GetConflicts")
|
|
.WithDescription("Get projections with conflicts")
|
|
.Produces<QueryProjectionsResponse>(StatusCodes.Status200OK);
|
|
|
|
// Delta/Noise-Gating endpoints
|
|
var deltaGroup = app.MapGroup("/api/v1/vexlens/deltas")
|
|
.WithTags("VexLens Delta")
|
|
.WithOpenApi();
|
|
|
|
deltaGroup.MapPost("/compute", ComputeDeltaAsync)
|
|
.WithName("ComputeDelta")
|
|
.WithDescription("Compute delta report between two snapshots")
|
|
.Produces<DeltaReportResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status400BadRequest);
|
|
|
|
var gatingGroup = app.MapGroup("/api/v1/vexlens/gating")
|
|
.WithTags("VexLens Gating")
|
|
.WithOpenApi();
|
|
|
|
gatingGroup.MapGet("/statistics", GetGatingStatisticsAsync)
|
|
.WithName("GetGatingStatistics")
|
|
.WithDescription("Get aggregated noise-gating statistics")
|
|
.Produces<AggregatedGatingStatisticsResponse>(StatusCodes.Status200OK);
|
|
|
|
gatingGroup.MapPost("/snapshots/{snapshotId}/gate", GateSnapshotAsync)
|
|
.WithName("GateSnapshot")
|
|
.WithDescription("Apply noise-gating to a snapshot")
|
|
.Produces<GatedSnapshotResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
// Issuer endpoints
|
|
var issuerGroup = app.MapGroup("/api/v1/vexlens/issuers")
|
|
.WithTags("VexLens Issuers")
|
|
.WithOpenApi();
|
|
|
|
issuerGroup.MapGet("/", ListIssuersAsync)
|
|
.WithName("ListIssuers")
|
|
.WithDescription("List registered VEX issuers")
|
|
.Produces<IssuerListResponse>(StatusCodes.Status200OK);
|
|
|
|
issuerGroup.MapGet("/{issuerId}", GetIssuerAsync)
|
|
.WithName("GetIssuer")
|
|
.WithDescription("Get issuer details")
|
|
.Produces<IssuerDetailResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
issuerGroup.MapPost("/", RegisterIssuerAsync)
|
|
.WithName("RegisterIssuer")
|
|
.WithDescription("Register a new VEX issuer")
|
|
.Produces<IssuerDetailResponse>(StatusCodes.Status201Created)
|
|
.Produces(StatusCodes.Status400BadRequest);
|
|
|
|
issuerGroup.MapDelete("/{issuerId}", RevokeIssuerAsync)
|
|
.WithName("RevokeIssuer")
|
|
.WithDescription("Revoke an issuer")
|
|
.Produces(StatusCodes.Status204NoContent)
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
issuerGroup.MapPost("/{issuerId}/keys", AddIssuerKeyAsync)
|
|
.WithName("AddIssuerKey")
|
|
.WithDescription("Add a key to an issuer")
|
|
.Produces<IssuerDetailResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
issuerGroup.MapDelete("/{issuerId}/keys/{fingerprint}", RevokeIssuerKeyAsync)
|
|
.WithName("RevokeIssuerKey")
|
|
.WithDescription("Revoke an issuer key")
|
|
.Produces(StatusCodes.Status204NoContent)
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
return app;
|
|
}
|
|
|
|
private static string? GetTenantId(HttpContext context)
|
|
{
|
|
return context.Request.Headers.TryGetValue(TenantHeader, out var value)
|
|
? value.ToString()
|
|
: null;
|
|
}
|
|
|
|
// Consensus handlers
|
|
private static async Task<IResult> ComputeConsensusAsync(
|
|
[FromBody] ComputeConsensusRequest request,
|
|
[FromServices] IVexLensApiService service,
|
|
HttpContext context,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var tenantId = GetTenantId(context) ?? request.TenantId;
|
|
var requestWithTenant = request with { TenantId = tenantId };
|
|
var result = await service.ComputeConsensusAsync(requestWithTenant, cancellationToken);
|
|
return Results.Ok(result);
|
|
}
|
|
|
|
private static async Task<IResult> ComputeConsensusWithProofAsync(
|
|
[FromBody] ComputeConsensusWithProofRequest request,
|
|
[FromServices] IVexLensApiService service,
|
|
HttpContext context,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var tenantId = GetTenantId(context) ?? request.TenantId;
|
|
var requestWithTenant = request with { TenantId = tenantId };
|
|
var result = await service.ComputeConsensusWithProofAsync(requestWithTenant, cancellationToken);
|
|
return Results.Ok(result);
|
|
}
|
|
|
|
private static async Task<IResult> ComputeConsensusBatchAsync(
|
|
[FromBody] ComputeConsensusBatchRequest request,
|
|
[FromServices] IVexLensApiService service,
|
|
HttpContext context,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var tenantId = GetTenantId(context) ?? request.TenantId;
|
|
var requestWithTenant = request with { TenantId = tenantId };
|
|
var result = await service.ComputeConsensusBatchAsync(requestWithTenant, cancellationToken);
|
|
return Results.Ok(result);
|
|
}
|
|
|
|
// Projection handlers
|
|
private static async Task<IResult> QueryProjectionsAsync(
|
|
[AsParameters] ProjectionQueryParams query,
|
|
[FromServices] IVexLensApiService service,
|
|
HttpContext context,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var tenantId = GetTenantId(context);
|
|
var request = new QueryProjectionsRequest(
|
|
VulnerabilityId: query.VulnerabilityId,
|
|
ProductKey: query.ProductKey,
|
|
Status: null,
|
|
Outcome: query.Outcome,
|
|
MinimumConfidence: query.MinimumConfidence,
|
|
ComputedAfter: query.ComputedAfter,
|
|
ComputedBefore: query.ComputedBefore,
|
|
StatusChanged: query.StatusChanged,
|
|
Limit: query.Limit,
|
|
Offset: query.Offset,
|
|
SortBy: query.SortBy,
|
|
SortDescending: query.SortDescending);
|
|
|
|
var result = await service.QueryProjectionsAsync(request, tenantId, cancellationToken);
|
|
return Results.Ok(result);
|
|
}
|
|
|
|
private static async Task<IResult> GetProjectionAsync(
|
|
string projectionId,
|
|
[FromServices] IVexLensApiService service,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = await service.GetProjectionAsync(projectionId, cancellationToken);
|
|
return result != null ? Results.Ok(result) : Results.NotFound();
|
|
}
|
|
|
|
private static async Task<IResult> GetLatestProjectionAsync(
|
|
[FromQuery] string vulnerabilityId,
|
|
[FromQuery] string productKey,
|
|
[FromServices] IVexLensApiService service,
|
|
HttpContext context,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var tenantId = GetTenantId(context);
|
|
var result = await service.GetLatestProjectionAsync(vulnerabilityId, productKey, tenantId, cancellationToken);
|
|
return result != null ? Results.Ok(result) : Results.NotFound();
|
|
}
|
|
|
|
private static async Task<IResult> GetProjectionHistoryAsync(
|
|
[FromQuery] string vulnerabilityId,
|
|
[FromQuery] string productKey,
|
|
[FromQuery] int? limit,
|
|
[FromServices] IVexLensApiService service,
|
|
HttpContext context,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var tenantId = GetTenantId(context);
|
|
var result = await service.GetProjectionHistoryAsync(vulnerabilityId, productKey, tenantId, limit, cancellationToken);
|
|
return Results.Ok(result);
|
|
}
|
|
|
|
private static async Task<IResult> GetStatisticsAsync(
|
|
[FromServices] IVexLensApiService service,
|
|
HttpContext context,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var tenantId = GetTenantId(context);
|
|
var result = await service.GetStatisticsAsync(tenantId, cancellationToken);
|
|
return Results.Ok(result);
|
|
}
|
|
|
|
private static async Task<IResult> GetConflictsAsync(
|
|
[FromQuery] int? limit,
|
|
[FromQuery] int? offset,
|
|
[FromServices] IVexLensApiService service,
|
|
HttpContext context,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var tenantId = GetTenantId(context);
|
|
// Query for projections with conflicts (conflictCount > 0)
|
|
var request = new QueryProjectionsRequest(
|
|
VulnerabilityId: null,
|
|
ProductKey: null,
|
|
Status: null,
|
|
Outcome: null,
|
|
MinimumConfidence: null,
|
|
ComputedAfter: null,
|
|
ComputedBefore: null,
|
|
StatusChanged: null,
|
|
Limit: limit ?? 50,
|
|
Offset: offset ?? 0,
|
|
SortBy: "ComputedAt",
|
|
SortDescending: true);
|
|
|
|
var result = await service.QueryProjectionsAsync(request, tenantId, cancellationToken);
|
|
|
|
// Filter to only show projections with conflicts
|
|
var conflictsOnly = new QueryProjectionsResponse(
|
|
Projections: result.Projections.Where(p => p.ConflictCount > 0).ToList(),
|
|
TotalCount: result.Projections.Count(p => p.ConflictCount > 0),
|
|
Offset: result.Offset,
|
|
Limit: result.Limit);
|
|
|
|
return Results.Ok(conflictsOnly);
|
|
}
|
|
|
|
// Delta/Noise-Gating handlers
|
|
private static async Task<IResult> ComputeDeltaAsync(
|
|
[FromBody] ComputeDeltaRequest request,
|
|
[FromServices] INoiseGate noiseGate,
|
|
[FromServices] ISnapshotStore snapshotStore,
|
|
HttpContext context,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var tenantId = GetTenantId(context) ?? request.TenantId;
|
|
|
|
// Get snapshots
|
|
var fromSnapshot = await snapshotStore.GetAsync(request.FromSnapshotId, tenantId, cancellationToken);
|
|
var toSnapshot = await snapshotStore.GetAsync(request.ToSnapshotId, tenantId, cancellationToken);
|
|
|
|
if (fromSnapshot is null || toSnapshot is null)
|
|
{
|
|
return Results.BadRequest("One or both snapshot IDs not found");
|
|
}
|
|
|
|
// Compute delta
|
|
var options = NoiseGatingApiMapper.MapOptions(request.Options);
|
|
var delta = await noiseGate.DiffAsync(fromSnapshot, toSnapshot, options, cancellationToken);
|
|
|
|
return Results.Ok(NoiseGatingApiMapper.MapToResponse(delta));
|
|
}
|
|
|
|
private static async Task<IResult> GetGatingStatisticsAsync(
|
|
[FromQuery] string? tenantId,
|
|
[FromQuery] DateTimeOffset? fromDate,
|
|
[FromQuery] DateTimeOffset? toDate,
|
|
[FromServices] IGatingStatisticsStore statsStore,
|
|
HttpContext context,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var tenant = GetTenantId(context) ?? tenantId;
|
|
var stats = await statsStore.GetAggregatedAsync(tenant, fromDate, toDate, cancellationToken);
|
|
|
|
return Results.Ok(new AggregatedGatingStatisticsResponse(
|
|
TotalSnapshots: stats.TotalSnapshots,
|
|
TotalEdgesProcessed: stats.TotalEdgesProcessed,
|
|
TotalEdgesAfterDedup: stats.TotalEdgesAfterDedup,
|
|
AverageEdgeReductionPercent: stats.AverageEdgeReductionPercent,
|
|
TotalVerdicts: stats.TotalVerdicts,
|
|
TotalSurfaced: stats.TotalSurfaced,
|
|
TotalDamped: stats.TotalDamped,
|
|
AverageDampingPercent: stats.AverageDampingPercent,
|
|
ComputedAt: DateTimeOffset.UtcNow));
|
|
}
|
|
|
|
private static async Task<IResult> GateSnapshotAsync(
|
|
string snapshotId,
|
|
[FromBody] GateSnapshotRequest request,
|
|
[FromServices] INoiseGate noiseGate,
|
|
[FromServices] ISnapshotStore snapshotStore,
|
|
HttpContext context,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var tenantId = GetTenantId(context) ?? request.TenantId;
|
|
|
|
// Get the raw snapshot
|
|
var snapshot = await snapshotStore.GetRawAsync(snapshotId, tenantId, cancellationToken);
|
|
if (snapshot is null)
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
|
|
// Apply noise-gating
|
|
var gateRequest = new NoiseGateRequest
|
|
{
|
|
Graph = snapshot.Graph,
|
|
SnapshotId = snapshotId,
|
|
Verdicts = snapshot.Verdicts
|
|
};
|
|
|
|
var gatedSnapshot = await noiseGate.GateAsync(gateRequest, cancellationToken);
|
|
|
|
return Results.Ok(new GatedSnapshotResponse(
|
|
SnapshotId: gatedSnapshot.SnapshotId,
|
|
Digest: gatedSnapshot.Digest,
|
|
CreatedAt: gatedSnapshot.CreatedAt,
|
|
EdgeCount: gatedSnapshot.Edges.Count,
|
|
VerdictCount: gatedSnapshot.Verdicts.Count,
|
|
Statistics: NoiseGatingApiMapper.MapStatistics(gatedSnapshot.Statistics)));
|
|
}
|
|
|
|
// Issuer handlers
|
|
private static async Task<IResult> ListIssuersAsync(
|
|
[FromQuery] string? category,
|
|
[FromQuery] string? minimumTrustTier,
|
|
[FromQuery] string? status,
|
|
[FromQuery] string? search,
|
|
[FromQuery] int? limit,
|
|
[FromQuery] int? offset,
|
|
[FromServices] IVexLensApiService service,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = await service.ListIssuersAsync(
|
|
category, minimumTrustTier, status, search, limit, offset, cancellationToken);
|
|
return Results.Ok(result);
|
|
}
|
|
|
|
private static async Task<IResult> GetIssuerAsync(
|
|
string issuerId,
|
|
[FromServices] IVexLensApiService service,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = await service.GetIssuerAsync(issuerId, cancellationToken);
|
|
return result != null ? Results.Ok(result) : Results.NotFound();
|
|
}
|
|
|
|
private static async Task<IResult> RegisterIssuerAsync(
|
|
[FromBody] RegisterIssuerRequest request,
|
|
[FromServices] IVexLensApiService service,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = await service.RegisterIssuerAsync(request, cancellationToken);
|
|
return Results.Created($"/api/v1/vexlens/issuers/{result.IssuerId}", result);
|
|
}
|
|
|
|
private static async Task<IResult> RevokeIssuerAsync(
|
|
string issuerId,
|
|
[FromBody] RevokeRequest request,
|
|
[FromServices] IVexLensApiService service,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var success = await service.RevokeIssuerAsync(issuerId, request, cancellationToken);
|
|
return success ? Results.NoContent() : Results.NotFound();
|
|
}
|
|
|
|
private static async Task<IResult> AddIssuerKeyAsync(
|
|
string issuerId,
|
|
[FromBody] RegisterKeyRequest request,
|
|
[FromServices] IVexLensApiService service,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = await service.AddIssuerKeyAsync(issuerId, request, cancellationToken);
|
|
return Results.Ok(result);
|
|
}
|
|
|
|
private static async Task<IResult> RevokeIssuerKeyAsync(
|
|
string issuerId,
|
|
string fingerprint,
|
|
[FromBody] RevokeRequest request,
|
|
[FromServices] IVexLensApiService service,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var success = await service.RevokeIssuerKeyAsync(issuerId, fingerprint, request, cancellationToken);
|
|
return success ? Results.NoContent() : Results.NotFound();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Query parameters for projection queries.
|
|
/// </summary>
|
|
public sealed record ProjectionQueryParams(
|
|
[FromQuery] string? VulnerabilityId,
|
|
[FromQuery] string? ProductKey,
|
|
[FromQuery] string? Outcome,
|
|
[FromQuery] double? MinimumConfidence,
|
|
[FromQuery] DateTimeOffset? ComputedAfter,
|
|
[FromQuery] DateTimeOffset? ComputedBefore,
|
|
[FromQuery] bool? StatusChanged,
|
|
[FromQuery] int? Limit,
|
|
[FromQuery] int? Offset,
|
|
[FromQuery] string? SortBy,
|
|
[FromQuery] bool? SortDescending);
|