Files
git.stella-ops.org/src/Scanner/StellaOps.Scanner.WebService/Endpoints/Triage/TriageInboxEndpoints.cs
StellaOps Bot 83c37243e0 save progress
2026-01-03 11:02:24 +02:00

123 lines
4.6 KiB
C#

// -----------------------------------------------------------------------------
// TriageInboxEndpoints.cs
// Sprint: SPRINT_3900_0003_0001_exploit_path_inbox_proof_bundles
// Description: HTTP endpoints for triage inbox with grouped exploit paths.
// -----------------------------------------------------------------------------
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Scanner.Triage.Models;
using StellaOps.Scanner.Triage.Services;
using StellaOps.Scanner.WebService.Security;
namespace StellaOps.Scanner.WebService.Endpoints.Triage;
/// <summary>
/// Endpoints for triage inbox - grouped exploit paths.
/// </summary>
internal static class TriageInboxEndpoints
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
/// <summary>
/// Maps triage inbox endpoints.
/// </summary>
public static void MapTriageInboxEndpoints(this RouteGroupBuilder apiGroup)
{
ArgumentNullException.ThrowIfNull(apiGroup);
var triageGroup = apiGroup.MapGroup("/triage")
.WithTags("Triage");
// GET /v1/triage/inbox?artifactDigest={digest}&filter={filter}
triageGroup.MapGet("/inbox", HandleGetInboxAsync)
.WithName("scanner.triage.inbox")
.WithDescription("Retrieves triage inbox with grouped exploit paths for an artifact.")
.Produces<TriageInboxResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.TriageRead);
}
private static async Task<IResult> HandleGetInboxAsync(
[FromQuery] string artifactDigest,
[FromQuery] string? filter,
[FromServices] IExploitPathGroupingService groupingService,
[FromServices] IFindingQueryService findingService,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(groupingService);
ArgumentNullException.ThrowIfNull(findingService);
if (string.IsNullOrWhiteSpace(artifactDigest))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid artifact digest",
detail = "Artifact digest is required."
});
}
var findings = await findingService.GetFindingsForArtifactAsync(artifactDigest, cancellationToken);
var paths = await groupingService.GroupFindingsAsync(artifactDigest, findings, cancellationToken);
var filteredPaths = ApplyFilter(paths, filter);
var response = new TriageInboxResponse
{
ArtifactDigest = artifactDigest,
TotalPaths = paths.Count,
FilteredPaths = filteredPaths.Count,
Filter = filter,
Paths = filteredPaths,
GeneratedAt = DateTimeOffset.UtcNow
};
return Results.Ok(response);
}
private static IReadOnlyList<ExploitPath> ApplyFilter(
IReadOnlyList<ExploitPath> paths,
string? filter)
{
if (string.IsNullOrWhiteSpace(filter))
return paths;
return filter.ToLowerInvariant() switch
{
"actionable" => paths.Where(p => !p.IsQuiet && p.Reachability is ReachabilityStatus.StaticallyReachable or ReachabilityStatus.RuntimeConfirmed).ToList(),
"noisy" => paths.Where(p => p.IsQuiet).ToList(),
"reachable" => paths.Where(p => p.Reachability is ReachabilityStatus.StaticallyReachable or ReachabilityStatus.RuntimeConfirmed).ToList(),
"runtime" => paths.Where(p => p.Reachability == ReachabilityStatus.RuntimeConfirmed).ToList(),
"critical" => paths.Where(p => p.RiskScore.CriticalCount > 0).ToList(),
"high" => paths.Where(p => p.RiskScore.HighCount > 0).ToList(),
_ => paths
};
}
}
/// <summary>
/// Response for triage inbox endpoint.
/// </summary>
public sealed record TriageInboxResponse
{
public required string ArtifactDigest { get; init; }
public required int TotalPaths { get; init; }
public required int FilteredPaths { get; init; }
public string? Filter { get; init; }
public required IReadOnlyList<ExploitPath> Paths { get; init; }
public required DateTimeOffset GeneratedAt { get; init; }
}
public interface IFindingQueryService
{
Task<IReadOnlyList<Finding>> GetFindingsForArtifactAsync(string artifactDigest, CancellationToken ct);
}