183 lines
6.9 KiB
C#
183 lines
6.9 KiB
C#
// -----------------------------------------------------------------------------
|
|
// RuntimeTracesEndpoints.cs
|
|
// Sprint: SPRINT_20260107_006_002_FE_diff_runtime_tabs
|
|
// Task: DR-014 — Runtime traces API endpoints
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using Microsoft.AspNetCore.Http.HttpResults;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using StellaOps.Auth.ServerIntegration.Tenancy;
|
|
using StellaOps.Findings.Ledger.WebService.Contracts;
|
|
|
|
namespace StellaOps.Findings.Ledger.WebService.Endpoints;
|
|
|
|
/// <summary>
|
|
/// API endpoints for runtime traces evidence.
|
|
/// </summary>
|
|
public static class RuntimeTracesEndpoints
|
|
{
|
|
/// <summary>
|
|
/// Maps runtime traces endpoints to the application.
|
|
/// </summary>
|
|
public static void MapRuntimeTracesEndpoints(this WebApplication app)
|
|
{
|
|
var group = app.MapGroup("/api/v1/findings")
|
|
.WithTags("Runtime Evidence")
|
|
.RequireAuthorization("scoring.read")
|
|
.RequireTenant();
|
|
|
|
// POST /api/v1/findings/{findingId}/runtime/traces
|
|
group.MapPost("/{findingId:guid}/runtime/traces", IngestRuntimeTrace)
|
|
.WithName("IngestRuntimeTrace")
|
|
.WithSummary("Ingest runtime trace observation for a finding")
|
|
.WithDescription("Accepts a runtime trace observation from an eBPF or APM agent, recording which function frames were observed executing within a vulnerable component at runtime. Requires artifact digest and component PURL for cross-referencing. Returns 202 Accepted; the trace is processed asynchronously.")
|
|
.Accepts<RuntimeTraceIngestRequest>("application/json")
|
|
.Produces<RuntimeTraceIngestResponse>(202)
|
|
.ProducesValidationProblem()
|
|
.RequireAuthorization("ledger.events.write");
|
|
|
|
// GET /api/v1/findings/{findingId}/runtime/traces
|
|
group.MapGet("/{findingId:guid}/runtime/traces", GetRuntimeTraces)
|
|
.WithName("GetRuntimeTraces")
|
|
.WithSummary("Get runtime function traces for a finding")
|
|
.WithDescription("Returns the aggregated runtime function traces recorded for a finding, sorted by hit count or recency. Each trace entry includes the function frame, hit count, artifact digest, and component PURL for cross-referencing with SBOM data.")
|
|
.Produces<RuntimeTracesResponse>(200)
|
|
.Produces(404)
|
|
.RequireAuthorization("scoring.read");
|
|
|
|
// GET /api/v1/findings/{findingId}/runtime/score
|
|
group.MapGet("/{findingId:guid}/runtime/score", GetRtsScore)
|
|
.WithName("GetRtsScore")
|
|
.WithSummary("Get Runtime Trustworthiness Score for a finding")
|
|
.WithDescription("Returns the Runtime Trustworthiness Score (RTS) for a finding, derived from observed runtime trace density and recency. A higher RTS indicates that the vulnerable code path has been recently and frequently exercised in production, increasing remediation priority.")
|
|
.Produces<RtsScoreResponse>(200)
|
|
.Produces(404)
|
|
.RequireAuthorization("scoring.read");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ingests a runtime trace observation.
|
|
/// </summary>
|
|
private static async Task<Results<Accepted<RuntimeTraceIngestResponse>, ValidationProblem>> IngestRuntimeTrace(
|
|
Guid findingId,
|
|
RuntimeTraceIngestRequest request,
|
|
IRuntimeTracesService service,
|
|
IStellaOpsTenantAccessor tenantAccessor,
|
|
CancellationToken ct)
|
|
{
|
|
var errors = new Dictionary<string, string[]>();
|
|
if (request.Frames is null || request.Frames.Count == 0)
|
|
{
|
|
errors["frames"] = ["At least one frame is required."];
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
|
|
{
|
|
errors["artifactDigest"] = ["Artifact digest is required."];
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.ComponentPurl))
|
|
{
|
|
errors["componentPurl"] = ["Component purl is required."];
|
|
}
|
|
|
|
if (errors.Count > 0)
|
|
{
|
|
return TypedResults.ValidationProblem(errors);
|
|
}
|
|
|
|
var response = await service.IngestTraceAsync(findingId, request, ct);
|
|
return TypedResults.Accepted($"/api/v1/findings/{findingId}/runtime/traces", response);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets runtime function traces for a finding.
|
|
/// </summary>
|
|
private static async Task<Results<Ok<RuntimeTracesResponse>, NotFound>> GetRuntimeTraces(
|
|
Guid findingId,
|
|
IRuntimeTracesService service,
|
|
IStellaOpsTenantAccessor tenantAccessor,
|
|
CancellationToken ct,
|
|
[FromQuery] int? limit = null,
|
|
[FromQuery] string? sortBy = null)
|
|
{
|
|
var options = new RuntimeTracesQueryOptions
|
|
{
|
|
Limit = limit ?? 50,
|
|
SortBy = sortBy ?? "hits"
|
|
};
|
|
|
|
var traces = await service.GetTracesAsync(findingId, options, ct);
|
|
|
|
return traces is not null
|
|
? TypedResults.Ok(traces)
|
|
: TypedResults.NotFound();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the RTS score for a finding.
|
|
/// </summary>
|
|
private static async Task<Results<Ok<RtsScoreResponse>, NotFound>> GetRtsScore(
|
|
Guid findingId,
|
|
IRuntimeTracesService service,
|
|
IStellaOpsTenantAccessor tenantAccessor,
|
|
CancellationToken ct)
|
|
{
|
|
var score = await service.GetRtsScoreAsync(findingId, ct);
|
|
|
|
return score is not null
|
|
? TypedResults.Ok(score)
|
|
: TypedResults.NotFound();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Query options for runtime traces.
|
|
/// </summary>
|
|
public sealed record RuntimeTracesQueryOptions
|
|
{
|
|
/// <summary>
|
|
/// Maximum number of traces to return.
|
|
/// </summary>
|
|
public int Limit { get; init; } = 50;
|
|
|
|
/// <summary>
|
|
/// Sort by field (hits, recent).
|
|
/// </summary>
|
|
public string SortBy { get; init; } = "hits";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Service for retrieving runtime traces.
|
|
/// </summary>
|
|
public interface IRuntimeTracesService
|
|
{
|
|
/// <summary>
|
|
/// Ingests a runtime trace observation for a finding.
|
|
/// </summary>
|
|
Task<RuntimeTraceIngestResponse> IngestTraceAsync(
|
|
Guid findingId,
|
|
RuntimeTraceIngestRequest request,
|
|
CancellationToken ct);
|
|
|
|
/// <summary>
|
|
/// Gets runtime traces for a finding.
|
|
/// </summary>
|
|
/// <param name="findingId">Finding identifier.</param>
|
|
/// <param name="options">Query options.</param>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
/// <returns>Runtime traces response or null if not found.</returns>
|
|
Task<RuntimeTracesResponse?> GetTracesAsync(
|
|
Guid findingId,
|
|
RuntimeTracesQueryOptions options,
|
|
CancellationToken ct);
|
|
|
|
/// <summary>
|
|
/// Gets RTS score for a finding.
|
|
/// </summary>
|
|
/// <param name="findingId">Finding identifier.</param>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
/// <returns>RTS score response or null if not found.</returns>
|
|
Task<RtsScoreResponse?> GetRtsScoreAsync(Guid findingId, CancellationToken ct);
|
|
}
|