partly or unimplemented features - now implemented

This commit is contained in:
master
2026-02-09 08:53:51 +02:00
parent 1bf6bbf395
commit 4bdc298ec1
674 changed files with 90194 additions and 2271 deletions

View File

@@ -262,3 +262,129 @@ public sealed record VexGateResultsQuery
/// </summary>
public int? Offset { get; init; }
}
/// <summary>
/// Request for VEX + reachability decision filtering.
/// </summary>
public sealed record VexReachabilityFilterRequest
{
/// <summary>
/// Findings to evaluate.
/// </summary>
[JsonPropertyName("findings")]
public required IReadOnlyList<VexReachabilityFilterFindingDto> Findings { get; init; }
}
/// <summary>
/// Input finding for VEX + reachability filtering.
/// </summary>
public sealed record VexReachabilityFilterFindingDto
{
/// <summary>
/// Finding identifier.
/// </summary>
[JsonPropertyName("findingId")]
public required string FindingId { get; init; }
/// <summary>
/// CVE identifier.
/// </summary>
[JsonPropertyName("cve")]
public required string Cve { get; init; }
/// <summary>
/// Package URL.
/// </summary>
[JsonPropertyName("purl")]
public string? Purl { get; init; }
/// <summary>
/// Vendor VEX status (e.g. not_affected, affected, fixed, under_investigation).
/// </summary>
[JsonPropertyName("vendorStatus")]
public string? VendorStatus { get; init; }
/// <summary>
/// Reachability tier (confirmed, likely, present, unreachable, unknown).
/// </summary>
[JsonPropertyName("reachabilityTier")]
public required string ReachabilityTier { get; init; }
/// <summary>
/// Existing gate decision (pass, warn, block).
/// </summary>
[JsonPropertyName("existingDecision")]
public string? ExistingDecision { get; init; }
}
/// <summary>
/// Response for VEX + reachability decision filtering.
/// </summary>
public sealed record VexReachabilityFilterResponse
{
/// <summary>
/// Annotated findings after matrix evaluation.
/// </summary>
[JsonPropertyName("findings")]
public required IReadOnlyList<VexReachabilityFilterDecisionDto> Findings { get; init; }
/// <summary>
/// Aggregate summary for filter actions.
/// </summary>
[JsonPropertyName("summary")]
public required VexReachabilityFilterSummaryDto Summary { get; init; }
}
/// <summary>
/// Matrix evaluation result for a single finding.
/// </summary>
public sealed record VexReachabilityFilterDecisionDto
{
[JsonPropertyName("findingId")]
public required string FindingId { get; init; }
[JsonPropertyName("cve")]
public required string Cve { get; init; }
[JsonPropertyName("purl")]
public string? Purl { get; init; }
[JsonPropertyName("vendorStatus")]
public string? VendorStatus { get; init; }
[JsonPropertyName("reachabilityTier")]
public required string ReachabilityTier { get; init; }
[JsonPropertyName("action")]
public required string Action { get; init; }
[JsonPropertyName("effectiveDecision")]
public required string EffectiveDecision { get; init; }
[JsonPropertyName("matrixRule")]
public required string MatrixRule { get; init; }
[JsonPropertyName("rationale")]
public required string Rationale { get; init; }
}
/// <summary>
/// Summary counts for VEX + reachability filter actions.
/// </summary>
public sealed record VexReachabilityFilterSummaryDto
{
[JsonPropertyName("total")]
public int Total { get; init; }
[JsonPropertyName("suppressed")]
public int Suppressed { get; init; }
[JsonPropertyName("elevated")]
public int Elevated { get; init; }
[JsonPropertyName("passThrough")]
public int PassThrough { get; init; }
[JsonPropertyName("flagForReview")]
public int FlagForReview { get; init; }
}

View File

