sprints work
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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; }
|
||||
}
|
||||
Reference in New Issue
Block a user