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