@@ -6,6 +6,7 @@
// -----------------------------------------------------------------------------
using Microsoft.AspNetCore.Mvc;
using StellaOps.Scanner.Gate;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Services;
@@ -20,13 +21,16 @@ namespace StellaOps.Scanner.WebService.Controllers;
public sealed class VexGateController : ControllerBase
{
private readonly IVexGateQueryService _gateQueryService;
private readonly IVexReachabilityDecisionFilter _vexReachabilityDecisionFilter;
private readonly ILogger<VexGateController> _logger;
public VexGateController(
IVexGateQueryService gateQueryService,
IVexReachabilityDecisionFilter vexReachabilityDecisionFilter,
ILogger<VexGateController> logger)
{
_gateQueryService = gateQueryService ?? throw new ArgumentNullException(nameof(gateQueryService));
_vexReachabilityDecisionFilter = vexReachabilityDecisionFilter ?? throw new ArgumentNullException(nameof(vexReachabilityDecisionFilter));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -140,4 +144,197 @@ public sealed class VexGateController : ControllerBase
return Ok(results.GatedFindings);
}
/// <summary>
/// Evaluate a list of findings with VEX + reachability matrix filtering.
/// </summary>
/// <param name="request">Findings to evaluate.</param>
/// <response code="200">Findings evaluated successfully.</response>
/// <response code="400">Request payload is invalid.</response>
[HttpPost("vex-reachability/filter")]
[ProducesResponseType(typeof(VexReachabilityFilterResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public IActionResult FilterByVexReachability([FromBody] VexReachabilityFilterRequest request)
{
if (request?.Findings is null || request.Findings.Count == 0)
{
return BadRequest(new { error = "At least one finding is required." });
}
var validationErrors = new List<string>();
var inputs = new List<VexReachabilityDecisionInput>(request.Findings.Count);
foreach (var finding in request.Findings)
{
if (!TryParseReachabilityTier(finding.ReachabilityTier, out var tier))
{
validationErrors.Add(
$"Finding '{finding.FindingId}' has unsupported reachabilityTier '{finding.ReachabilityTier}'.");
continue;
}
if (!TryParseVendorStatus(finding.VendorStatus, out var vendorStatus))
{
validationErrors.Add(
$"Finding '{finding.FindingId}' has unsupported vendorStatus '{finding.VendorStatus}'.");
continue;
}
if (!TryParseDecision(finding.ExistingDecision, out var existingDecision))
{
validationErrors.Add(
$"Finding '{finding.FindingId}' has unsupported existingDecision '{finding.ExistingDecision}'.");
continue;
}
inputs.Add(new VexReachabilityDecisionInput
{
FindingId = finding.FindingId,
VulnerabilityId = finding.Cve,
Purl = finding.Purl,
VendorStatus = vendorStatus,
ReachabilityTier = tier,
ExistingDecision = existingDecision
});
}
if (validationErrors.Count > 0)
{
return BadRequest(new
{
error = "One or more findings contain invalid values.",
details = validationErrors
});
}
var results = _vexReachabilityDecisionFilter.EvaluateBatch(inputs);
var responseFindings = results
.Select(result => new VexReachabilityFilterDecisionDto
{
FindingId = result.FindingId,
Cve = result.VulnerabilityId,
Purl = result.Purl,
VendorStatus = ToVendorStatusString(result.VendorStatus),
ReachabilityTier = ToReachabilityTierString(result.ReachabilityTier),
Action = ToActionString(result.Action),
EffectiveDecision = ToDecisionString(result.EffectiveDecision),
MatrixRule = result.MatrixRule,
Rationale = result.Rationale
})
.ToList();
var summary = new VexReachabilityFilterSummaryDto
{
Total = responseFindings.Count,
Suppressed = responseFindings.Count(f => string.Equals(f.Action, "suppress", StringComparison.Ordinal)),
Elevated = responseFindings.Count(f => string.Equals(f.Action, "elevate", StringComparison.Ordinal)),
PassThrough = responseFindings.Count(f => string.Equals(f.Action, "pass_through", StringComparison.Ordinal)),
FlagForReview = responseFindings.Count(f => string.Equals(f.Action, "flag_for_review", StringComparison.Ordinal))
};
return Ok(new VexReachabilityFilterResponse
{
Findings = responseFindings,
Summary = summary
});
}
private static bool TryParseVendorStatus(string? value, out VexStatus? status)
{
status = null;
if (string.IsNullOrWhiteSpace(value))
{
return true;
}
var normalized = value.Trim().ToLowerInvariant().Replace('-', '_');
status = normalized switch
{
"not_affected" => VexStatus.NotAffected,
"affected" => VexStatus.Affected,
"fixed" => VexStatus.Fixed,
"under_investigation" => VexStatus.UnderInvestigation,
_ => (VexStatus?)null
};
return status is not null;
}
private static bool TryParseReachabilityTier(string? value, out VexReachabilityTier tier)
{
tier = VexReachabilityTier.Unknown;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var normalized = value.Trim().ToLowerInvariant().Replace('-', '_');
tier = normalized switch
{
"confirmed" => VexReachabilityTier.Confirmed,
"likely" => VexReachabilityTier.Likely,
"present" => VexReachabilityTier.Present,
"unreachable" => VexReachabilityTier.Unreachable,
"unknown" => VexReachabilityTier.Unknown,
_ => VexReachabilityTier.Unknown
};
return normalized is "confirmed" or "likely" or "present" or "unreachable" or "unknown";
}
private static bool TryParseDecision(string? value, out VexGateDecision decision)
{
if (string.IsNullOrWhiteSpace(value))
{
decision = VexGateDecision.Warn;
return true;
}
var normalized = value.Trim().ToLowerInvariant();
decision = normalized switch
{
"pass" => VexGateDecision.Pass,
"warn" => VexGateDecision.Warn,
"block" => VexGateDecision.Block,
_ => VexGateDecision.Warn
};
return normalized is "pass" or "warn" or "block";
}
private static string ToVendorStatusString(VexStatus? status) => status switch
{
VexStatus.NotAffected => "not_affected",
VexStatus.Affected => "affected",
VexStatus.Fixed => "fixed",
VexStatus.UnderInvestigation => "under_investigation",
_ => "unknown"
};
private static string ToReachabilityTierString(VexReachabilityTier tier) => tier switch
{
VexReachabilityTier.Confirmed => "confirmed",
VexReachabilityTier.Likely => "likely",
VexReachabilityTier.Present => "present",
VexReachabilityTier.Unreachable => "unreachable",
_ => "unknown"
};
private static string ToActionString(VexReachabilityFilterAction action) => action switch
{
VexReachabilityFilterAction.Suppress => "suppress",
VexReachabilityFilterAction.Elevate => "elevate",
VexReachabilityFilterAction.PassThrough => "pass_through",
VexReachabilityFilterAction.FlagForReview => "flag_for_review",
_ => "pass_through"
};
private static string ToDecisionString(VexGateDecision decision) => decision switch
{
VexGateDecision.Pass => "pass",
VexGateDecision.Warn => "warn",
VexGateDecision.Block => "block",
_ => "warn"
};
}

View File

@@ -0,0 +1,334 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Scanner.Triage.Models;
using StellaOps.Scanner.Triage.Services;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Security;
namespace StellaOps.Scanner.WebService.Endpoints.Triage;
/// <summary>
/// Endpoints for exploit-path cluster statistics and batch triage actions.
/// </summary>
internal static class BatchTriageEndpoints
{
public static void MapBatchTriageEndpoints(this RouteGroupBuilder apiGroup)
{
ArgumentNullException.ThrowIfNull(apiGroup);
var triageGroup = apiGroup.MapGroup("/triage")
.WithTags("Triage");
triageGroup.MapGet("/inbox/clusters/stats", HandleGetClusterStatsAsync)
.WithName("scanner.triage.inbox.cluster-stats")
.WithDescription("Returns per-cluster severity and reachability distributions.")
.Produces<TriageClusterStatsResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.TriageRead);
triageGroup.MapPost("/inbox/clusters/{pathId}/actions", HandleApplyBatchActionAsync)
.WithName("scanner.triage.inbox.cluster-action")
.WithDescription("Applies one triage action to all findings in an exploit-path cluster.")
.Produces<BatchTriageClusterActionResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.TriageWrite);
}
private static async Task<IResult> HandleGetClusterStatsAsync(
[FromQuery] string artifactDigest,
[FromQuery] string? filter,
[FromQuery] decimal? similarityThreshold,
[FromServices] IFindingQueryService findingService,
[FromServices] IExploitPathGroupingService groupingService,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(artifactDigest))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid artifact digest",
detail = "Artifact digest is required."
});
}
var findings = await findingService.GetFindingsForArtifactAsync(artifactDigest, cancellationToken).ConfigureAwait(false);
var clusters = similarityThreshold.HasValue
? await groupingService.GroupFindingsAsync(artifactDigest, findings, similarityThreshold.Value, cancellationToken).ConfigureAwait(false)
: await groupingService.GroupFindingsAsync(artifactDigest, findings, cancellationToken).ConfigureAwait(false);
var filtered = ApplyFilter(clusters, filter);
var response = new TriageClusterStatsResponse
{
ArtifactDigest = artifactDigest,
Filter = filter,
TotalClusters = filtered.Count,
TotalFindings = filtered.Sum(static c => c.FindingIds.Length),
Clusters = filtered.Select(ToClusterStats).OrderByDescending(static c => c.PriorityScore).ThenBy(static c => c.PathId, StringComparer.Ordinal).ToArray(),
SeverityDistribution = BuildSeverityDistribution(filtered),
ReachabilityDistribution = BuildReachabilityDistribution(filtered),
GeneratedAt = timeProvider.GetUtcNow()
};
return Results.Ok(response);
}
private static async Task<IResult> HandleApplyBatchActionAsync(
[FromRoute] string pathId,
[FromBody] BatchTriageClusterActionRequest request,
[FromServices] IFindingQueryService findingService,
[FromServices] IExploitPathGroupingService groupingService,
[FromServices] ITriageStatusService triageStatusService,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid artifact digest",
detail = "Artifact digest is required."
});
}
if (string.IsNullOrWhiteSpace(pathId))
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Invalid path id",
detail = "Path id is required."
});
}
var findings = await findingService.GetFindingsForArtifactAsync(request.ArtifactDigest, cancellationToken).ConfigureAwait(false);
var clusters = request.SimilarityThreshold.HasValue
? await groupingService.GroupFindingsAsync(request.ArtifactDigest, findings, request.SimilarityThreshold.Value, cancellationToken).ConfigureAwait(false)
: await groupingService.GroupFindingsAsync(request.ArtifactDigest, findings, cancellationToken).ConfigureAwait(false);
var cluster = clusters.FirstOrDefault(c => string.Equals(c.PathId, pathId, StringComparison.Ordinal));
if (cluster is null)
{
return Results.NotFound(new
{
type = "not-found",
title = "Cluster not found",
detail = $"Cluster '{pathId}' was not found for artifact '{request.ArtifactDigest}'."
});
}
var decisionKind = NormalizeDecisionKind(request.DecisionKind);
var lane = string.IsNullOrWhiteSpace(request.Lane) ? ResolveLane(decisionKind) : request.Lane!.Trim();
var actor = string.IsNullOrWhiteSpace(request.Actor) ? "batch-triage" : request.Actor!.Trim();
var updated = ImmutableArray.CreateBuilder<string>(cluster.FindingIds.Length);
foreach (var findingId in cluster.FindingIds)
{
var updateRequest = new UpdateTriageStatusRequestDto
{
DecisionKind = decisionKind,
Lane = lane,
Reason = request.Reason,
Actor = actor
};
var result = await triageStatusService.UpdateStatusAsync(findingId, updateRequest, actor, cancellationToken).ConfigureAwait(false);
if (result is not null)
{
updated.Add(findingId);
}
}
var record = BuildActionRecord(request.ArtifactDigest, pathId, lane, decisionKind, request.Reason, updated.ToImmutable());
var response = new BatchTriageClusterActionResponse
{
PathId = pathId,
ArtifactDigest = request.ArtifactDigest,
DecisionKind = decisionKind,
Lane = lane,
RequestedFindingCount = cluster.FindingIds.Length,
UpdatedFindingCount = updated.Count,
UpdatedFindingIds = updated.ToImmutable(),
ActionRecord = record,
AppliedAt = timeProvider.GetUtcNow()
};
return Results.Ok(response);
}
private static IReadOnlyList<ExploitPath> ApplyFilter(IReadOnlyList<ExploitPath> paths, string? filter)
{
if (string.IsNullOrWhiteSpace(filter))
{
return paths;
}
return filter.Trim().ToLowerInvariant() switch
{
"actionable" => paths.Where(static p => !p.IsQuiet && p.Reachability is ReachabilityStatus.StaticallyReachable or ReachabilityStatus.RuntimeConfirmed).ToArray(),
"noisy" => paths.Where(static p => p.IsQuiet).ToArray(),
"reachable" => paths.Where(static p => p.Reachability is ReachabilityStatus.StaticallyReachable or ReachabilityStatus.RuntimeConfirmed).ToArray(),
"runtime" => paths.Where(static p => p.Reachability == ReachabilityStatus.RuntimeConfirmed).ToArray(),
"critical" => paths.Where(static p => p.RiskScore.CriticalCount > 0).ToArray(),
"high" => paths.Where(static p => p.RiskScore.HighCount > 0).ToArray(),
_ => paths
};
}
private static string NormalizeDecisionKind(string? decisionKind)
{
if (string.IsNullOrWhiteSpace(decisionKind))
{
return "Ack";
}
return decisionKind.Trim() switch
{
var value when value.Equals("MuteReach", StringComparison.OrdinalIgnoreCase) => "MuteReach",
var value when value.Equals("MuteVex", StringComparison.OrdinalIgnoreCase) => "MuteVex",
var value when value.Equals("Exception", StringComparison.OrdinalIgnoreCase) => "Exception",
_ => "Ack"
};
}
private static string ResolveLane(string decisionKind)
=> decisionKind switch
{
"MuteReach" => "MutedReach",
"MuteVex" => "MutedVex",
"Exception" => "NeedsException",
_ => "Active"
};
private static BatchTriageActionRecord BuildActionRecord(
string artifactDigest,
string pathId,
string lane,
string decisionKind,
string? reason,
ImmutableArray<string> findingIds)
{
var payload = string.Join(
"\n",
artifactDigest.Trim(),
pathId.Trim(),
lane.Trim(),
decisionKind.Trim(),
reason?.Trim() ?? string.Empty,
string.Join(",", findingIds.Order(StringComparer.Ordinal)));
var digest = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(payload)));
return new BatchTriageActionRecord
{
ActionRecordId = $"triage-action:{digest[..16]}",
PayloadDigest = $"sha256:{digest}",
Signed = false
};
}
private static TriageClusterStatsItem ToClusterStats(ExploitPath path)
=> new()
{
PathId = path.PathId,
FindingCount = path.FindingIds.Length,
PriorityScore = path.PriorityScore,
Reachability = path.Reachability.ToString(),
Critical = path.RiskScore.CriticalCount,
High = path.RiskScore.HighCount,
Medium = path.RiskScore.MediumCount,
Low = path.RiskScore.LowCount
};
private static IReadOnlyDictionary<string, int> BuildSeverityDistribution(IEnumerable<ExploitPath> paths)
{
var totals = new SortedDictionary<string, int>(StringComparer.Ordinal)
{
["critical"] = 0,
["high"] = 0,
["medium"] = 0,
["low"] = 0
};
foreach (var path in paths)
{
totals["critical"] += path.RiskScore.CriticalCount;
totals["high"] += path.RiskScore.HighCount;
totals["medium"] += path.RiskScore.MediumCount;
totals["low"] += path.RiskScore.LowCount;
}
return totals;
}
private static IReadOnlyDictionary<string, int> BuildReachabilityDistribution(IEnumerable<ExploitPath> paths)
{
var totals = new SortedDictionary<string, int>(StringComparer.Ordinal);
foreach (var path in paths)
{
var key = path.Reachability.ToString();
totals[key] = totals.TryGetValue(key, out var count) ? count + 1 : 1;
}
return totals;
}
}
public sealed record BatchTriageClusterActionRequest
{
public required string ArtifactDigest { get; init; }
public string? DecisionKind { get; init; }
public string? Lane { get; init; }
public string? Reason { get; init; }
public string? Actor { get; init; }
public decimal? SimilarityThreshold { get; init; }
}
public sealed record BatchTriageClusterActionResponse
{
public required string PathId { get; init; }
public required string ArtifactDigest { get; init; }
public required string DecisionKind { get; init; }
public required string Lane { get; init; }
public required int RequestedFindingCount { get; init; }
public required int UpdatedFindingCount { get; init; }
public required IReadOnlyList<string> UpdatedFindingIds { get; init; }
public required BatchTriageActionRecord ActionRecord { get; init; }
public required DateTimeOffset AppliedAt { get; init; }
}
public sealed record BatchTriageActionRecord
{
public required string ActionRecordId { get; init; }
public required string PayloadDigest { get; init; }
public required bool Signed { get; init; }
}
public sealed record TriageClusterStatsResponse
{
public required string ArtifactDigest { get; init; }
public string? Filter { get; init; }
public required int TotalClusters { get; init; }
public required int TotalFindings { get; init; }
public required IReadOnlyList<TriageClusterStatsItem> Clusters { get; init; }
public required IReadOnlyDictionary<string, int> SeverityDistribution { get; init; }
public required IReadOnlyDictionary<string, int> ReachabilityDistribution { get; init; }
public required DateTimeOffset GeneratedAt { get; init; }
}
public sealed record TriageClusterStatsItem
{
public required string PathId { get; init; }
public required int FindingCount { get; init; }
public required decimal PriorityScore { get; init; }
public required string Reachability { get; init; }
public required int Critical { get; init; }
public required int High { get; init; }
public required int Medium { get; init; }
public required int Low { get; init; }
}

