sprints and audit work

This commit is contained in:
StellaOps Bot
2026-01-07 09:36:16 +02:00
parent 05833e0af2
commit ab364c6032
377 changed files with 64534 additions and 1627 deletions

View File

@@ -0,0 +1,141 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.WebService.Contracts;
/// <summary>
/// Response for GET /scans/{scanId}/layers endpoint.
/// </summary>
public sealed record LayerListResponseDto
{
[JsonPropertyName("scanId")]
public required string ScanId { get; init; }
[JsonPropertyName("imageDigest")]
public required string ImageDigest { get; init; }
[JsonPropertyName("layers")]
public required IReadOnlyList<LayerSummaryDto> Layers { get; init; }
}
/// <summary>
/// Summary of a single layer.
/// </summary>
public sealed record LayerSummaryDto
{
[JsonPropertyName("digest")]
public required string Digest { get; init; }
[JsonPropertyName("order")]
public required int Order { get; init; }
[JsonPropertyName("hasSbom")]
public required bool HasSbom { get; init; }
[JsonPropertyName("componentCount")]
public required int ComponentCount { get; init; }
}
/// <summary>
/// Response for GET /scans/{scanId}/composition-recipe endpoint.
/// </summary>
public sealed record CompositionRecipeResponseDto
{
[JsonPropertyName("scanId")]
public required string ScanId { get; init; }
[JsonPropertyName("imageDigest")]
public required string ImageDigest { get; init; }
[JsonPropertyName("createdAt")]
public required string CreatedAt { get; init; }
[JsonPropertyName("recipe")]
public required CompositionRecipeDto Recipe { get; init; }
}
/// <summary>
/// The composition recipe.
/// </summary>
public sealed record CompositionRecipeDto
{
[JsonPropertyName("version")]
public required string Version { get; init; }
[JsonPropertyName("generatorName")]
public required string GeneratorName { get; init; }
[JsonPropertyName("generatorVersion")]
public required string GeneratorVersion { get; init; }
[JsonPropertyName("layers")]
public required IReadOnlyList<CompositionRecipeLayerDto> Layers { get; init; }
[JsonPropertyName("merkleRoot")]
public required string MerkleRoot { get; init; }
[JsonPropertyName("aggregatedSbomDigests")]
public required AggregatedSbomDigestsDto AggregatedSbomDigests { get; init; }
}
/// <summary>
/// A layer in the composition recipe.
/// </summary>
public sealed record CompositionRecipeLayerDto
{
[JsonPropertyName("digest")]
public required string Digest { get; init; }
[JsonPropertyName("order")]
public required int Order { get; init; }
[JsonPropertyName("fragmentDigest")]
public required string FragmentDigest { get; init; }
[JsonPropertyName("sbomDigests")]
public required LayerSbomDigestsDto SbomDigests { get; init; }
[JsonPropertyName("componentCount")]
public required int ComponentCount { get; init; }
}
/// <summary>
/// Digests for a layer's SBOMs.
/// </summary>
public sealed record LayerSbomDigestsDto
{
[JsonPropertyName("cyclonedx")]
public required string CycloneDx { get; init; }
[JsonPropertyName("spdx")]
public required string Spdx { get; init; }
}
/// <summary>
/// Digests for aggregated SBOMs.
/// </summary>
public sealed record AggregatedSbomDigestsDto
{
[JsonPropertyName("cyclonedx")]
public required string CycloneDx { get; init; }
[JsonPropertyName("spdx")]
public string? Spdx { get; init; }
}
/// <summary>
/// Result of composition recipe verification.
/// </summary>
public sealed record CompositionRecipeVerificationResponseDto
{
[JsonPropertyName("valid")]
public required bool Valid { get; init; }
[JsonPropertyName("merkleRootMatch")]
public required bool MerkleRootMatch { get; init; }
[JsonPropertyName("layerDigestsMatch")]
public required bool LayerDigestsMatch { get; init; }
[JsonPropertyName("errors")]
public IReadOnlyList<string>? Errors { get; init; }
}

View File

