378 lines
15 KiB
C#
378 lines
15 KiB
C#
// -----------------------------------------------------------------------------
|
|
// 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;
|
|
|
|
/// <summary>
|
|
/// Triage operations with gating support for quiet-by-design UX.
|
|
/// </summary>
|
|
[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<TriageController> _logger;
|
|
|
|
public TriageController(
|
|
IGatingReasonService gatingService,
|
|
IUnifiedEvidenceService evidenceService,
|
|
IReplayCommandService replayService,
|
|
IEvidenceBundleExporter bundleExporter,
|
|
ILogger<TriageController> 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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get gating status for a finding.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Returns why a finding is gated (hidden by default) in quiet triage mode,
|
|
/// including gating reasons, VEX trust score, and evidence links.
|
|
/// </remarks>
|
|
/// <param name="findingId">Finding identifier.</param>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
/// <response code="200">Gating status retrieved.</response>
|
|
/// <response code="404">Finding not found.</response>
|
|
[HttpGet("findings/{findingId}/gating")]
|
|
[ProducesResponseType(typeof(FindingGatingStatusDto), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
public async Task<IActionResult> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get gating status for multiple findings.
|
|
/// </summary>
|
|
/// <param name="request">Request with finding IDs.</param>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
/// <response code="200">Gating statuses retrieved.</response>
|
|
[HttpPost("findings/gating/batch")]
|
|
[ProducesResponseType(typeof(IReadOnlyList<FindingGatingStatusDto>), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
public async Task<IActionResult> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get gated buckets summary for a scan.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Returns aggregated counts of findings by gating bucket - how many are
|
|
/// hidden by VEX, reachability, KEV status, etc.
|
|
/// </remarks>
|
|
/// <param name="scanId">Scan identifier.</param>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
/// <response code="200">Summary retrieved.</response>
|
|
/// <response code="404">Scan not found.</response>
|
|
[HttpGet("scans/{scanId}/gated-buckets")]
|
|
[ProducesResponseType(typeof(GatedBucketsSummaryDto), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
public async Task<IActionResult> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get unified evidence package for a finding.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
/// <param name="findingId">Finding identifier.</param>
|
|
/// <param name="includeSbom">Include SBOM evidence.</param>
|
|
/// <param name="includeReachability">Include reachability evidence.</param>
|
|
/// <param name="includeVex">Include VEX claims.</param>
|
|
/// <param name="includeAttestations">Include attestations.</param>
|
|
/// <param name="includeDeltas">Include delta evidence.</param>
|
|
/// <param name="includePolicy">Include policy evidence.</param>
|
|
/// <param name="includeReplayCommand">Include replay command.</param>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
/// <response code="200">Evidence retrieved.</response>
|
|
/// <response code="304">Not modified (ETag match).</response>
|
|
/// <response code="404">Finding not found.</response>
|
|
[HttpGet("findings/{findingId}/evidence")]
|
|
[ProducesResponseType(typeof(UnifiedEvidenceResponseDto), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status304NotModified)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
public async Task<IActionResult> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Export evidence bundle as downloadable archive.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
/// <param name="findingId">Finding identifier.</param>
|
|
/// <param name="format">Archive format: zip (default) or tar.gz.</param>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
/// <response code="200">Archive download stream.</response>
|
|
/// <response code="400">Invalid format specified.</response>
|
|
/// <response code="404">Finding not found.</response>
|
|
[HttpGet("findings/{findingId}/evidence/export")]
|
|
[ProducesResponseType(typeof(FileStreamResult), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
public async Task<IActionResult> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generate replay command for a finding.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Generates copy-ready CLI commands to deterministically replay
|
|
/// the verdict for this finding.
|
|
/// </remarks>
|
|
/// <param name="findingId">Finding identifier.</param>
|
|
/// <param name="shells">Target shells (bash, powershell, cmd).</param>
|
|
/// <param name="includeOffline">Include offline replay variant.</param>
|
|
/// <param name="generateBundle">Generate evidence bundle.</param>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
/// <response code="200">Replay commands generated.</response>
|
|
/// <response code="404">Finding not found.</response>
|
|
[HttpGet("findings/{findingId}/replay-command")]
|
|
[ProducesResponseType(typeof(ReplayCommandResponseDto), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
public async Task<IActionResult> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generate replay command for an entire scan.
|
|
/// </summary>
|
|
/// <param name="scanId">Scan identifier.</param>
|
|
/// <param name="shells">Target shells.</param>
|
|
/// <param name="includeOffline">Include offline variant.</param>
|
|
/// <param name="generateBundle">Generate evidence bundle.</param>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
/// <response code="200">Replay commands generated.</response>
|
|
/// <response code="404">Scan not found.</response>
|
|
[HttpGet("scans/{scanId}/replay-command")]
|
|
[ProducesResponseType(typeof(ScanReplayCommandResponseDto), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
public async Task<IActionResult> 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Request for bulk gating status.
|
|
/// </summary>
|
|
public sealed record BulkGatingStatusRequest
|
|
{
|
|
/// <summary>Finding IDs to query.</summary>
|
|
public required IReadOnlyList<string> FindingIds { get; init; }
|
|
}
|