View File

@@ -49,6 +49,9 @@ internal static class TriageInboxEndpoints
private static async Task<IResult> HandleGetInboxAsync(
[FromQuery] string artifactDigest,
[FromQuery] string? filter,
[FromQuery] decimal? similarityThreshold,
[FromQuery] string? sortBy,
[FromQuery] bool descending,
[FromServices] IExploitPathGroupingService groupingService,
[FromServices] IFindingQueryService findingService,
[FromServices] TimeProvider timeProvider,
@@ -68,9 +71,11 @@ internal static class TriageInboxEndpoints
}
var findings = await findingService.GetFindingsForArtifactAsync(artifactDigest, cancellationToken);
var paths = await groupingService.GroupFindingsAsync(artifactDigest, findings, cancellationToken);
var paths = similarityThreshold.HasValue
? await groupingService.GroupFindingsAsync(artifactDigest, findings, similarityThreshold.Value, cancellationToken)
: await groupingService.GroupFindingsAsync(artifactDigest, findings, cancellationToken);
var filteredPaths = ApplyFilter(paths, filter);
var filteredPaths = ApplySort(ApplyFilter(paths, filter), sortBy, descending);
var response = new TriageInboxResponse
{
@@ -103,6 +108,31 @@ internal static class TriageInboxEndpoints
_ => paths
};
}
private static IReadOnlyList<ExploitPath> ApplySort(
IReadOnlyList<ExploitPath> paths,
string? sortBy,
bool descending)
{
if (string.IsNullOrWhiteSpace(sortBy))
{
return paths.OrderByDescending(static p => p.PriorityScore).ThenBy(static p => p.PathId, StringComparer.Ordinal).ToArray();
}
var ordered = sortBy.Trim().ToLowerInvariant() switch
{
"cluster-size" => paths.OrderBy(static p => p.FindingIds.Length).ThenBy(static p => p.PathId, StringComparer.Ordinal),
"severity" => paths.OrderBy(static p => p.RiskScore.CriticalCount)
.ThenBy(static p => p.RiskScore.HighCount)
.ThenBy(static p => p.RiskScore.MediumCount)
.ThenBy(static p => p.PathId, StringComparer.Ordinal),
"reachability" => paths.OrderBy(static p => p.Reachability).ThenBy(static p => p.PathId, StringComparer.Ordinal),
"priority" => paths.OrderBy(static p => p.PriorityScore).ThenBy(static p => p.PathId, StringComparer.Ordinal),
_ => paths.OrderByDescending(static p => p.PriorityScore).ThenBy(static p => p.PathId, StringComparer.Ordinal)
};
return descending ? ordered.Reverse().ToArray() : ordered.ToArray();
}
}
/// <summary>

