// ----------------------------------------------------------------------------- // TriageController.cs // Sprint: SPRINT_9200_0001_0001_SCANNER_gated_triage_contracts // Description: API endpoints for triage operations with gating support. // ----------------------------------------------------------------------------- using Microsoft.AspNetCore.Mvc; using StellaOps.Scanner.WebService.Contracts; using StellaOps.Scanner.WebService.Services; namespace StellaOps.Scanner.WebService.Controllers; /// /// Triage operations with gating support for quiet-by-design UX. /// [ApiController] [Route("api/v1/triage")] [Produces("application/json")] public sealed class TriageController : ControllerBase { private readonly IGatingReasonService _gatingService; private readonly IUnifiedEvidenceService _evidenceService; private readonly IReplayCommandService _replayService; private readonly IEvidenceBundleExporter _bundleExporter; private readonly ILogger _logger; public TriageController( IGatingReasonService gatingService, IUnifiedEvidenceService evidenceService, IReplayCommandService replayService, IEvidenceBundleExporter bundleExporter, ILogger logger) { _gatingService = gatingService ?? throw new ArgumentNullException(nameof(gatingService)); _evidenceService = evidenceService ?? throw new ArgumentNullException(nameof(evidenceService)); _replayService = replayService ?? throw new ArgumentNullException(nameof(replayService)); _bundleExporter = bundleExporter ?? throw new ArgumentNullException(nameof(bundleExporter)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// /// Get gating status for a finding. /// /// /// Returns why a finding is gated (hidden by default) in quiet triage mode, /// including gating reasons, VEX trust score, and evidence links. /// /// Finding identifier. /// Cancellation token. /// Gating status retrieved. /// Finding not found. [HttpGet("findings/{findingId}/gating")] [ProducesResponseType(typeof(FindingGatingStatusDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task GetGatingStatusAsync( [FromRoute] string findingId, CancellationToken ct = default) { _logger.LogDebug("Getting gating status for finding {FindingId}", findingId); var status = await _gatingService.GetGatingStatusAsync(findingId, ct) .ConfigureAwait(false); if (status is null) { return NotFound(new { error = "Finding not found", findingId }); } return Ok(status); } /// /// Get gating status for multiple findings. /// /// Request with finding IDs. /// Cancellation token. /// Gating statuses retrieved. [HttpPost("findings/gating/batch")] [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task GetBulkGatingStatusAsync( [FromBody] BulkGatingStatusRequest request, CancellationToken ct = default) { if (request.FindingIds.Count == 0) { return BadRequest(new { error = "At least one finding ID required" }); } if (request.FindingIds.Count > 500) { return BadRequest(new { error = "Maximum 500 findings per batch" }); } _logger.LogDebug("Getting bulk gating status for {Count} findings", request.FindingIds.Count); var statuses = await _gatingService.GetBulkGatingStatusAsync(request.FindingIds, ct) .ConfigureAwait(false); return Ok(statuses); } /// /// Get gated buckets summary for a scan. /// /// /// Returns aggregated counts of findings by gating bucket - how many are /// hidden by VEX, reachability, KEV status, etc. /// /// Scan identifier. /// Cancellation token. /// Summary retrieved. /// Scan not found. [HttpGet("scans/{scanId}/gated-buckets")] [ProducesResponseType(typeof(GatedBucketsSummaryDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task GetGatedBucketsSummaryAsync( [FromRoute] string scanId, CancellationToken ct = default) { _logger.LogDebug("Getting gated buckets summary for scan {ScanId}", scanId); var summary = await _gatingService.GetGatedBucketsSummaryAsync(scanId, ct) .ConfigureAwait(false); if (summary is null) { return NotFound(new { error = "Scan not found", scanId }); } return Ok(summary); } /// /// Get unified evidence package for a finding. /// /// /// Returns all evidence tabs for a finding in a single response: /// SBOM, reachability, VEX, attestations, deltas, and policy. /// Supports ETag/If-None-Match for efficient caching. /// /// Finding identifier. /// Include SBOM evidence. /// Include reachability evidence. /// Include VEX claims. /// Include attestations. /// Include delta evidence. /// Include policy evidence. /// Include replay command. /// Cancellation token. /// Evidence retrieved. /// Not modified (ETag match). /// Finding not found. [HttpGet("findings/{findingId}/evidence")] [ProducesResponseType(typeof(UnifiedEvidenceResponseDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status304NotModified)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task GetUnifiedEvidenceAsync( [FromRoute] string findingId, [FromQuery] bool includeSbom = true, [FromQuery] bool includeReachability = true, [FromQuery] bool includeVex = true, [FromQuery] bool includeAttestations = true, [FromQuery] bool includeDeltas = true, [FromQuery] bool includePolicy = true, [FromQuery] bool includeReplayCommand = true, CancellationToken ct = default) { _logger.LogDebug("Getting unified evidence for finding {FindingId}", findingId); var options = new UnifiedEvidenceOptions { IncludeSbom = includeSbom, IncludeReachability = includeReachability, IncludeVexClaims = includeVex, IncludeAttestations = includeAttestations, IncludeDeltas = includeDeltas, IncludePolicy = includePolicy, IncludeReplayCommand = includeReplayCommand }; var evidence = await _evidenceService.GetUnifiedEvidenceAsync(findingId, options, ct) .ConfigureAwait(false); if (evidence is null) { return NotFound(new { error = "Finding not found", findingId }); } // Support ETag-based caching using content-addressed cache key var etag = $"\"{evidence.CacheKey}\""; Response.Headers.ETag = etag; Response.Headers.CacheControl = "private, max-age=300"; // 5 minutes // Check If-None-Match header for conditional GET if (Request.Headers.TryGetValue("If-None-Match", out var ifNoneMatch)) { var clientEtag = ifNoneMatch.ToString().Trim(); if (string.Equals(clientEtag, etag, StringComparison.Ordinal)) { return StatusCode(StatusCodes.Status304NotModified); } } return Ok(evidence); } /// /// Export evidence bundle as downloadable archive. /// /// /// Exports all evidence for a finding as a ZIP or TAR.GZ archive. /// Archive includes manifest, SBOM, reachability, VEX, attestations, /// policy evaluation, delta comparison, and replay command. /// /// Finding identifier. /// Archive format: zip (default) or tar.gz. /// Cancellation token. /// Archive download stream. /// Invalid format specified. /// Finding not found. [HttpGet("findings/{findingId}/evidence/export")] [ProducesResponseType(typeof(FileStreamResult), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task ExportEvidenceBundleAsync( [FromRoute] string findingId, [FromQuery] string format = "zip", CancellationToken ct = default) { _logger.LogDebug("Exporting evidence bundle for finding {FindingId} as {Format}", findingId, format); // Parse format EvidenceExportFormat exportFormat; switch (format.ToLowerInvariant()) { case "zip": exportFormat = EvidenceExportFormat.Zip; break; case "tar.gz": case "targz": case "tgz": exportFormat = EvidenceExportFormat.TarGz; break; default: return BadRequest(new { error = "Invalid format. Supported: zip, tar.gz", format }); } // Get full evidence (all tabs) var options = new UnifiedEvidenceOptions { IncludeSbom = true, IncludeReachability = true, IncludeVexClaims = true, IncludeAttestations = true, IncludeDeltas = true, IncludePolicy = true, IncludeReplayCommand = true }; var evidence = await _evidenceService.GetUnifiedEvidenceAsync(findingId, options, ct) .ConfigureAwait(false); if (evidence is null) { return NotFound(new { error = "Finding not found", findingId }); } // Export to archive var exportResult = await _bundleExporter.ExportAsync(evidence, exportFormat, ct) .ConfigureAwait(false); // Set digest header for verification Response.Headers["X-Archive-Digest"] = $"sha256:{exportResult.ArchiveDigest}"; return File( exportResult.Stream, exportResult.ContentType, exportResult.FileName, enableRangeProcessing: false); } /// /// Generate replay command for a finding. /// /// /// Generates copy-ready CLI commands to deterministically replay /// the verdict for this finding. /// /// Finding identifier. /// Target shells (bash, powershell, cmd). /// Include offline replay variant. /// Generate evidence bundle. /// Cancellation token. /// Replay commands generated. /// Finding not found. [HttpGet("findings/{findingId}/replay-command")] [ProducesResponseType(typeof(ReplayCommandResponseDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task GetReplayCommandAsync( [FromRoute] string findingId, [FromQuery] string[]? shells = null, [FromQuery] bool includeOffline = false, [FromQuery] bool generateBundle = false, CancellationToken ct = default) { _logger.LogDebug("Generating replay command for finding {FindingId}", findingId); var request = new GenerateReplayCommandRequestDto { FindingId = findingId, Shells = shells, IncludeOffline = includeOffline, GenerateBundle = generateBundle }; var result = await _replayService.GenerateForFindingAsync(request, ct) .ConfigureAwait(false); if (result is null) { return NotFound(new { error = "Finding not found", findingId }); } return Ok(result); } /// /// Generate replay command for an entire scan. /// /// Scan identifier. /// Target shells. /// Include offline variant. /// Generate evidence bundle. /// Cancellation token. /// Replay commands generated. /// Scan not found. [HttpGet("scans/{scanId}/replay-command")] [ProducesResponseType(typeof(ScanReplayCommandResponseDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task GetScanReplayCommandAsync( [FromRoute] string scanId, [FromQuery] string[]? shells = null, [FromQuery] bool includeOffline = false, [FromQuery] bool generateBundle = false, CancellationToken ct = default) { _logger.LogDebug("Generating replay command for scan {ScanId}", scanId); var request = new GenerateScanReplayCommandRequestDto { ScanId = scanId, Shells = shells, IncludeOffline = includeOffline, GenerateBundle = generateBundle }; var result = await _replayService.GenerateForScanAsync(request, ct) .ConfigureAwait(false); if (result is null) { return NotFound(new { error = "Scan not found", scanId }); } return Ok(result); } } /// /// Request for bulk gating status. /// public sealed record BulkGatingStatusRequest { /// Finding IDs to query. public required IReadOnlyList FindingIds { get; init; } }