sprints and audit work
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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]}";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user