// ----------------------------------------------------------------------------- // TriageStatusService.cs // Sprint: SPRINT_4200_0001_0001_triage_rest_api // Description: Service implementation for triage status operations. // ----------------------------------------------------------------------------- using Microsoft.Extensions.Logging; using StellaOps.Policy.Counterfactuals; using StellaOps.Scanner.Triage.Entities; using StellaOps.Scanner.WebService.Contracts; using StellaOps.Scanner.WebService.Endpoints.Triage; namespace StellaOps.Scanner.WebService.Services; /// /// Default implementation of triage status service. /// public sealed class TriageStatusService : ITriageStatusService { private readonly ILogger _logger; private readonly ITriageQueryService _queryService; private readonly ICounterfactualEngine? _counterfactualEngine; private readonly TimeProvider _timeProvider; public TriageStatusService( ILogger logger, ITriageQueryService queryService, TimeProvider timeProvider, ICounterfactualEngine? counterfactualEngine = null) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _queryService = queryService ?? throw new ArgumentNullException(nameof(queryService)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _counterfactualEngine = counterfactualEngine; } public async Task GetFindingStatusAsync( string findingId, CancellationToken ct = default) { _logger.LogDebug("Getting triage status for finding {FindingId}", findingId); var finding = await _queryService.GetFindingAsync(findingId, ct); if (finding is null) { return null; } return MapToDto(finding); } public async Task UpdateStatusAsync( string findingId, UpdateTriageStatusRequestDto request, string actor, CancellationToken ct = default) { _logger.LogDebug("Updating triage status for finding {FindingId} by {Actor}", findingId, actor); var finding = await _queryService.GetFindingAsync(findingId, ct); if (finding is null) { return null; } var previousLane = GetCurrentLane(finding); var previousVerdict = GetCurrentVerdict(finding); // In a full implementation, this would: // 1. Create a new TriageDecision // 2. Update the finding lane // 3. Create a snapshot for audit var newLane = !string.IsNullOrWhiteSpace(request.Lane) ? request.Lane : previousLane; var newVerdict = ComputeVerdict(newLane, request.DecisionKind); _logger.LogInformation( "Triage status updated: Finding={FindingId}, Lane={PrevLane}->{NewLane}, Verdict={PrevVerdict}->{NewVerdict}", findingId, previousLane, newLane, previousVerdict, newVerdict); return new UpdateTriageStatusResponseDto { FindingId = findingId, PreviousLane = previousLane, NewLane = newLane, PreviousVerdict = previousVerdict, NewVerdict = newVerdict, SnapshotId = $"snap-{Guid.NewGuid():N}", AppliedAt = _timeProvider.GetUtcNow() }; } public async Task SubmitVexStatementAsync( string findingId, SubmitVexStatementRequestDto request, string actor, CancellationToken ct = default) { _logger.LogDebug("Submitting VEX statement for finding {FindingId} by {Actor}", findingId, actor); var finding = await _queryService.GetFindingAsync(findingId, ct); if (finding is null) { return null; } var previousVerdict = GetCurrentVerdict(finding); var vexStatementId = $"vex-{Guid.NewGuid():N}"; // Determine if verdict changes based on VEX status var verdictChanged = false; string? newVerdict = null; if (request.Status.Equals("NotAffected", StringComparison.OrdinalIgnoreCase)) { verdictChanged = previousVerdict != "Ship"; newVerdict = "Ship"; } _logger.LogInformation( "VEX statement submitted: Finding={FindingId}, Status={Status}, VerdictChanged={Changed}", findingId, request.Status, verdictChanged); return new SubmitVexStatementResponseDto { VexStatementId = vexStatementId, FindingId = findingId, Status = request.Status, VerdictChanged = verdictChanged, NewVerdict = newVerdict, RecordedAt = _timeProvider.GetUtcNow() }; } public Task QueryFindingsAsync( BulkTriageQueryRequestDto request, int limit, CancellationToken ct = default) { _logger.LogDebug("Querying findings with limit {Limit}", limit); // In a full implementation, this would query the database // For now, return empty results var response = new BulkTriageQueryResponseDto { Findings = [], TotalCount = 0, NextCursor = null, Summary = new TriageSummaryDto { ByLane = new Dictionary(), ByVerdict = new Dictionary(), CanShipCount = 0, BlockingCount = 0 } }; return Task.FromResult(response); } public Task GetSummaryAsync(string artifactDigest, CancellationToken ct = default) { _logger.LogDebug("Getting triage summary for artifact {ArtifactDigest}", artifactDigest); // In a full implementation, this would aggregate data from the database var summary = new TriageSummaryDto { ByLane = new Dictionary { ["Active"] = 0, ["Blocked"] = 0, ["NeedsException"] = 0, ["MutedReach"] = 0, ["MutedVex"] = 0, ["Compensated"] = 0 }, ByVerdict = new Dictionary { ["Ship"] = 0, ["Block"] = 0, ["Exception"] = 0 }, CanShipCount = 0, BlockingCount = 0 }; return Task.FromResult(summary); } private FindingTriageStatusDto MapToDto(TriageFinding finding) { var lane = GetCurrentLane(finding); var verdict = GetCurrentVerdict(finding); TriageVexStatusDto? vexStatus = null; var latestVex = finding.EffectiveVexRecords .OrderByDescending(v => v.EffectiveAt) .FirstOrDefault(); if (latestVex is not null) { vexStatus = new TriageVexStatusDto { Status = latestVex.Status.ToString(), Justification = latestVex.Justification, ImpactStatement = latestVex.ImpactStatement, IssuedBy = latestVex.IssuedBy, IssuedAt = latestVex.IssuedAt, VexDocumentRef = latestVex.VexDocumentRef }; } TriageReachabilityDto? reachability = null; var latestReach = finding.ReachabilityResults .OrderByDescending(r => r.AnalyzedAt) .FirstOrDefault(); if (latestReach is not null) { reachability = new TriageReachabilityDto { Status = latestReach.Reachability.ToString(), Confidence = latestReach.Confidence, Source = latestReach.Source, AnalyzedAt = latestReach.AnalyzedAt }; } TriageRiskScoreDto? riskScore = null; var latestRisk = finding.RiskResults .OrderByDescending(r => r.ComputedAt) .FirstOrDefault(); if (latestRisk is not null) { riskScore = new TriageRiskScoreDto { Score = latestRisk.RiskScore, CriticalCount = latestRisk.CriticalCount, HighCount = latestRisk.HighCount, MediumCount = latestRisk.MediumCount, LowCount = latestRisk.LowCount, EpssScore = latestRisk.EpssScore, EpssPercentile = latestRisk.EpssPercentile }; } var evidence = finding.EvidenceArtifacts .Select(e => new TriageEvidenceDto { Type = e.Type.ToString(), Uri = e.Uri, Digest = e.Digest, CreatedAt = e.CreatedAt }) .ToList(); // Compute counterfactuals for non-Ship verdicts IReadOnlyList? wouldPassIf = null; if (verdict != "Ship") { wouldPassIf = ComputeWouldPassIf(finding, lane); } return new FindingTriageStatusDto { FindingId = finding.Id.ToString(), Lane = lane, Verdict = verdict, Reason = GetReason(finding), VexStatus = vexStatus, Reachability = reachability, RiskScore = riskScore, WouldPassIf = wouldPassIf, Evidence = evidence.Count > 0 ? evidence : null, ComputedAt = _timeProvider.GetUtcNow(), ProofBundleUri = $"/v1/triage/findings/{finding.Id}/proof-bundle" }; } private static string GetCurrentLane(TriageFinding finding) { var latestSnapshot = finding.Snapshots .OrderByDescending(s => s.CreatedAt) .FirstOrDefault(); return latestSnapshot?.Lane.ToString() ?? "Active"; } private static string GetCurrentVerdict(TriageFinding finding) { var latestSnapshot = finding.Snapshots .OrderByDescending(s => s.CreatedAt) .FirstOrDefault(); return latestSnapshot?.Verdict.ToString() ?? "Block"; } private static string? GetReason(TriageFinding finding) { var latestDecision = finding.Decisions .OrderByDescending(d => d.DecidedAt) .FirstOrDefault(); return latestDecision?.Reason; } private static string ComputeVerdict(string lane, string? decisionKind) { return lane switch { "MutedReach" => "Ship", "MutedVex" => "Ship", "Compensated" => "Ship", "Blocked" => "Block", "NeedsException" => decisionKind == "Exception" ? "Exception" : "Block", _ => "Block" }; } private IReadOnlyList ComputeWouldPassIf(TriageFinding finding, string currentLane) { var suggestions = new List(); // Check VEX path var latestVex = finding.EffectiveVexRecords .OrderByDescending(v => v.EffectiveAt) .FirstOrDefault(); if (latestVex is null || latestVex.Status != TriageVexStatus.NotAffected) { suggestions.Add("VEX status changed to 'not_affected'"); } // Check reachability path var latestReach = finding.ReachabilityResults .OrderByDescending(r => r.AnalyzedAt) .FirstOrDefault(); if (latestReach is null || latestReach.Reachability != TriageReachability.No) { suggestions.Add("Reachability analysis shows code is not reachable"); } // Check exception path if (!string.Equals(currentLane, "Compensated", StringComparison.OrdinalIgnoreCase)) { suggestions.Add("Security exception is granted"); } // Check version upgrade path if (!string.IsNullOrWhiteSpace(finding.CveId)) { suggestions.Add($"Component upgraded to version without {finding.CveId}"); } return suggestions; } }