Files
git.stella-ops.org/src/Scanner/StellaOps.Scanner.WebService/Services/TriageStatusService.cs
StellaOps Bot 5146204f1b feat: add security sink detection patterns for JavaScript/TypeScript
- Introduced `sink-detect.js` with various security sink detection patterns categorized by type (e.g., command injection, SQL injection, file operations).
- Implemented functions to build a lookup map for fast sink detection and to match sink calls against known patterns.
- Added `package-lock.json` for dependency management.
2025-12-22 23:21:21 +02:00

360 lines
12 KiB
C#

// -----------------------------------------------------------------------------
// 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;
/// <summary>
/// Default implementation of triage status service.
/// </summary>
public sealed class TriageStatusService : ITriageStatusService
{
private readonly ILogger<TriageStatusService> _logger;
private readonly ITriageQueryService _queryService;
private readonly ICounterfactualEngine? _counterfactualEngine;
private readonly TimeProvider _timeProvider;
public TriageStatusService(
ILogger<TriageStatusService> 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<FindingTriageStatusDto?> 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<UpdateTriageStatusResponseDto?> 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<SubmitVexStatementResponseDto?> 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<BulkTriageQueryResponseDto> 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<string, int>(),
ByVerdict = new Dictionary<string, int>(),
CanShipCount = 0,
BlockingCount = 0
}
};
return Task.FromResult(response);
}
public Task<TriageSummaryDto> 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<string, int>
{
["Active"] = 0,
["Blocked"] = 0,
["NeedsException"] = 0,
["MutedReach"] = 0,
["MutedVex"] = 0,
["Compensated"] = 0
},
ByVerdict = new Dictionary<string, int>
{
["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<string>? 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<string> ComputeWouldPassIf(TriageFinding finding, string currentLane)
{
var suggestions = new List<string>();
// 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;
}
}