- 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.
360 lines
12 KiB
C#
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;
|
|
}
|
|
}
|