// ----------------------------------------------------------------------------- // ActionablesEndpoints.cs // Sprint: SPRINT_4200_0002_0006_delta_compare_api // Description: HTTP endpoints for actionable remediation recommendations. // ----------------------------------------------------------------------------- using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using StellaOps.Scanner.WebService.Contracts; using StellaOps.Scanner.WebService.Security; namespace StellaOps.Scanner.WebService.Endpoints; /// /// Endpoints for actionable remediation recommendations. /// Per SPRINT_4200_0002_0006 T3. /// internal static class ActionablesEndpoints { /// /// Maps actionables endpoints. /// public static void MapActionablesEndpoints(this RouteGroupBuilder apiGroup, string prefix = "/actionables") { ArgumentNullException.ThrowIfNull(apiGroup); var group = apiGroup.MapGroup(prefix) .WithTags("Actionables"); // GET /v1/actionables/delta/{deltaId} - Get actionables for a delta group.MapGet("/delta/{deltaId}", HandleGetDeltaActionablesAsync) .WithName("scanner.actionables.delta") .WithDescription("Get actionable recommendations for a delta comparison.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(ScannerPolicies.ScansRead); // GET /v1/actionables/delta/{deltaId}/by-priority/{priority} - Filter by priority group.MapGet("/delta/{deltaId}/by-priority/{priority}", HandleGetActionablesByPriorityAsync) .WithName("scanner.actionables.by-priority") .WithDescription("Get actionables filtered by priority level.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .RequireAuthorization(ScannerPolicies.ScansRead); // GET /v1/actionables/delta/{deltaId}/by-type/{type} - Filter by type group.MapGet("/delta/{deltaId}/by-type/{type}", HandleGetActionablesByTypeAsync) .WithName("scanner.actionables.by-type") .WithDescription("Get actionables filtered by action type.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .RequireAuthorization(ScannerPolicies.ScansRead); } private static async Task HandleGetDeltaActionablesAsync( string deltaId, IActionablesService actionablesService, HttpContext context, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(actionablesService); if (string.IsNullOrWhiteSpace(deltaId)) { return Results.BadRequest(new { type = "validation-error", title = "Invalid delta ID", detail = "Delta ID is required." }); } var actionables = await actionablesService.GenerateForDeltaAsync(deltaId, cancellationToken); if (actionables is null) { return Results.NotFound(new { type = "not-found", title = "Delta not found", detail = $"Delta with ID '{deltaId}' was not found." }); } return Results.Ok(actionables); } private static async Task HandleGetActionablesByPriorityAsync( string deltaId, string priority, IActionablesService actionablesService, HttpContext context, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(actionablesService); var validPriorities = new[] { "critical", "high", "medium", "low" }; if (!validPriorities.Contains(priority, StringComparer.OrdinalIgnoreCase)) { return Results.BadRequest(new { type = "validation-error", title = "Invalid priority", detail = $"Priority must be one of: {string.Join(", ", validPriorities)}" }); } var allActionables = await actionablesService.GenerateForDeltaAsync(deltaId, cancellationToken); if (allActionables is null) { return Results.NotFound(new { type = "not-found", title = "Delta not found", detail = $"Delta with ID '{deltaId}' was not found." }); } var filtered = allActionables.Actionables .Where(a => a.Priority.Equals(priority, StringComparison.OrdinalIgnoreCase)) .ToList(); return Results.Ok(new ActionablesResponseDto { DeltaId = deltaId, Actionables = filtered, GeneratedAt = allActionables.GeneratedAt }); } private static async Task HandleGetActionablesByTypeAsync( string deltaId, string type, IActionablesService actionablesService, HttpContext context, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(actionablesService); var validTypes = new[] { "upgrade", "patch", "vex", "config", "investigate" }; if (!validTypes.Contains(type, StringComparer.OrdinalIgnoreCase)) { return Results.BadRequest(new { type = "validation-error", title = "Invalid type", detail = $"Type must be one of: {string.Join(", ", validTypes)}" }); } var allActionables = await actionablesService.GenerateForDeltaAsync(deltaId, cancellationToken); if (allActionables is null) { return Results.NotFound(new { type = "not-found", title = "Delta not found", detail = $"Delta with ID '{deltaId}' was not found." }); } var filtered = allActionables.Actionables .Where(a => a.Type.Equals(type, StringComparison.OrdinalIgnoreCase)) .ToList(); return Results.Ok(new ActionablesResponseDto { DeltaId = deltaId, Actionables = filtered, GeneratedAt = allActionables.GeneratedAt }); } } /// /// Service interface for actionables generation. /// Per SPRINT_4200_0002_0006 T3. /// public interface IActionablesService { /// /// Generates actionable recommendations for a delta. /// Task GenerateForDeltaAsync(string deltaId, CancellationToken ct = default); } /// /// Default implementation of actionables service. /// public sealed class ActionablesService : IActionablesService { private readonly TimeProvider _timeProvider; private readonly IDeltaCompareService _deltaService; public ActionablesService(TimeProvider timeProvider, IDeltaCompareService deltaService) { _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _deltaService = deltaService ?? throw new ArgumentNullException(nameof(deltaService)); } public async Task GenerateForDeltaAsync(string deltaId, CancellationToken ct = default) { // In a full implementation, this would retrieve the delta and generate // actionables based on the findings. For now, return sample actionables. var delta = await _deltaService.GetComparisonAsync(deltaId, ct); // Even if delta is null, we can still generate sample actionables for demo var actionables = new List(); // Sample upgrade actionable actionables.Add(new ActionableDto { Id = $"action-upgrade-{deltaId[..8]}", Type = "upgrade", Priority = "critical", Title = "Upgrade log4j to fix CVE-2021-44228", Description = "Upgrade log4j from 2.14.1 to 2.17.1 to remediate the Log4Shell vulnerability. " + "This is a critical remote code execution vulnerability.", Component = "pkg:maven/org.apache.logging.log4j/log4j-core", CurrentVersion = "2.14.1", TargetVersion = "2.17.1", CveIds = ["CVE-2021-44228", "CVE-2021-45046"], EstimatedEffort = "low", Evidence = new ActionableEvidenceDto { PolicyRuleId = "rule-critical-cve" } }); // Sample VEX actionable actionables.Add(new ActionableDto { Id = $"action-vex-{deltaId[..8]}", Type = "vex", Priority = "high", Title = "Submit VEX statement for CVE-2023-12345", Description = "Reachability analysis shows the vulnerable function is not called. " + "Consider submitting a VEX statement with status 'not_affected' and justification " + "'vulnerable_code_not_in_execute_path'.", Component = "pkg:npm/example-lib", CveIds = ["CVE-2023-12345"], EstimatedEffort = "trivial", Evidence = new ActionableEvidenceDto { WitnessId = "witness-12345" } }); // Sample investigate actionable actionables.Add(new ActionableDto { Id = $"action-investigate-{deltaId[..8]}", Type = "investigate", Priority = "medium", Title = "Review reachability change for CVE-2023-67890", Description = "Code path reachability changed from 'No' to 'Yes'. Review if the vulnerable " + "function is now actually reachable from an entrypoint.", Component = "pkg:pypi/requests", CveIds = ["CVE-2023-67890"], EstimatedEffort = "medium", Evidence = new ActionableEvidenceDto { WitnessId = "witness-67890" } }); // Sample config actionable actionables.Add(new ActionableDto { Id = $"action-config-{deltaId[..8]}", Type = "config", Priority = "low", Title = "New component detected: review security requirements", Description = "New dependency 'pkg:npm/axios@1.6.0' was added. Verify it meets security " + "requirements and is from a trusted source.", Component = "pkg:npm/axios", CurrentVersion = "1.6.0", EstimatedEffort = "trivial" }); // Sort by priority var sortedActionables = actionables .OrderBy(a => GetPriorityOrder(a.Priority)) .ThenBy(a => a.Title, StringComparer.Ordinal) .ToList(); return new ActionablesResponseDto { DeltaId = deltaId, Actionables = sortedActionables, GeneratedAt = _timeProvider.GetUtcNow() }; } private static int GetPriorityOrder(string priority) { return priority.ToLowerInvariant() switch { "critical" => 0, "high" => 1, "medium" => 2, "low" => 3, _ => 4 }; } }