View File

@@ -28,6 +28,7 @@ using StellaOps.Scanner.Core.Configuration;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Core.TrustAnchors;
using StellaOps.Scanner.Emit.Composition;
using StellaOps.Scanner.Gate;
using StellaOps.Scanner.ReachabilityDrift.DependencyInjection;
using StellaOps.Scanner.Storage;
using StellaOps.Scanner.Storage.Extensions;
@@ -37,6 +38,7 @@ using StellaOps.Scanner.Surface.Secrets;
using StellaOps.Scanner.Surface.Validation;
using StellaOps.Scanner.Triage;
using StellaOps.Scanner.Triage.Entities;
using StellaOps.Scanner.Triage.Services;
using StellaOps.Scanner.WebService.Determinism;
using StellaOps.Scanner.WebService.Diagnostics;
using StellaOps.Scanner.WebService.Endpoints;
@@ -174,6 +176,9 @@ builder.Services.AddSingleton<IDeltaCompareService, DeltaCompareService>();
builder.Services.AddSingleton<IBaselineService, BaselineService>();
builder.Services.AddSingleton<IActionablesService, ActionablesService>();
builder.Services.AddSingleton<ICounterfactualApiService, CounterfactualApiService>();
builder.Services.TryAddSingleton<IVexGateResultsStore, InMemoryVexGateResultsStore>();
builder.Services.TryAddSingleton<IVexGateQueryService, VexGateQueryService>();
builder.Services.TryAddSingleton<IVexReachabilityDecisionFilter, VexReachabilityDecisionFilter>();
// Secret Detection Settings (Sprint: SPRINT_20260104_006_BE)
builder.Services.AddScoped<ISecretDetectionSettingsService, SecretDetectionSettingsService>();
@@ -192,6 +197,8 @@ builder.Services.AddDbContext<TriageDbContext>(options =>
}));
builder.Services.AddScoped<ITriageQueryService, TriageQueryService>();
builder.Services.AddScoped<ITriageStatusService, TriageStatusService>();
builder.Services.TryAddScoped<IFindingQueryService, FindingQueryService>();
builder.Services.TryAddSingleton<IExploitPathGroupingService, ExploitPathGroupingService>();
// Verdict rationale rendering (Sprint: SPRINT_20260106_001_001_LB_verdict_rationale_renderer)
builder.Services.AddVerdictExplainability();
@@ -612,6 +619,7 @@ apiGroup.MapWitnessEndpoints(); // Sprint: SPRINT_3700_0001_0001
apiGroup.MapEpssEndpoints(); // Sprint: SPRINT_3410_0002_0001
apiGroup.MapTriageStatusEndpoints();
apiGroup.MapTriageInboxEndpoints();
apiGroup.MapBatchTriageEndpoints();
apiGroup.MapProofBundleEndpoints();
apiGroup.MapSecretDetectionSettingsEndpoints(); // Sprint: SPRINT_20260104_006_BE

