// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Scanner.Reachability.Stack;
using StellaOps.Scanner.WebService.Constants;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Infrastructure;
using StellaOps.Scanner.WebService.Security;
namespace StellaOps.Scanner.WebService.Endpoints;
///
/// Endpoints for three-layer reachability stack analysis.
///
internal static class ReachabilityStackEndpoints
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
///
/// Maps reachability stack endpoints under /reachability.
///
public static void MapReachabilityStackEndpoints(this RouteGroupBuilder apiGroup)
{
ArgumentNullException.ThrowIfNull(apiGroup);
var reachabilityGroup = apiGroup.MapGroup("/reachability");
// GET /reachability/{findingId}/stack - Full 3-layer breakdown
reachabilityGroup.MapGet("/{findingId}/stack", HandleGetStackAsync)
.WithName("scanner.reachability.stack")
.WithTags("ReachabilityStack")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
// GET /reachability/{findingId}/stack/layer/{layerNumber} - Single layer detail
reachabilityGroup.MapGet("/{findingId}/stack/layer/{layerNumber:int}", HandleGetLayerAsync)
.WithName("scanner.reachability.stack.layer")
.WithTags("ReachabilityStack")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
}
private static async Task HandleGetStackAsync(
string findingId,
IReachabilityStackRepository? stackRepository,
HttpContext context,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(findingId))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid finding identifier",
StatusCodes.Status400BadRequest,
detail: "Finding identifier is required.");
}
// If no repository is registered, return a stub implementation for now
if (stackRepository is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotImplemented,
"Reachability stack not available",
StatusCodes.Status501NotImplemented,
detail: "Reachability stack analysis is not yet implemented for this deployment.");
}
var stack = await stackRepository.TryGetByFindingIdAsync(findingId, cancellationToken)
.ConfigureAwait(false);
if (stack is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Reachability stack not found",
StatusCodes.Status404NotFound,
detail: $"No reachability stack found for finding '{findingId}'.");
}
var dto = MapToDto(stack);
return Json(dto, StatusCodes.Status200OK);
}
private static async Task HandleGetLayerAsync(
string findingId,
int layerNumber,
IReachabilityStackRepository? stackRepository,
HttpContext context,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(findingId))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid finding identifier",
StatusCodes.Status400BadRequest,
detail: "Finding identifier is required.");
}
if (layerNumber < 1 || layerNumber > 3)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid layer number",
StatusCodes.Status400BadRequest,
detail: "Layer number must be 1, 2, or 3.");
}
if (stackRepository is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotImplemented,
"Reachability stack not available",
StatusCodes.Status501NotImplemented,
detail: "Reachability stack analysis is not yet implemented for this deployment.");
}
var stack = await stackRepository.TryGetByFindingIdAsync(findingId, cancellationToken)
.ConfigureAwait(false);
if (stack is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Reachability stack not found",
StatusCodes.Status404NotFound,
detail: $"No reachability stack found for finding '{findingId}'.");
}
object layerDto = layerNumber switch
{
1 => MapLayer1ToDto(stack.StaticCallGraph),
2 => MapLayer2ToDto(stack.BinaryResolution),
3 => MapLayer3ToDto(stack.RuntimeGating),
_ => throw new InvalidOperationException("Invalid layer number")
};
return Json(layerDto, StatusCodes.Status200OK);
}
private static ReachabilityStackDto MapToDto(ReachabilityStack stack)
{
return new ReachabilityStackDto(
Id: stack.Id,
FindingId: stack.FindingId,
Symbol: MapSymbolToDto(stack.Symbol),
Layer1: MapLayer1ToDto(stack.StaticCallGraph),
Layer2: MapLayer2ToDto(stack.BinaryResolution),
Layer3: MapLayer3ToDto(stack.RuntimeGating),
Verdict: stack.Verdict.ToString(),
Explanation: stack.Explanation,
AnalyzedAt: stack.AnalyzedAt);
}
private static VulnerableSymbolDto MapSymbolToDto(VulnerableSymbol symbol)
{
return new VulnerableSymbolDto(
Name: symbol.Name,
Library: symbol.Library,
Version: symbol.Version,
VulnerabilityId: symbol.VulnerabilityId,
Type: symbol.Type.ToString());
}
private static ReachabilityLayer1Dto MapLayer1ToDto(ReachabilityLayer1 layer)
{
return new ReachabilityLayer1Dto(
IsReachable: layer.IsReachable,
Confidence: layer.Confidence.ToString(),
PathCount: layer.Paths.Length,
EntrypointCount: layer.ReachingEntrypoints.Length,
AnalysisMethod: layer.AnalysisMethod,
Paths: layer.Paths.Select(MapCallPathToDto).ToList());
}
private static ReachabilityLayer2Dto MapLayer2ToDto(ReachabilityLayer2 layer)
{
return new ReachabilityLayer2Dto(
IsResolved: layer.IsResolved,
Confidence: layer.Confidence.ToString(),
Reason: layer.Reason,
Resolution: layer.Resolution is not null ? MapResolutionToDto(layer.Resolution) : null,
LoaderRule: layer.AppliedRule is not null ? MapLoaderRuleToDto(layer.AppliedRule) : null);
}
private static ReachabilityLayer3Dto MapLayer3ToDto(ReachabilityLayer3 layer)
{
return new ReachabilityLayer3Dto(
IsGated: layer.IsGated,
Outcome: layer.Outcome.ToString(),
Confidence: layer.Confidence.ToString(),
Conditions: layer.Conditions.Select(MapGatingConditionToDto).ToList());
}
private static CallPathDto MapCallPathToDto(CallPath path)
{
return new CallPathDto(
Entrypoint: path.Entrypoint is not null ? MapEntrypointToDto(path.Entrypoint) : null,
Sites: path.Sites.Select(MapCallSiteToDto).ToList(),
Confidence: path.Confidence,
HasConditionals: path.HasConditionals);
}
private static EntrypointDto MapEntrypointToDto(Entrypoint entrypoint)
{
return new EntrypointDto(
Name: entrypoint.Name,
Type: entrypoint.Type.ToString(),
File: entrypoint.Location,
Description: entrypoint.Description);
}
private static CallSiteDto MapCallSiteToDto(CallSite site)
{
return new CallSiteDto(
Method: site.MethodName,
Type: site.ClassName,
File: site.FileName,
Line: site.LineNumber,
CallType: site.Type.ToString());
}
private static SymbolResolutionDto MapResolutionToDto(SymbolResolution resolution)
{
return new SymbolResolutionDto(
SymbolName: resolution.SymbolName,
ResolvedLibrary: resolution.ResolvedLibrary,
ResolvedVersion: resolution.ResolvedVersion,
SymbolVersion: resolution.SymbolVersion,
Method: resolution.Method.ToString());
}
private static LoaderRuleDto MapLoaderRuleToDto(LoaderRule rule)
{
return new LoaderRuleDto(
Type: rule.Type.ToString(),
Value: rule.Value,
Source: rule.Source);
}
private static GatingConditionDto MapGatingConditionToDto(GatingCondition condition)
{
return new GatingConditionDto(
Type: condition.Type.ToString(),
Description: condition.Description,
ConfigKey: condition.ConfigKey,
EnvVar: condition.EnvVar,
IsBlocking: condition.IsBlocking,
Status: condition.Status.ToString());
}
private static IResult Json(T value, int statusCode)
{
var payload = JsonSerializer.Serialize(value, SerializerOptions);
return Results.Content(payload, "application/json", System.Text.Encoding.UTF8, statusCode);
}
}
///
/// Repository interface for reachability stack data.
///
public interface IReachabilityStackRepository
{
///
/// Gets a reachability stack by finding ID.
///
Task TryGetByFindingIdAsync(string findingId, CancellationToken ct);
///
/// Stores a reachability stack.
///
Task StoreAsync(ReachabilityStack stack, CancellationToken ct);
}