@@ -243,6 +243,71 @@ internal sealed record ScanCompletedEventPayload : OrchestratorEventPayload
[JsonPropertyName("report")]
[JsonPropertyOrder(10)]
public ReportDocumentDto Report { get; init; } = new();
/// <summary>
/// VEX gate evaluation summary (Sprint: SPRINT_20260106_003_002, Task: T024).
/// </summary>
[JsonPropertyName("vexGate")]
[JsonPropertyOrder(11)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public VexGateSummaryPayload? VexGate { get; init; }
}
/// <summary>
/// VEX gate evaluation summary for scan completion events.
/// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service, Task: T024
/// </summary>
internal sealed record VexGateSummaryPayload
{
/// <summary>
/// Total findings evaluated by the gate.
/// </summary>
[JsonPropertyName("totalFindings")]
[JsonPropertyOrder(0)]
public int TotalFindings { get; init; }
/// <summary>
/// Findings that passed (cleared by VEX evidence).
/// </summary>
[JsonPropertyName("passed")]
[JsonPropertyOrder(1)]
public int Passed { get; init; }
/// <summary>
/// Findings with warnings (partial evidence).
/// </summary>
[JsonPropertyName("warned")]
[JsonPropertyOrder(2)]
public int Warned { get; init; }
/// <summary>
/// Findings that were blocked (require attention).
/// </summary>
[JsonPropertyName("blocked")]
[JsonPropertyOrder(3)]
public int Blocked { get; init; }
/// <summary>
/// Whether the gate was bypassed for this scan.
/// </summary>
[JsonPropertyName("bypassed")]
[JsonPropertyOrder(4)]
public bool Bypassed { get; init; }
/// <summary>
/// Policy version used for evaluation.
/// </summary>
[JsonPropertyName("policyVersion")]
[JsonPropertyOrder(5)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? PolicyVersion { get; init; }
/// <summary>
/// When the gate evaluation was performed.
/// </summary>
[JsonPropertyName("evaluatedAt")]
[JsonPropertyOrder(6)]
public DateTimeOffset EvaluatedAt { get; init; }
}
internal sealed record ReportDeltaPayload

View File

@@ -0,0 +1,322 @@
// -----------------------------------------------------------------------------
// RationaleContracts.cs
// Sprint: SPRINT_20260106_001_001_LB_verdict_rationale_renderer
// Task: VRR-020 - Integrate VerdictRationaleRenderer into Scanner.WebService
// Description: DTOs for verdict rationale endpoint responses.
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.WebService.Contracts;
/// <summary>
/// Response for verdict rationale request.
/// </summary>
public sealed record VerdictRationaleResponseDto
{
/// <summary>
/// Finding identifier.
/// </summary>
[JsonPropertyName("finding_id")]
public required string FindingId { get; init; }
/// <summary>
/// Unique rationale ID (content-addressed).
/// </summary>
[JsonPropertyName("rationale_id")]
public required string RationaleId { get; init; }
/// <summary>
/// Schema version.
/// </summary>
[JsonPropertyName("schema_version")]
public string SchemaVersion { get; init; } = "1.0";
/// <summary>
/// Line 1: Evidence summary.
/// </summary>
[JsonPropertyName("evidence")]
public required RationaleEvidenceDto Evidence { get; init; }
/// <summary>
/// Line 2: Policy clause that triggered the decision.
/// </summary>
[JsonPropertyName("policy_clause")]
public required RationalePolicyClauseDto PolicyClause { get; init; }
/// <summary>
/// Line 3: Attestations and proofs.
/// </summary>
[JsonPropertyName("attestations")]
public required RationaleAttestationsDto Attestations { get; init; }
/// <summary>
/// Line 4: Final decision with recommendation.
/// </summary>
[JsonPropertyName("decision")]
public required RationaleDecisionDto Decision { get; init; }
/// <summary>
/// When the rationale was generated.
/// </summary>
[JsonPropertyName("generated_at")]
public required DateTimeOffset GeneratedAt { get; init; }
/// <summary>
/// Input digests for reproducibility verification.
/// </summary>
[JsonPropertyName("input_digests")]
public required RationaleInputDigestsDto InputDigests { get; init; }
}
/// <summary>
/// Line 1: Evidence summary DTO.
/// </summary>
public sealed record RationaleEvidenceDto
{
/// <summary>
/// CVE identifier.
/// </summary>
[JsonPropertyName("cve")]
public required string Cve { get; init; }
/// <summary>
/// Component PURL.
/// </summary>
[JsonPropertyName("component_purl")]
public required string ComponentPurl { get; init; }
/// <summary>
/// Component version.
/// </summary>
[JsonPropertyName("component_version")]
public string? ComponentVersion { get; init; }
/// <summary>
/// Vulnerable function (if reachability analyzed).
/// </summary>
[JsonPropertyName("vulnerable_function")]
public string? VulnerableFunction { get; init; }
/// <summary>
/// Entry point from which vulnerable code is reachable.
/// </summary>
[JsonPropertyName("entry_point")]
public string? EntryPoint { get; init; }
/// <summary>
/// Human-readable formatted text.
/// </summary>
[JsonPropertyName("text")]
public required string Text { get; init; }
}
/// <summary>
/// Line 2: Policy clause DTO.
/// </summary>
public sealed record RationalePolicyClauseDto
{
/// <summary>
/// Policy clause ID.
/// </summary>
[JsonPropertyName("clause_id")]
public required string ClauseId { get; init; }
/// <summary>
/// Rule description.
/// </summary>
[JsonPropertyName("rule_description")]
public required string RuleDescription { get; init; }
/// <summary>
/// Conditions that matched.
/// </summary>
[JsonPropertyName("conditions")]
public required IReadOnlyList<string> Conditions { get; init; }
/// <summary>
/// Human-readable formatted text.
/// </summary>
[JsonPropertyName("text")]
public required string Text { get; init; }
}
/// <summary>
/// Line 3: Attestations DTO.
/// </summary>
public sealed record RationaleAttestationsDto
{
/// <summary>
/// Path witness reference.
/// </summary>
[JsonPropertyName("path_witness")]
public RationaleAttestationRefDto? PathWitness { get; init; }
/// <summary>
/// VEX statement references.
/// </summary>
[JsonPropertyName("vex_statements")]
public IReadOnlyList<RationaleAttestationRefDto>? VexStatements { get; init; }
/// <summary>
/// Provenance attestation reference.
/// </summary>
[JsonPropertyName("provenance")]
public RationaleAttestationRefDto? Provenance { get; init; }
/// <summary>
/// Human-readable formatted text.
/// </summary>
[JsonPropertyName("text")]
public required string Text { get; init; }
}
/// <summary>
/// Attestation reference DTO.
/// </summary>
public sealed record RationaleAttestationRefDto
{
/// <summary>
/// Attestation ID.
/// </summary>
[JsonPropertyName("id")]
public required string Id { get; init; }
/// <summary>
/// Attestation type.
/// </summary>
[JsonPropertyName("type")]
public required string Type { get; init; }
/// <summary>
/// Content digest.
/// </summary>
[JsonPropertyName("digest")]
public string? Digest { get; init; }
/// <summary>
/// Summary description.
/// </summary>
[JsonPropertyName("summary")]
public string? Summary { get; init; }
}
/// <summary>
/// Line 4: Decision DTO.
/// </summary>
public sealed record RationaleDecisionDto
{
/// <summary>
/// Final verdict (Affected, Not Affected, etc.).
/// </summary>
[JsonPropertyName("verdict")]
public required string Verdict { get; init; }
/// <summary>
/// Risk score (0-1).
/// </summary>
[JsonPropertyName("score")]
public double? Score { get; init; }
/// <summary>
/// Recommended action.
/// </summary>
[JsonPropertyName("recommendation")]
public required string Recommendation { get; init; }
/// <summary>
/// Mitigation guidance.
/// </summary>
[JsonPropertyName("mitigation")]
public RationaleMitigationDto? Mitigation { get; init; }
/// <summary>
/// Human-readable formatted text.
/// </summary>
[JsonPropertyName("text")]
public required string Text { get; init; }
}
/// <summary>
/// Mitigation guidance DTO.
/// </summary>
public sealed record RationaleMitigationDto
{
/// <summary>
/// Recommended action.
/// </summary>
[JsonPropertyName("action")]
public required string Action { get; init; }
/// <summary>
/// Additional details.
/// </summary>
[JsonPropertyName("details")]
public string? Details { get; init; }
}
/// <summary>
/// Input digests for reproducibility.
/// </summary>
public sealed record RationaleInputDigestsDto
{
/// <summary>
/// Verdict attestation digest.
/// </summary>
[JsonPropertyName("verdict_digest")]
public required string VerdictDigest { get; init; }
/// <summary>
/// Policy snapshot digest.
/// </summary>
[JsonPropertyName("policy_digest")]
public string? PolicyDigest { get; init; }
/// <summary>
/// Evidence bundle digest.
/// </summary>
[JsonPropertyName("evidence_digest")]
public string? EvidenceDigest { get; init; }
}
/// <summary>
/// Request for rationale in specific format.
/// </summary>
public sealed record RationaleFormatRequestDto
{
/// <summary>
/// Desired format: json, markdown, plaintext.
/// </summary>
[JsonPropertyName("format")]
public string Format { get; init; } = "json";
}
/// <summary>
/// Plain text rationale response.
/// </summary>
public sealed record RationalePlainTextResponseDto
{
/// <summary>
/// Finding identifier.
/// </summary>
[JsonPropertyName("finding_id")]
public required string FindingId { get; init; }
/// <summary>
/// Rationale ID.
/// </summary>
[JsonPropertyName("rationale_id")]
public required string RationaleId { get; init; }
/// <summary>
/// Format of the content.
/// </summary>
[JsonPropertyName("format")]
public required string Format { get; init; }
/// <summary>
/// Rendered content.
/// </summary>
[JsonPropertyName("content")]
public required string Content { get; init; }
}

View File

@@ -0,0 +1,264 @@
// -----------------------------------------------------------------------------
// VexGateContracts.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Task: T021
// Description: DTOs for VEX gate results API endpoints.
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.WebService.Contracts;
/// <summary>
/// Response for GET /scans/{scanId}/gate-results.
/// </summary>
public sealed record VexGateResultsResponse
{
/// <summary>
/// Scan identifier.
/// </summary>
[JsonPropertyName("scanId")]
public required string ScanId { get; init; }
/// <summary>
/// Summary of gate evaluation results.
/// </summary>
[JsonPropertyName("gateSummary")]
public required VexGateSummaryDto GateSummary { get; init; }
/// <summary>
/// Individual gated findings.
/// </summary>
[JsonPropertyName("gatedFindings")]
public required IReadOnlyList<GatedFindingDto> GatedFindings { get; init; }
/// <summary>
/// Policy version used for evaluation.
/// </summary>
[JsonPropertyName("policyVersion")]
public string? PolicyVersion { get; init; }
/// <summary>
/// Whether gate was bypassed for this scan.
/// </summary>
[JsonPropertyName("bypassed")]
public bool Bypassed { get; init; }
}
/// <summary>
/// Summary of VEX gate evaluation.
/// </summary>
public sealed record VexGateSummaryDto
{
/// <summary>
/// Total number of findings evaluated.
/// </summary>
[JsonPropertyName("totalFindings")]
public int TotalFindings { get; init; }
/// <summary>
/// Number of findings that passed (cleared by VEX evidence).
/// </summary>
[JsonPropertyName("passed")]
public int Passed { get; init; }
/// <summary>
/// Number of findings with warnings (partial evidence).
/// </summary>
[JsonPropertyName("warned")]
public int Warned { get; init; }
/// <summary>
/// Number of findings blocked (requires attention).
/// </summary>
[JsonPropertyName("blocked")]
public int Blocked { get; init; }
/// <summary>
/// When the evaluation was performed (UTC ISO-8601).
/// </summary>
[JsonPropertyName("evaluatedAt")]
public DateTimeOffset EvaluatedAt { get; init; }
/// <summary>
/// Percentage of findings that passed.
/// </summary>
[JsonPropertyName("passRate")]
public double PassRate => TotalFindings > 0 ? (double)Passed / TotalFindings : 0;
/// <summary>
/// Percentage of findings that were blocked.
/// </summary>
[JsonPropertyName("blockRate")]
public double BlockRate => TotalFindings > 0 ? (double)Blocked / TotalFindings : 0;
}
/// <summary>
/// A finding with its gate evaluation result.
/// </summary>
public sealed record GatedFindingDto
{
/// <summary>
/// Finding identifier.
/// </summary>
[JsonPropertyName("findingId")]
public required string FindingId { get; init; }
/// <summary>
/// CVE or vulnerability identifier.
/// </summary>
[JsonPropertyName("cve")]
public required string Cve { get; init; }
/// <summary>
/// Package URL of the affected component.
/// </summary>
[JsonPropertyName("purl")]
public string? Purl { get; init; }
/// <summary>
/// Gate decision: Pass, Warn, or Block.
/// </summary>
[JsonPropertyName("decision")]
public required string Decision { get; init; }
/// <summary>
/// Human-readable explanation of the decision.
/// </summary>
[JsonPropertyName("rationale")]
public required string Rationale { get; init; }
/// <summary>
/// ID of the policy rule that matched.
/// </summary>
[JsonPropertyName("policyRuleMatched")]
public required string PolicyRuleMatched { get; init; }
/// <summary>
/// Supporting evidence for the decision.
/// </summary>
[JsonPropertyName("evidence")]
public required GateEvidenceDto Evidence { get; init; }
/// <summary>
/// References to VEX statements that contributed to this decision.
/// </summary>
[JsonPropertyName("contributingStatements")]
public IReadOnlyList<VexStatementRefDto>? ContributingStatements { get; init; }
}
/// <summary>
/// Evidence supporting a gate decision.
/// </summary>
public sealed record GateEvidenceDto
{
/// <summary>
/// VEX status from vendor or authoritative source.
/// </summary>
[JsonPropertyName("vendorStatus")]
public string? VendorStatus { get; init; }
/// <summary>
/// Justification type from VEX statement.
/// </summary>
[JsonPropertyName("justification")]
public string? Justification { get; init; }
/// <summary>
/// Whether the vulnerable code is reachable.
/// </summary>
[JsonPropertyName("isReachable")]
public bool IsReachable { get; init; }
/// <summary>
/// Whether compensating controls mitigate the vulnerability.
/// </summary>
[JsonPropertyName("hasCompensatingControl")]
public bool HasCompensatingControl { get; init; }
/// <summary>
/// Confidence score in the gate decision (0.0 to 1.0).
/// </summary>
[JsonPropertyName("confidenceScore")]
public double ConfidenceScore { get; init; }
/// <summary>
/// Severity level from the advisory.
/// </summary>
[JsonPropertyName("severityLevel")]
public string? SeverityLevel { get; init; }
/// <summary>
/// Whether the vulnerability is exploitable.
/// </summary>
[JsonPropertyName("isExploitable")]
public bool IsExploitable { get; init; }
/// <summary>
/// Backport hints detected.
/// </summary>
[JsonPropertyName("backportHints")]
public IReadOnlyList<string>? BackportHints { get; init; }
}
/// <summary>
/// Reference to a VEX statement.
/// </summary>
public sealed record VexStatementRefDto
{
/// <summary>
/// Statement identifier.
/// </summary>
[JsonPropertyName("statementId")]
public required string StatementId { get; init; }
/// <summary>
/// Issuer identifier.
/// </summary>
[JsonPropertyName("issuerId")]
public required string IssuerId { get; init; }
/// <summary>
/// VEX status declared in the statement.
/// </summary>
[JsonPropertyName("status")]
public required string Status { get; init; }
/// <summary>
/// When the statement was issued (UTC ISO-8601).
/// </summary>
[JsonPropertyName("timestamp")]
public DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Trust weight of this statement (0.0 to 1.0).
/// </summary>
[JsonPropertyName("trustWeight")]
public double TrustWeight { get; init; }
}
/// <summary>
/// Query parameters for filtering gate results.
/// </summary>
public sealed record VexGateResultsQuery
{
/// <summary>
/// Filter by gate decision (Pass, Warn, Block).
/// </summary>
public string? Decision { get; init; }
/// <summary>
/// Filter by minimum confidence score.
/// </summary>
public double? MinConfidence { get; init; }
/// <summary>
/// Maximum number of results to return.
/// </summary>
public int? Limit { get; init; }
/// <summary>
/// Offset for pagination.
/// </summary>
public int? Offset { get; init; }
}

View File

@@ -22,6 +22,7 @@ public sealed class TriageController : ControllerBase
private readonly IUnifiedEvidenceService _evidenceService;
private readonly IReplayCommandService _replayService;
private readonly IEvidenceBundleExporter _bundleExporter;
private readonly IFindingRationaleService _rationaleService;
private readonly ILogger<TriageController> _logger;
public TriageController(
@@ -29,12 +30,14 @@ public sealed class TriageController : ControllerBase
IUnifiedEvidenceService evidenceService,
IReplayCommandService replayService,
IEvidenceBundleExporter bundleExporter,
IFindingRationaleService rationaleService,
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));
_rationaleService = rationaleService ?? throw new ArgumentNullException(nameof(rationaleService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -365,6 +368,70 @@ public sealed class TriageController : ControllerBase
return Ok(result);
}
/// <summary>
/// Get structured verdict rationale for a finding.
/// </summary>
/// <remarks>
/// Returns a 4-line structured rationale:
/// 1. Evidence: CVE, component, reachability
/// 2. Policy clause: Rule that triggered the decision
/// 3. Attestations: Path witness, VEX statements, provenance
/// 4. Decision: Verdict, score, recommendation
/// </remarks>
/// <param name="findingId">Finding identifier.</param>
/// <param name="format">Output format: json (default), plaintext, markdown.</param>
/// <param name="ct">Cancellation token.</param>
/// <response code="200">Rationale retrieved.</response>
/// <response code="404">Finding not found.</response>
[HttpGet("findings/{findingId}/rationale")]
[ProducesResponseType(typeof(VerdictRationaleResponseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetFindingRationaleAsync(
[FromRoute] string findingId,
[FromQuery] string format = "json",
CancellationToken ct = default)
{
_logger.LogDebug("Getting rationale for finding {FindingId} in format {Format}", findingId, format);
switch (format.ToLowerInvariant())
{
case "plaintext":
case "text":
var plainText = await _rationaleService.GetRationalePlainTextAsync(findingId, ct)
.ConfigureAwait(false);
if (plainText is null)
{
return NotFound(new { error = "Finding not found", findingId });
}
return Ok(plainText);
case "markdown":
case "md":
var markdown = await _rationaleService.GetRationaleMarkdownAsync(findingId, ct)
.ConfigureAwait(false);
if (markdown is null)
{
return NotFound(new { error = "Finding not found", findingId });
}
return Ok(markdown);
case "json":
default:
var rationale = await _rationaleService.GetRationaleAsync(findingId, ct)
.ConfigureAwait(false);
if (rationale is null)
{
return NotFound(new { error = "Finding not found", findingId });
}
// Set ETag for caching
Response.Headers.ETag = $"\"{rationale.RationaleId}\"";
Response.Headers.CacheControl = "private, max-age=300";
return Ok(rationale);
}
}
}
/// <summary>

View File

@@ -0,0 +1,143 @@
// -----------------------------------------------------------------------------
// VexGateController.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Task: T021
// Description: API controller for VEX gate results and policy configuration.
// -----------------------------------------------------------------------------
using Microsoft.AspNetCore.Mvc;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Services;
namespace StellaOps.Scanner.WebService.Controllers;
/// <summary>
/// Controller for VEX gate results and policy operations.
/// </summary>
[ApiController]
[Route("api/v1/scans")]
[Produces("application/json")]
public sealed class VexGateController : ControllerBase
{
private readonly IVexGateQueryService _gateQueryService;
private readonly ILogger<VexGateController> _logger;
public VexGateController(
IVexGateQueryService gateQueryService,
ILogger<VexGateController> logger)
{
_gateQueryService = gateQueryService ?? throw new ArgumentNullException(nameof(gateQueryService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Get VEX gate evaluation results for a scan.
/// </summary>
/// <param name="scanId">The scan identifier.</param>
/// <param name="decision">Filter by gate decision (Pass, Warn, Block).</param>
/// <param name="minConfidence">Filter by minimum confidence score.</param>
/// <param name="limit">Maximum number of results.</param>
/// <param name="offset">Offset for pagination.</param>
/// <response code="200">Gate results retrieved successfully.</response>
/// <response code="404">Scan not found.</response>
[HttpGet("{scanId}/gate-results")]
[ProducesResponseType(typeof(VexGateResultsResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetGateResultsAsync(
[FromRoute] string scanId,
[FromQuery] string? decision = null,
[FromQuery] double? minConfidence = null,
[FromQuery] int? limit = null,
[FromQuery] int? offset = null,
CancellationToken ct = default)
{
_logger.LogDebug(
"Getting VEX gate results for scan {ScanId} (decision={Decision}, minConfidence={MinConfidence})",
scanId, decision, minConfidence);
var query = new VexGateResultsQuery
{
Decision = decision,
MinConfidence = minConfidence,
Limit = limit,
Offset = offset
};
var results = await _gateQueryService.GetGateResultsAsync(scanId, query, ct).ConfigureAwait(false);
if (results is null)
{
return NotFound(new { error = "Scan not found or gate results not available", scanId });
}
return Ok(results);
}
/// <summary>
/// Get the current VEX gate policy configuration.
/// </summary>
/// <param name="tenantId">Optional tenant identifier.</param>
/// <response code="200">Policy retrieved successfully.</response>
[HttpGet("gate-policy")]
[ProducesResponseType(typeof(VexGatePolicyDto), StatusCodes.Status200OK)]
public async Task<IActionResult> GetPolicyAsync(
[FromQuery] string? tenantId = null,
CancellationToken ct = default)
{
_logger.LogDebug("Getting VEX gate policy (tenantId={TenantId})", tenantId);
var policy = await _gateQueryService.GetPolicyAsync(tenantId, ct).ConfigureAwait(false);
return Ok(policy);
}
/// <summary>
/// Get gate results summary (counts only) for a scan.
/// </summary>
/// <param name="scanId">The scan identifier.</param>
/// <response code="200">Summary retrieved successfully.</response>
/// <response code="404">Scan not found.</response>
[HttpGet("{scanId}/gate-summary")]
[ProducesResponseType(typeof(VexGateSummaryDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetGateSummaryAsync(
[FromRoute] string scanId,
CancellationToken ct = default)
{
_logger.LogDebug("Getting VEX gate summary for scan {ScanId}", scanId);
var results = await _gateQueryService.GetGateResultsAsync(scanId, null, ct).ConfigureAwait(false);
if (results is null)
{
return NotFound(new { error = "Scan not found or gate results not available", scanId });
}
return Ok(results.GateSummary);
}
/// <summary>
/// Get blocked findings only for a scan.
/// </summary>
/// <param name="scanId">The scan identifier.</param>
/// <response code="200">Blocked findings retrieved successfully.</response>
/// <response code="404">Scan not found.</response>
[HttpGet("{scanId}/gate-blocked")]
[ProducesResponseType(typeof(IReadOnlyList<GatedFindingDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetBlockedFindingsAsync(
[FromRoute] string scanId,
CancellationToken ct = default)
{
_logger.LogDebug("Getting blocked findings for scan {ScanId}", scanId);
var query = new VexGateResultsQuery { Decision = "Block" };
var results = await _gateQueryService.GetGateResultsAsync(scanId, query, ct).ConfigureAwait(false);
if (results is null)
{
return NotFound(new { error = "Scan not found or gate results not available", scanId });
}
return Ok(results.GatedFindings);
}
}

View File

@@ -0,0 +1,336 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Scanner.WebService.Constants;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Domain;
using StellaOps.Scanner.WebService.Infrastructure;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Services;
namespace StellaOps.Scanner.WebService.Endpoints;
/// <summary>
/// Endpoints for per-layer SBOM access and composition recipes.
/// Sprint: SPRINT_20260106_003_001_SCANNER_perlayer_sbom_api
/// </summary>
internal static class LayerSbomEndpoints
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
public static void MapLayerSbomEndpoints(this RouteGroupBuilder scansGroup)
{
ArgumentNullException.ThrowIfNull(scansGroup);
// GET /scans/{scanId}/layers - List layers with SBOM info
scansGroup.MapGet("/{scanId}/layers", HandleListLayersAsync)
.WithName("scanner.scans.layers.list")
.WithTags("Scans", "Layers")
.Produces<LayerListResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
// GET /scans/{scanId}/layers/{layerDigest}/sbom - Get per-layer SBOM
scansGroup.MapGet("/{scanId}/layers/{layerDigest}/sbom", HandleGetLayerSbomAsync)
.WithName("scanner.scans.layers.sbom")
.WithTags("Scans", "Layers", "SBOM")
.Produces(StatusCodes.Status200OK, contentType: "application/json")
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
// GET /scans/{scanId}/composition-recipe - Get composition recipe
scansGroup.MapGet("/{scanId}/composition-recipe", HandleGetCompositionRecipeAsync)
.WithName("scanner.scans.composition-recipe")
.WithTags("Scans", "SBOM")
.Produces<CompositionRecipeResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
// POST /scans/{scanId}/composition-recipe/verify - Verify composition recipe
scansGroup.MapPost("/{scanId}/composition-recipe/verify", HandleVerifyCompositionRecipeAsync)
.WithName("scanner.scans.composition-recipe.verify")
.WithTags("Scans", "SBOM")
.Produces<CompositionRecipeVerificationResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
}
private static async Task<IResult> HandleListLayersAsync(
string scanId,
IScanCoordinator coordinator,
ILayerSbomService layerSbomService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(coordinator);
ArgumentNullException.ThrowIfNull(layerSbomService);
if (!ScanId.TryParse(scanId, out var parsed))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid scan identifier",
StatusCodes.Status400BadRequest,
detail: "Scan identifier is required.");
}
var snapshot = await coordinator.GetAsync(parsed, cancellationToken).ConfigureAwait(false);
if (snapshot is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Scan not found",
StatusCodes.Status404NotFound,
detail: "Requested scan could not be located.");
}
var layers = await layerSbomService.GetLayerSummariesAsync(parsed, cancellationToken).ConfigureAwait(false);
var response = new LayerListResponseDto
{
ScanId = scanId,
ImageDigest = snapshot.Target.Digest ?? string.Empty,
Layers = layers.Select(l => new LayerSummaryDto
{
Digest = l.LayerDigest,
Order = l.Order,
HasSbom = l.HasSbom,
ComponentCount = l.ComponentCount,
}).ToList(),
};
return Json(response, StatusCodes.Status200OK);
}
private static async Task<IResult> HandleGetLayerSbomAsync(
string scanId,
string layerDigest,
string? format,
IScanCoordinator coordinator,
ILayerSbomService layerSbomService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(coordinator);
ArgumentNullException.ThrowIfNull(layerSbomService);
if (!ScanId.TryParse(scanId, out var parsed))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid scan identifier",
StatusCodes.Status400BadRequest,
detail: "Scan identifier is required.");
}
if (string.IsNullOrWhiteSpace(layerDigest))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid layer digest",
StatusCodes.Status400BadRequest,
detail: "Layer digest is required.");
}
var snapshot = await coordinator.GetAsync(parsed, cancellationToken).ConfigureAwait(false);
if (snapshot is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Scan not found",
StatusCodes.Status404NotFound,
detail: "Requested scan could not be located.");
}
// Normalize layer digest (URL decode if needed)
var normalizedDigest = Uri.UnescapeDataString(layerDigest);
// Determine format: cdx (default) or spdx
var sbomFormat = string.Equals(format, "spdx", StringComparison.OrdinalIgnoreCase)
? "spdx"
: "cdx";
var sbomBytes = await layerSbomService.GetLayerSbomAsync(
parsed,
normalizedDigest,
sbomFormat,
cancellationToken).ConfigureAwait(false);
if (sbomBytes is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Layer SBOM not found",
StatusCodes.Status404NotFound,
detail: $"SBOM for layer {normalizedDigest} could not be found.");
}
var contentType = sbomFormat == "spdx"
? "application/spdx+json; version=3.0.1"
: "application/vnd.cyclonedx+json; version=1.7";
var contentDigest = ComputeSha256(sbomBytes);
context.Response.Headers.ETag = $"\"{contentDigest}\"";
context.Response.Headers["X-StellaOps-Layer-Digest"] = normalizedDigest;
context.Response.Headers["X-StellaOps-Format"] = sbomFormat == "spdx" ? "spdx-3.0.1" : "cyclonedx-1.7";
context.Response.Headers.CacheControl = "public, max-age=31536000, immutable";
return Results.Bytes(sbomBytes, contentType);
}
private static async Task<IResult> HandleGetCompositionRecipeAsync(
string scanId,
IScanCoordinator coordinator,
ILayerSbomService layerSbomService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(coordinator);
ArgumentNullException.ThrowIfNull(layerSbomService);
if (!ScanId.TryParse(scanId, out var parsed))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid scan identifier",
StatusCodes.Status400BadRequest,
detail: "Scan identifier is required.");
}
var snapshot = await coordinator.GetAsync(parsed, cancellationToken).ConfigureAwait(false);
if (snapshot is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Scan not found",
StatusCodes.Status404NotFound,
detail: "Requested scan could not be located.");
}
var recipe = await layerSbomService.GetCompositionRecipeAsync(parsed, cancellationToken).ConfigureAwait(false);
if (recipe is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Composition recipe not found",
StatusCodes.Status404NotFound,
detail: "Composition recipe for this scan is not available.");
}
var response = new CompositionRecipeResponseDto
{
ScanId = scanId,
ImageDigest = snapshot.Target.Digest ?? string.Empty,
CreatedAt = recipe.CreatedAt,
Recipe = new CompositionRecipeDto
{
Version = recipe.Recipe.Version,
GeneratorName = recipe.Recipe.GeneratorName,
GeneratorVersion = recipe.Recipe.GeneratorVersion,
Layers = recipe.Recipe.Layers.Select(l => new CompositionRecipeLayerDto
{
Digest = l.Digest,
Order = l.Order,
FragmentDigest = l.FragmentDigest,
SbomDigests = new LayerSbomDigestsDto
{
CycloneDx = l.SbomDigests.CycloneDx,
Spdx = l.SbomDigests.Spdx,
},
ComponentCount = l.ComponentCount,
}).ToList(),
MerkleRoot = recipe.Recipe.MerkleRoot,
AggregatedSbomDigests = new AggregatedSbomDigestsDto
{
CycloneDx = recipe.Recipe.AggregatedSbomDigests.CycloneDx,
Spdx = recipe.Recipe.AggregatedSbomDigests.Spdx,
},
},
};
return Json(response, StatusCodes.Status200OK);
}
private static async Task<IResult> HandleVerifyCompositionRecipeAsync(
string scanId,
IScanCoordinator coordinator,
ILayerSbomService layerSbomService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(coordinator);
ArgumentNullException.ThrowIfNull(layerSbomService);
if (!ScanId.TryParse(scanId, out var parsed))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid scan identifier",
StatusCodes.Status400BadRequest,
detail: "Scan identifier is required.");
}
var snapshot = await coordinator.GetAsync(parsed, cancellationToken).ConfigureAwait(false);
if (snapshot is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Scan not found",
StatusCodes.Status404NotFound,
detail: "Requested scan could not be located.");
}
var verificationResult = await layerSbomService.VerifyCompositionRecipeAsync(parsed, cancellationToken).ConfigureAwait(false);
if (verificationResult is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Composition recipe not found",
StatusCodes.Status404NotFound,
detail: "Composition recipe for this scan is not available for verification.");
}
var response = new CompositionRecipeVerificationResponseDto
{
Valid = verificationResult.Valid,
MerkleRootMatch = verificationResult.MerkleRootMatch,
LayerDigestsMatch = verificationResult.LayerDigestsMatch,
Errors = verificationResult.Errors.IsDefaultOrEmpty ? null : verificationResult.Errors.ToList(),
};
return Json(response, StatusCodes.Status200OK);
}
private static IResult Json<T>(T value, int statusCode)
{
var payload = JsonSerializer.Serialize(value, SerializerOptions);
return Results.Content(payload, "application/json", Encoding.UTF8, statusCode);
}
private static string ComputeSha256(byte[] bytes)
{
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -35,6 +35,7 @@ using StellaOps.Scanner.Surface.Secrets;
using StellaOps.Scanner.Surface.Validation;
using StellaOps.Scanner.Triage;
using StellaOps.Scanner.Triage.Entities;
using StellaOps.Policy.Explainability;
using StellaOps.Scanner.WebService.Diagnostics;
using StellaOps.Scanner.WebService.Determinism;
using StellaOps.Scanner.WebService.Endpoints;
@@ -174,6 +175,10 @@ builder.Services.AddDbContext<TriageDbContext>(options =>
builder.Services.AddScoped<ITriageQueryService, TriageQueryService>();
builder.Services.AddScoped<ITriageStatusService, TriageStatusService>();
// Verdict rationale rendering (Sprint: SPRINT_20260106_001_001_LB_verdict_rationale_renderer)
builder.Services.AddVerdictExplainability();
builder.Services.AddScoped<IFindingRationaleService, FindingRationaleService>();
// Register Storage.Repositories implementations for ManifestEndpoints
builder.Services.AddSingleton<StellaOps.Scanner.Storage.Repositories.IScanManifestRepository, TestManifestRepository>();
builder.Services.AddSingleton<StellaOps.Scanner.Storage.Repositories.IProofBundleRepository, TestProofBundleRepository>();

View File

@@ -0,0 +1,449 @@
// -----------------------------------------------------------------------------
// FindingRationaleService.cs
// Sprint: SPRINT_20260106_001_001_LB_verdict_rationale_renderer
// Task: VRR-020 - Integrate VerdictRationaleRenderer into Scanner.WebService
// Description: Service implementation for generating verdict rationales.
// -----------------------------------------------------------------------------
using StellaOps.Policy.Explainability;
using StellaOps.Scanner.WebService.Contracts;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// Service for generating structured verdict rationales for findings.
/// </summary>
internal sealed class FindingRationaleService : IFindingRationaleService
{
private readonly ITriageQueryService _triageQueryService;
private readonly IVerdictRationaleRenderer _rationaleRenderer;
private readonly TimeProvider _timeProvider;
private readonly ILogger<FindingRationaleService> _logger;
public FindingRationaleService(
ITriageQueryService triageQueryService,
IVerdictRationaleRenderer rationaleRenderer,
TimeProvider timeProvider,
ILogger<FindingRationaleService> logger)
{
_triageQueryService = triageQueryService ?? throw new ArgumentNullException(nameof(triageQueryService));
_rationaleRenderer = rationaleRenderer ?? throw new ArgumentNullException(nameof(rationaleRenderer));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<VerdictRationaleResponseDto?> GetRationaleAsync(string findingId, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
var finding = await _triageQueryService.GetFindingAsync(findingId, ct).ConfigureAwait(false);
if (finding is null)
{
_logger.LogDebug("Finding {FindingId} not found", findingId);
return null;
}
var input = BuildRationaleInput(finding);
var rationale = _rationaleRenderer.Render(input);
_logger.LogDebug("Generated rationale {RationaleId} for finding {FindingId}",
rationale.RationaleId, findingId);
return MapToDto(findingId, rationale);
}
public async Task<RationalePlainTextResponseDto?> GetRationalePlainTextAsync(string findingId, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
var finding = await _triageQueryService.GetFindingAsync(findingId, ct).ConfigureAwait(false);
if (finding is null)
{
return null;
}
var input = BuildRationaleInput(finding);
var rationale = _rationaleRenderer.Render(input);
var plainText = _rationaleRenderer.RenderPlainText(rationale);
return new RationalePlainTextResponseDto
{
FindingId = findingId,
RationaleId = rationale.RationaleId,
Format = "plaintext",
Content = plainText
};
}
public async Task<RationalePlainTextResponseDto?> GetRationaleMarkdownAsync(string findingId, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
var finding = await _triageQueryService.GetFindingAsync(findingId, ct).ConfigureAwait(false);
if (finding is null)
{
return null;
}
var input = BuildRationaleInput(finding);
var rationale = _rationaleRenderer.Render(input);
var markdown = _rationaleRenderer.RenderMarkdown(rationale);
return new RationalePlainTextResponseDto
{
FindingId = findingId,
RationaleId = rationale.RationaleId,
Format = "markdown",
Content = markdown
};
}
private VerdictRationaleInput BuildRationaleInput(Scanner.Triage.Entities.TriageFinding finding)
{
// Extract version from PURL
var version = ExtractVersionFromPurl(finding.Purl);
// Build policy clause info from decisions
var policyDecision = finding.PolicyDecisions.FirstOrDefault();
var policyClauseId = policyDecision?.PolicyId ?? "default";
var policyRuleDescription = policyDecision?.Reason ?? "Default policy evaluation";
var policyConditions = new List<string>();
if (!string.IsNullOrEmpty(policyDecision?.Action))
{
policyConditions.Add($"action={policyDecision.Action}");
}
// Build reachability detail if available
ReachabilityDetail? reachability = null;
var reachabilityResult = finding.ReachabilityResults.FirstOrDefault();
if (reachabilityResult is not null && reachabilityResult.Reachable == Scanner.Triage.Entities.TriageReachability.Yes)
{
reachability = new ReachabilityDetail
{
VulnerableFunction = null, // Not tracked at entity level
EntryPoint = null,
PathSummary = $"Reachable (confidence: {reachabilityResult.Confidence}%)"
};
}
// Build attestation references
var pathWitness = BuildPathWitnessRef(finding);
var vexStatements = BuildVexStatementRefs(finding);
var provenance = BuildProvenanceRef(finding);
// Get risk score (normalize from entities)
var riskResult = finding.RiskResults.FirstOrDefault();
double? score = null;
if (riskResult is not null)
{
// Risk results track scores at entity level
score = 0.5; // Default moderate score when we have a risk result
}
// Determine verdict
var verdict = DetermineVerdict(finding);
var recommendation = DetermineRecommendation(finding);
var mitigation = BuildMitigationGuidance(finding);
return new VerdictRationaleInput
{
VerdictRef = new VerdictReference
{
AttestationId = finding.Id.ToString(),
ArtifactDigest = finding.ArtifactDigest ?? "unknown",
PolicyId = policyDecision?.PolicyId ?? "default",
Cve = finding.CveId,
ComponentPurl = finding.Purl
},
Cve = finding.CveId ?? "UNKNOWN",
Component = new ComponentIdentity
{
Purl = finding.Purl,
Name = ExtractNameFromPurl(finding.Purl),
Version = version,
Ecosystem = ExtractEcosystemFromPurl(finding.Purl)
},
Reachability = reachability,
PolicyClauseId = policyClauseId,
PolicyRuleDescription = policyRuleDescription,
PolicyConditions = policyConditions,
PathWitness = pathWitness,
VexStatements = vexStatements,
Provenance = provenance,
Verdict = verdict,
Score = score,
Recommendation = recommendation,
Mitigation = mitigation,
GeneratedAt = _timeProvider.GetUtcNow(),
VerdictDigest = ComputeVerdictDigest(finding),
PolicyDigest = null, // PolicyDecision doesn't have digest
EvidenceDigest = ComputeEvidenceDigest(finding)
};
}
private static VerdictRationaleResponseDto MapToDto(string findingId, VerdictRationale rationale)
{
return new VerdictRationaleResponseDto
{
FindingId = findingId,
RationaleId = rationale.RationaleId,
SchemaVersion = rationale.SchemaVersion,
Evidence = new RationaleEvidenceDto
{
Cve = rationale.Evidence.Cve,
ComponentPurl = rationale.Evidence.Component.Purl,
ComponentVersion = rationale.Evidence.Component.Version,
VulnerableFunction = rationale.Evidence.Reachability?.VulnerableFunction,
EntryPoint = rationale.Evidence.Reachability?.EntryPoint,
Text = rationale.Evidence.FormattedText
},
PolicyClause = new RationalePolicyClauseDto
{
ClauseId = rationale.PolicyClause.ClauseId,
RuleDescription = rationale.PolicyClause.RuleDescription,
Conditions = rationale.PolicyClause.Conditions,
Text = rationale.PolicyClause.FormattedText
},
Attestations = new RationaleAttestationsDto
{
PathWitness = rationale.Attestations.PathWitness is not null
? new RationaleAttestationRefDto
{
Id = rationale.Attestations.PathWitness.Id,
Type = rationale.Attestations.PathWitness.Type,
Digest = rationale.Attestations.PathWitness.Digest,
Summary = rationale.Attestations.PathWitness.Summary
}
: null,
VexStatements = rationale.Attestations.VexStatements?.Select(v => new RationaleAttestationRefDto
{
Id = v.Id,
Type = v.Type,
Digest = v.Digest,
Summary = v.Summary
}).ToList(),
Provenance = rationale.Attestations.Provenance is not null
? new RationaleAttestationRefDto
{
Id = rationale.Attestations.Provenance.Id,
Type = rationale.Attestations.Provenance.Type,
Digest = rationale.Attestations.Provenance.Digest,
Summary = rationale.Attestations.Provenance.Summary
}
: null,
Text = rationale.Attestations.FormattedText
},
Decision = new RationaleDecisionDto
{
Verdict = rationale.Decision.Verdict,
Score = rationale.Decision.Score,
Recommendation = rationale.Decision.Recommendation,
Mitigation = rationale.Decision.Mitigation is not null
? new RationaleMitigationDto
{
Action = rationale.Decision.Mitigation.Action,
Details = rationale.Decision.Mitigation.Details
}
: null,
Text = rationale.Decision.FormattedText
},
GeneratedAt = rationale.GeneratedAt,
InputDigests = new RationaleInputDigestsDto
{
VerdictDigest = rationale.InputDigests.VerdictDigest,
PolicyDigest = rationale.InputDigests.PolicyDigest,
EvidenceDigest = rationale.InputDigests.EvidenceDigest
}
};
}
private static string ExtractVersionFromPurl(string purl)
{
var atIndex = purl.LastIndexOf('@');
return atIndex > 0 ? purl[(atIndex + 1)..] : "unknown";
}
private static string? ExtractNameFromPurl(string purl)
{
// pkg:type/namespace/name@version or pkg:type/name@version
var atIndex = purl.LastIndexOf('@');
var withoutVersion = atIndex > 0 ? purl[..atIndex] : purl;
var lastSlash = withoutVersion.LastIndexOf('/');
return lastSlash > 0 ? withoutVersion[(lastSlash + 1)..] : null;
}
private static string? ExtractEcosystemFromPurl(string purl)
{
// pkg:type/...
if (!purl.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
{
return null;
}
var colonIndex = purl.IndexOf(':', 4);
var slashIndex = purl.IndexOf('/', 4);
var endIndex = colonIndex > 4 && (slashIndex < 0 || colonIndex < slashIndex)
? colonIndex
: slashIndex;
return endIndex > 4 ? purl[4..endIndex] : null;
}
private static AttestationReference? BuildPathWitnessRef(Scanner.Triage.Entities.TriageFinding finding)
{
var witness = finding.Attestations.FirstOrDefault(a =>
a.Type == "path-witness" || a.Type == "reachability");
if (witness is null)
{
return null;
}
return new AttestationReference
{
Id = witness.Id.ToString(),
Type = "path-witness",
Digest = witness.EnvelopeHash,
Summary = $"Path witness from {witness.Issuer ?? "unknown"}"
};
}
private static IReadOnlyList<AttestationReference>? BuildVexStatementRefs(Scanner.Triage.Entities.TriageFinding finding)
{
var vexRecords = finding.EffectiveVexRecords;
if (vexRecords.Count == 0)
{
return null;
}
return vexRecords.Select(v => new AttestationReference
{
Id = v.Id.ToString(),
Type = "vex",
Digest = v.DsseEnvelopeHash,
Summary = $"{v.Status}: from {v.SourceDomain}"
}).ToList();
}
private static AttestationReference? BuildProvenanceRef(Scanner.Triage.Entities.TriageFinding finding)
{
var provenance = finding.Attestations.FirstOrDefault(a =>
a.Type == "provenance" || a.Type == "slsa-provenance");
if (provenance is null)
{
return null;
}
return new AttestationReference
{
Id = provenance.Id.ToString(),
Type = "provenance",
Digest = provenance.EnvelopeHash,
Summary = $"Provenance from {provenance.Issuer ?? "unknown"}"
};
}
private static string DetermineVerdict(Scanner.Triage.Entities.TriageFinding finding)
{
// Check VEX status first
var vex = finding.EffectiveVexRecords.FirstOrDefault();
if (vex is not null)
{
return vex.Status switch
{
Scanner.Triage.Entities.TriageVexStatus.NotAffected => "Not Affected",
Scanner.Triage.Entities.TriageVexStatus.Affected => "Affected",
Scanner.Triage.Entities.TriageVexStatus.UnderInvestigation => "Under Investigation",
Scanner.Triage.Entities.TriageVexStatus.Unknown => "Unknown",
_ => "Unknown"
};
}
// Check if backport fixed
if (finding.IsBackportFixed)
{
return "Fixed (Backport)";
}
// Check if muted
if (finding.IsMuted)
{
return "Muted";
}
// Default based on status
return finding.Status switch
{
"resolved" => "Resolved",
"open" => "Affected",
_ => "Under Investigation"
};
}
private static string DetermineRecommendation(Scanner.Triage.Entities.TriageFinding finding)
{
// If there's a VEX not_affected, no action needed
var vex = finding.EffectiveVexRecords.FirstOrDefault(v =>
v.Status == Scanner.Triage.Entities.TriageVexStatus.NotAffected);
if (vex is not null)
{
return "No action required";
}
// If fixed version available, recommend upgrade
if (!string.IsNullOrEmpty(finding.FixedInVersion))
{
return $"Upgrade to version {finding.FixedInVersion}";
}
// If backport fixed
if (finding.IsBackportFixed)
{
return "Already patched via backport";
}
// Default recommendation
return "Review and apply appropriate mitigation";
}
private static MitigationGuidance? BuildMitigationGuidance(Scanner.Triage.Entities.TriageFinding finding)
{
if (!string.IsNullOrEmpty(finding.FixedInVersion))
{
return new MitigationGuidance
{
Action = "upgrade",
Details = $"Upgrade to {finding.FixedInVersion} or later"
};
}
if (finding.IsBackportFixed)
{
return new MitigationGuidance
{
Action = "verify-backport",
Details = "Verify backport patch is applied"
};
}
return null;
}
private static string ComputeVerdictDigest(Scanner.Triage.Entities.TriageFinding finding)
{
// Simple digest based on finding ID and last update
var input = $"{finding.Id}:{finding.UpdatedAt:O}";
var hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()[..16]}";
}
private static string ComputeEvidenceDigest(Scanner.Triage.Entities.TriageFinding finding)
{
// Simple digest based on evidence artifacts
var evidenceIds = string.Join("|", finding.EvidenceArtifacts.Select(e => e.Id.ToString()).OrderBy(x => x, StringComparer.Ordinal));
var input = $"{finding.Id}:{evidenceIds}";
var hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()[..16]}";
}
}

View File

@@ -0,0 +1,40 @@
// -----------------------------------------------------------------------------
// IFindingRationaleService.cs
// Sprint: SPRINT_20260106_001_001_LB_verdict_rationale_renderer
// Task: VRR-020 - Integrate VerdictRationaleRenderer into Scanner.WebService
// Description: Service interface for generating verdict rationales for findings.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.WebService.Contracts;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// Service for generating structured verdict rationales for findings.
/// </summary>
public interface IFindingRationaleService
{
/// <summary>
/// Get the structured rationale for a finding.
/// </summary>
/// <param name="findingId">Finding identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Rationale response or null if finding not found.</returns>
Task<VerdictRationaleResponseDto?> GetRationaleAsync(string findingId, CancellationToken ct = default);
/// <summary>
/// Get the rationale as plain text (4-line format).
/// </summary>
/// <param name="findingId">Finding identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Plain text response or null if finding not found.</returns>
Task<RationalePlainTextResponseDto?> GetRationalePlainTextAsync(string findingId, CancellationToken ct = default);
/// <summary>
/// Get the rationale as Markdown.
/// </summary>
/// <param name="findingId">Finding identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Markdown response or null if finding not found.</returns>
Task<RationalePlainTextResponseDto?> GetRationaleMarkdownAsync(string findingId, CancellationToken ct = default);
}

View File

@@ -0,0 +1,95 @@
using System.Collections.Immutable;
using StellaOps.Scanner.Emit.Composition;
using StellaOps.Scanner.WebService.Domain;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// Service for managing per-layer SBOMs and composition recipes.
/// Sprint: SPRINT_20260106_003_001_SCANNER_perlayer_sbom_api
/// </summary>
public interface ILayerSbomService
{
/// <summary>
/// Gets summary information for all layers in a scan.
/// </summary>
/// <param name="scanId">The scan identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of layer summaries.</returns>
Task<ImmutableArray<LayerSummary>> GetLayerSummariesAsync(
ScanId scanId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the SBOM for a specific layer.
/// </summary>
/// <param name="scanId">The scan identifier.</param>
/// <param name="layerDigest">The layer digest (e.g., "sha256:abc123...").</param>
/// <param name="format">SBOM format: "cdx" or "spdx".</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>SBOM bytes, or null if not found.</returns>
Task<byte[]?> GetLayerSbomAsync(
ScanId scanId,
string layerDigest,
string format,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the composition recipe for a scan.
/// </summary>
/// <param name="scanId">The scan identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Composition recipe response, or null if not found.</returns>
Task<CompositionRecipeResponse?> GetCompositionRecipeAsync(
ScanId scanId,
CancellationToken cancellationToken = default);
/// <summary>
/// Verifies the composition recipe for a scan against stored layer SBOMs.
/// </summary>
/// <param name="scanId">The scan identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Verification result, or null if recipe not found.</returns>
Task<CompositionRecipeVerificationResult?> VerifyCompositionRecipeAsync(
ScanId scanId,
CancellationToken cancellationToken = default);
/// <summary>
/// Stores per-layer SBOMs for a scan.
/// </summary>
/// <param name="scanId">The scan identifier.</param>
/// <param name="imageDigest">The image digest.</param>
/// <param name="result">The layer SBOM composition result.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task StoreLayerSbomsAsync(
ScanId scanId,
string imageDigest,
LayerSbomCompositionResult result,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Summary information for a layer.
/// </summary>
public sealed record LayerSummary
{
/// <summary>
/// The layer digest.
/// </summary>
public required string LayerDigest { get; init; }
/// <summary>
/// The layer order (0-indexed).
/// </summary>
public required int Order { get; init; }
/// <summary>
/// Whether this layer has a stored SBOM.
/// </summary>
public required bool HasSbom { get; init; }
/// <summary>
/// Number of components in this layer.
/// </summary>
public required int ComponentCount { get; init; }
}

View File

@@ -0,0 +1,126 @@
// -----------------------------------------------------------------------------
// IVexGateQueryService.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Task: T021
// Description: Interface for querying VEX gate results from completed scans.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.WebService.Contracts;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// Service for querying VEX gate evaluation results.
/// </summary>
public interface IVexGateQueryService
{
/// <summary>
/// Gets VEX gate results for a completed scan.
/// </summary>
/// <param name="scanId">The scan identifier.</param>
/// <param name="query">Optional query parameters for filtering.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Gate results or null if scan not found.</returns>
Task<VexGateResultsResponse?> GetGateResultsAsync(
string scanId,
VexGateResultsQuery? query = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current gate policy configuration.
/// </summary>
/// <param name="tenantId">Optional tenant identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Policy configuration.</returns>
Task<VexGatePolicyDto> GetPolicyAsync(
string? tenantId = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// DTO for VEX gate policy configuration.
/// </summary>
public sealed record VexGatePolicyDto
{
/// <summary>
/// Policy version identifier.
/// </summary>
public required string Version { get; init; }
/// <summary>
/// Whether gate evaluation is enabled.
/// </summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Default decision when no rule matches.
/// </summary>
public required string DefaultDecision { get; init; }
/// <summary>
/// Policy rules in priority order.
/// </summary>
public required IReadOnlyList<VexGatePolicyRuleDto> Rules { get; init; }
}
/// <summary>
/// DTO for a single gate policy rule.
/// </summary>
public sealed record VexGatePolicyRuleDto
{
/// <summary>
/// Rule identifier.
/// </summary>
public required string RuleId { get; init; }
/// <summary>
/// Priority (higher = evaluated first).
/// </summary>
public int Priority { get; init; }
/// <summary>
/// Decision when this rule matches.
/// </summary>
public required string Decision { get; init; }
/// <summary>
/// Human-readable description.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Conditions that must be met for this rule.
/// </summary>
public VexGatePolicyConditionDto? Condition { get; init; }
}
/// <summary>
/// DTO for policy rule conditions.
/// </summary>
public sealed record VexGatePolicyConditionDto
{
/// <summary>
/// Required vendor VEX status.
/// </summary>
public string? VendorStatus { get; init; }
/// <summary>
/// Required exploitability state.
/// </summary>
public bool? IsExploitable { get; init; }
/// <summary>
/// Required reachability state.
/// </summary>
public bool? IsReachable { get; init; }
/// <summary>
/// Required compensating control state.
/// </summary>
public bool? HasCompensatingControl { get; init; }
/// <summary>
/// Matching severity levels.
/// </summary>
public IReadOnlyList<string>? SeverityLevels { get; init; }
}

View File

@@ -0,0 +1,193 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Text;
using System.Text.Json;
using StellaOps.Scanner.Emit.Composition;
using StellaOps.Scanner.WebService.Domain;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// Default implementation of <see cref="ILayerSbomService"/>.
/// Sprint: SPRINT_20260106_003_001_SCANNER_perlayer_sbom_api
/// </summary>
public sealed class LayerSbomService : ILayerSbomService
{
private readonly ICompositionRecipeService _recipeService;
// In-memory cache for layer SBOMs (would be replaced with CAS in production)
private static readonly ConcurrentDictionary<string, LayerSbomStore> LayerSbomCache = new(StringComparer.Ordinal);
public LayerSbomService(ICompositionRecipeService? recipeService = null)
{
_recipeService = recipeService ?? new CompositionRecipeService();
}
/// <inheritdoc />
public Task<ImmutableArray<LayerSummary>> GetLayerSummariesAsync(
ScanId scanId,
CancellationToken cancellationToken = default)
{
var key = scanId.Value;
if (!LayerSbomCache.TryGetValue(key, out var store))
{
return Task.FromResult(ImmutableArray<LayerSummary>.Empty);
}
var summaries = store.LayerRefs
.OrderBy(r => r.Order)
.Select(r => new LayerSummary
{
LayerDigest = r.LayerDigest,
Order = r.Order,
HasSbom = true,
ComponentCount = r.ComponentCount,
})
.ToImmutableArray();
return Task.FromResult(summaries);
}
/// <inheritdoc />
public Task<byte[]?> GetLayerSbomAsync(
ScanId scanId,
string layerDigest,
string format,
CancellationToken cancellationToken = default)
{
var key = scanId.Value;
if (!LayerSbomCache.TryGetValue(key, out var store))
{
return Task.FromResult<byte[]?>(null);
}
var artifact = store.Artifacts.FirstOrDefault(a =>
string.Equals(a.LayerDigest, layerDigest, StringComparison.OrdinalIgnoreCase));
if (artifact is null)
{
return Task.FromResult<byte[]?>(null);
}
var bytes = string.Equals(format, "spdx", StringComparison.OrdinalIgnoreCase)
? artifact.SpdxJsonBytes
: artifact.CycloneDxJsonBytes;
return Task.FromResult<byte[]?>(bytes);
}
/// <inheritdoc />
public Task<CompositionRecipeResponse?> GetCompositionRecipeAsync(
ScanId scanId,
CancellationToken cancellationToken = default)
{
var key = scanId.Value;
if (!LayerSbomCache.TryGetValue(key, out var store))
{
return Task.FromResult<CompositionRecipeResponse?>(null);
}
return Task.FromResult<CompositionRecipeResponse?>(store.Recipe);
}
/// <inheritdoc />
public Task<CompositionRecipeVerificationResult?> VerifyCompositionRecipeAsync(
ScanId scanId,
CancellationToken cancellationToken = default)
{
var key = scanId.Value;
if (!LayerSbomCache.TryGetValue(key, out var store))
{
return Task.FromResult<CompositionRecipeVerificationResult?>(null);
}
if (store.Recipe is null)
{
return Task.FromResult<CompositionRecipeVerificationResult?>(null);
}
var result = _recipeService.Verify(store.Recipe, store.LayerRefs);
return Task.FromResult<CompositionRecipeVerificationResult?>(result);
}
/// <inheritdoc />
public Task StoreLayerSbomsAsync(
ScanId scanId,
string imageDigest,
LayerSbomCompositionResult result,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(result);
var key = scanId.Value;
// Build a mock SbomCompositionResult for recipe generation
// In a real implementation, this would come from the scan coordinator
var recipe = BuildRecipe(scanId.Value, imageDigest, result);
var store = new LayerSbomStore
{
ScanId = scanId.Value,
ImageDigest = imageDigest,
Artifacts = result.Artifacts,
LayerRefs = result.References,
Recipe = recipe,
};
LayerSbomCache[key] = store;
return Task.CompletedTask;
}
private CompositionRecipeResponse BuildRecipe(string scanId, string imageDigest, LayerSbomCompositionResult result)
{
var layers = result.References
.Select(r => new CompositionRecipeLayer
{
Digest = r.LayerDigest,
Order = r.Order,
FragmentDigest = r.FragmentDigest,
SbomDigests = new LayerSbomDigests
{
CycloneDx = r.CycloneDxDigest,
Spdx = r.SpdxDigest,
},
ComponentCount = r.ComponentCount,
})
.OrderBy(l => l.Order)
.ToImmutableArray();
return new CompositionRecipeResponse
{
ScanId = scanId,
ImageDigest = imageDigest,
CreatedAt = DateTimeOffset.UtcNow.ToString("O"),
Recipe = new CompositionRecipe
{
Version = "1.0.0",
GeneratorName = "StellaOps.Scanner",
GeneratorVersion = "2026.04",
Layers = layers,
MerkleRoot = result.MerkleRoot,
AggregatedSbomDigests = new AggregatedSbomDigests
{
CycloneDx = result.MerkleRoot, // Placeholder - would come from actual SBOM
Spdx = null,
},
},
};
}
private sealed record LayerSbomStore
{
public required string ScanId { get; init; }
public required string ImageDigest { get; init; }
public required ImmutableArray<LayerSbomArtifact> Artifacts { get; init; }
public required ImmutableArray<LayerSbomRef> LayerRefs { get; init; }
public CompositionRecipeResponse? Recipe { get; init; }
}
}

View File

@@ -0,0 +1,208 @@
// -----------------------------------------------------------------------------
// VexGateQueryService.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Task: T021
// Description: Service for querying VEX gate results from completed scans.
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using StellaOps.Scanner.WebService.Contracts;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// Service for querying VEX gate evaluation results.
/// Uses in-memory storage for gate results (populated by scan worker).
/// </summary>
public sealed class VexGateQueryService : IVexGateQueryService
{
private readonly IVexGateResultsStore _resultsStore;
private readonly ILogger<VexGateQueryService> _logger;
private readonly VexGatePolicyDto _defaultPolicy;
public VexGateQueryService(
IVexGateResultsStore resultsStore,
ILogger<VexGateQueryService> logger)
{
_resultsStore = resultsStore ?? throw new ArgumentNullException(nameof(resultsStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_defaultPolicy = CreateDefaultPolicy();
}
/// <inheritdoc />
public async Task<VexGateResultsResponse?> GetGateResultsAsync(
string scanId,
VexGateResultsQuery? query = null,
CancellationToken cancellationToken = default)
{
var results = await _resultsStore.GetAsync(scanId, cancellationToken).ConfigureAwait(false);
if (results is null)
{
_logger.LogDebug("Gate results not found for scan {ScanId}", scanId);
return null;
}
// Apply query filters if provided
if (query is not null)
{
results = ApplyFilters(results, query);
}
return results;
}
/// <inheritdoc />
public Task<VexGatePolicyDto> GetPolicyAsync(
string? tenantId = null,
CancellationToken cancellationToken = default)
{
// TODO: Load tenant-specific policy from configuration
_logger.LogDebug("Getting gate policy for tenant {TenantId}", tenantId ?? "(default)");
return Task.FromResult(_defaultPolicy);
}
private static VexGateResultsResponse ApplyFilters(VexGateResultsResponse results, VexGateResultsQuery query)
{
var filtered = results.GatedFindings.AsEnumerable();
if (!string.IsNullOrEmpty(query.Decision))
{
filtered = filtered.Where(f =>
f.Decision.Equals(query.Decision, StringComparison.OrdinalIgnoreCase));
}
if (query.MinConfidence.HasValue)
{
filtered = filtered.Where(f =>
f.Evidence.ConfidenceScore >= query.MinConfidence.Value);
}
if (query.Offset.HasValue && query.Offset.Value > 0)
{
filtered = filtered.Skip(query.Offset.Value);
}
if (query.Limit.HasValue && query.Limit.Value > 0)
{
filtered = filtered.Take(query.Limit.Value);
}
return results with { GatedFindings = filtered.ToList() };
}
private static VexGatePolicyDto CreateDefaultPolicy()
{
return new VexGatePolicyDto
{
Version = "default",
Enabled = true,
DefaultDecision = "Warn",
Rules = new List<VexGatePolicyRuleDto>
{
new()
{
RuleId = "block-exploitable-reachable",
Priority = 100,
Decision = "Block",
Description = "Block findings that are exploitable and reachable without compensating controls",
Condition = new VexGatePolicyConditionDto
{
IsExploitable = true,
IsReachable = true,
HasCompensatingControl = false
}
},
new()
{
RuleId = "warn-high-not-reachable",
Priority = 90,
Decision = "Warn",
Description = "Warn on high/critical severity that is not reachable",
Condition = new VexGatePolicyConditionDto
{
IsReachable = false,
SeverityLevels = new[] { "critical", "high" }
}
},
new()
{
RuleId = "pass-vendor-not-affected",
Priority = 80,
Decision = "Pass",
Description = "Pass findings with vendor not_affected VEX status",
Condition = new VexGatePolicyConditionDto
{
VendorStatus = "NotAffected"
}
},
new()
{
RuleId = "pass-backport-confirmed",
Priority = 70,
Decision = "Pass",
Description = "Pass findings with confirmed backport fix",
Condition = new VexGatePolicyConditionDto
{
VendorStatus = "Fixed"
}
}
}
};
}
}
/// <summary>
/// Interface for storing and retrieving VEX gate results.
/// </summary>
public interface IVexGateResultsStore
{
/// <summary>
/// Gets gate results for a scan.
/// </summary>
Task<VexGateResultsResponse?> GetAsync(string scanId, CancellationToken cancellationToken = default);
/// <summary>
/// Stores gate results for a scan.
/// </summary>
Task StoreAsync(string scanId, VexGateResultsResponse results, CancellationToken cancellationToken = default);
}
/// <summary>
/// In-memory implementation of VEX gate results store.
/// </summary>
public sealed class InMemoryVexGateResultsStore : IVexGateResultsStore
{
private readonly ConcurrentDictionary<string, VexGateResultsResponse> _results = new(StringComparer.OrdinalIgnoreCase);
private readonly int _maxEntries;
public InMemoryVexGateResultsStore(int maxEntries = 10000)
{
_maxEntries = maxEntries;
}
public Task<VexGateResultsResponse?> GetAsync(string scanId, CancellationToken cancellationToken = default)
{
_results.TryGetValue(scanId, out var result);
return Task.FromResult(result);
}
public Task StoreAsync(string scanId, VexGateResultsResponse results, CancellationToken cancellationToken = default)
{
// Simple eviction: if at capacity, remove oldest (first) entry
while (_results.Count >= _maxEntries)
{
var firstKey = _results.Keys.FirstOrDefault();
if (firstKey is not null)
{
_results.TryRemove(firstKey, out _);
}
else
{
break;
}
}
_results[scanId] = results;
return Task.CompletedTask;
}
}

View File

@@ -28,6 +28,8 @@
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />
<ProjectReference Include="../../Policy/__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
<ProjectReference Include="../../Policy/__Libraries/StellaOps.Policy.Determinization/StellaOps.Policy.Determinization.csproj" />
<ProjectReference Include="../../Policy/__Libraries/StellaOps.Policy.Explainability/StellaOps.Policy.Explainability.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.DependencyInjection/StellaOps.Cryptography.DependencyInjection.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Plugin.BouncyCastle/StellaOps.Cryptography.Plugin.BouncyCastle.csproj" />
@@ -49,6 +51,7 @@
<ProjectReference Include="../../Router/__Libraries/StellaOps.Messaging/StellaOps.Messaging.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Orchestration/StellaOps.Scanner.Orchestration.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Sources/StellaOps.Scanner.Sources.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Emit/StellaOps.Scanner.Emit.csproj" />
<ProjectReference Include="../../Router/__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
</ItemGroup>