View File

@@ -0,0 +1,181 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Scanner.Triage;
using StellaOps.Scanner.Triage.Entities;
using StellaOps.Scanner.Triage.Models;
using StellaOps.Scanner.Triage.Services;
using StellaOps.Scanner.WebService.Endpoints.Triage;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// Reads triage findings and maps them into exploit-path grouping inputs.
/// </summary>
public sealed class FindingQueryService : IFindingQueryService
{
private readonly TriageDbContext _dbContext;
private readonly ILogger<FindingQueryService> _logger;
public FindingQueryService(TriageDbContext dbContext, ILogger<FindingQueryService> logger)
{
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<IReadOnlyList<Finding>> GetFindingsForArtifactAsync(string artifactDigest, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(artifactDigest))
{
return [];
}
var findings = await _dbContext.Findings
.Include(static f => f.RiskResults)
.Include(static f => f.ReachabilityResults)
.AsNoTracking()
.Where(f => f.ArtifactDigest == artifactDigest)
.OrderBy(f => f.Id)
.ToListAsync(ct)
.ConfigureAwait(false);
var mapped = findings.Select(MapFinding).ToArray();
_logger.LogInformation(
"Mapped {FindingCount} findings for artifact {ArtifactDigest} into triage-grouping inputs",
mapped.Length,
artifactDigest);
return mapped;
}
private static Finding MapFinding(TriageFinding finding)
{
var latestRisk = finding.RiskResults
.OrderByDescending(static r => r.ComputedAt)
.FirstOrDefault();
var latestReachability = finding.ReachabilityResults
.OrderByDescending(static r => r.ComputedAt)
.FirstOrDefault();
var cveIds = string.IsNullOrWhiteSpace(finding.CveId)
? []
: new[] { finding.CveId.Trim() };
var packageName = ParsePackageName(finding.Purl);
var packageVersion = ParsePackageVersion(finding.Purl);
var callChain = ParseCallChain(latestReachability?.StaticProofRef, latestReachability?.RuntimeProofRef, packageName);
var reachabilityHint = MapReachability(latestReachability);
var reachabilityConfidence = latestReachability is null
? (decimal?)null
: decimal.Round(decimal.Clamp(latestReachability.Confidence / 100m, 0m, 1m), 4, MidpointRounding.ToZero);
return new Finding(
finding.Id.ToString(),
finding.Purl,
packageName,
packageVersion,
cveIds,
ConvertRiskToCvss(latestRisk),
ConvertRiskToEpss(latestRisk),
MapSeverity(latestRisk),
finding.ArtifactDigest ?? "sha256:unknown",
finding.FirstSeenAt,
callChain,
callChain.Count > 0 ? callChain[0] : null,
callChain.Count > 0 ? callChain[^1] : null,
reachabilityHint,
reachabilityConfidence);
}
private static Severity MapSeverity(TriageRiskResult? risk)
=> risk?.Score switch
{
>= 90 => Severity.Critical,
>= 70 => Severity.High,
>= 40 => Severity.Medium,
>= 10 => Severity.Low,
_ => Severity.Info
};
private static decimal ConvertRiskToCvss(TriageRiskResult? risk)
{
if (risk is null)
{
return 0m;
}
var cvss = risk.Score / 10m;
return decimal.Round(decimal.Clamp(cvss, 0m, 10m), 2, MidpointRounding.ToZero);
}
private static decimal ConvertRiskToEpss(TriageRiskResult? risk)
{
if (risk is null)
{
return 0m;
}
var epss = risk.Score / 100m;
return decimal.Round(decimal.Clamp(epss, 0m, 1m), 4, MidpointRounding.ToZero);
}
private static ReachabilityStatus MapReachability(TriageReachabilityResult? reachability)
=> reachability?.Reachable switch
{
TriageReachability.Yes when !string.IsNullOrWhiteSpace(reachability.RuntimeProofRef) => ReachabilityStatus.RuntimeConfirmed,
TriageReachability.Yes => ReachabilityStatus.StaticallyReachable,
TriageReachability.No => ReachabilityStatus.Unreachable,
_ => ReachabilityStatus.Unknown
};
private static IReadOnlyList<string> ParseCallChain(string? staticProofRef, string? runtimeProofRef, string packageName)
{
var raw = !string.IsNullOrWhiteSpace(staticProofRef) ? staticProofRef! : runtimeProofRef;
if (string.IsNullOrWhiteSpace(raw))
{
return [$"entrypoint:{packageName}", $"symbol:{packageName}"];
}
var separators = new[] { "->", "=>", "|" };
foreach (var separator in separators)
{
if (raw.Contains(separator, StringComparison.Ordinal))
{
var chain = raw.Split(separator, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (chain.Length > 0)
{
return chain;
}
}
}
return [$"entrypoint:{packageName}", raw.Trim()];
}
private static string ParsePackageName(string purl)
{
var normalized = purl.Trim();
var atIndex = normalized.IndexOf('@', StringComparison.Ordinal);
if (atIndex > 0)
{
normalized = normalized[..atIndex];
}
var slash = normalized.LastIndexOf('/');
if (slash >= 0 && slash + 1 < normalized.Length)
{
return normalized[(slash + 1)..];
}
return normalized;
}
private static string ParsePackageVersion(string purl)
{
var atIndex = purl.IndexOf('@', StringComparison.Ordinal);
if (atIndex < 0 || atIndex + 1 >= purl.Length)
{
return "unknown";
}
return purl[(atIndex + 1)..].Trim();
}
}

View File

@@ -57,6 +57,7 @@
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Emit/StellaOps.Scanner.Emit.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Sarif/StellaOps.Scanner.Sarif.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Validation/StellaOps.Scanner.Validation.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Gate/StellaOps.Scanner.Gate.csproj" />
<ProjectReference Include="../../Router/__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
</ItemGroup>

View File

@@ -10,3 +10,5 @@ Source of truth: `docs/implplan/SPRINT_20260112_003_BE_csproj_audit_pending_appl
| TODO-WEB-003 | TODO | Add VEX expiry once integrated in `src/Scanner/StellaOps.Scanner.WebService/Services/EvidenceCompositionService.cs`. |
| PRAGMA-WEB-001 | DONE | Documented ASPDEPR002 suppressions in `src/Scanner/StellaOps.Scanner.WebService/Endpoints/ReportEndpoints.cs`, `src/Scanner/StellaOps.Scanner.WebService/Endpoints/PolicyEndpoints.cs`, and `src/Scanner/StellaOps.Scanner.WebService/Endpoints/EpssEndpoints.cs`. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-20260208-062-VEXREACH-001 | DONE | Added `POST /api/v1/scans/vex-reachability/filter` endpoint and deterministic matrix annotations for findings (2026-02-08). |
| SPRINT-20260208-063-TRIAGE-001 | DONE | Implement triage cluster batch action and cluster statistics endpoints for sprint 063 (2026-02-08). |