sprints work

This commit is contained in:
StellaOps Bot
2025-12-24 21:46:08 +02:00
parent 43e2af88f6
commit b9f71fc7e9
161 changed files with 29566 additions and 527 deletions

View File

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