// 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); }