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). |

View File

@@ -1,4 +1,4 @@
# StellaOps.Scanner.Gate Task Board
# StellaOps.Scanner.Gate Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
@@ -6,3 +6,5 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Libraries/StellaOps.Scanner.Gate/StellaOps.Scanner.Gate.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-20260208-062-VEXREACH-001 | DONE | Implemented dedicated VEX+reachability decision matrix filter with deterministic action/effective-decision mapping (2026-02-08). |

View File

@@ -68,6 +68,7 @@ public static class VexGateServiceCollectionExtensions
// Register VEX gate service
services.AddSingleton<IVexGateService, VexGateService>();
services.AddSingleton<IVexReachabilityDecisionFilter, VexReachabilityDecisionFilter>();
return services;
}
@@ -122,6 +123,7 @@ public static class VexGateServiceCollectionExtensions
// Register VEX gate service
services.AddSingleton<IVexGateService, VexGateService>();
services.AddSingleton<IVexReachabilityDecisionFilter, VexReachabilityDecisionFilter>();
return services;
}
@@ -163,6 +165,7 @@ public static class VexGateServiceCollectionExtensions
// Register VEX gate service
services.AddSingleton<IVexGateService, VexGateService>();
services.AddSingleton<IVexReachabilityDecisionFilter, VexReachabilityDecisionFilter>();
return services;
}

View File

@@ -0,0 +1,184 @@
// -----------------------------------------------------------------------------
// VexReachabilityDecisionFilter.cs
// Sprint: SPRINT_20260208_062_Scanner_vex_decision_filter_with_reachability
// Description: Deterministic matrix filter that combines VEX status and reachability.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Filters findings using a deterministic (VEX status x reachability tier) decision matrix.
/// </summary>
public interface IVexReachabilityDecisionFilter
{
/// <summary>
/// Evaluates a single finding and returns the annotated decision.
/// </summary>
VexReachabilityDecisionResult Evaluate(VexReachabilityDecisionInput input);
/// <summary>
/// Evaluates a batch of findings in stable input order.
/// </summary>
ImmutableArray<VexReachabilityDecisionResult> EvaluateBatch(IReadOnlyList<VexReachabilityDecisionInput> inputs);
}
/// <summary>
/// Reachability confidence tier used for VEX-aware filtering.
/// </summary>
public enum VexReachabilityTier
{
Confirmed,
Likely,
Present,
Unreachable,
Unknown
}
/// <summary>
/// Filter action after matrix evaluation.
/// </summary>
public enum VexReachabilityFilterAction
{
Suppress,
Elevate,
PassThrough,
FlagForReview
}
/// <summary>
/// Input for VEX + reachability matrix evaluation.
/// </summary>
public sealed record VexReachabilityDecisionInput
{
public required string FindingId { get; init; }
public required string VulnerabilityId { get; init; }
public string? Purl { get; init; }
public VexStatus? VendorStatus { get; init; }
public VexReachabilityTier ReachabilityTier { get; init; } = VexReachabilityTier.Unknown;
public VexGateDecision ExistingDecision { get; init; } = VexGateDecision.Warn;
}
/// <summary>
/// Output from VEX + reachability matrix evaluation.
/// </summary>
public sealed record VexReachabilityDecisionResult
{
public required string FindingId { get; init; }
public required string VulnerabilityId { get; init; }
public string? Purl { get; init; }
public VexStatus? VendorStatus { get; init; }
public VexReachabilityTier ReachabilityTier { get; init; }
public VexReachabilityFilterAction Action { get; init; }
public VexGateDecision EffectiveDecision { get; init; }
public required string Rationale { get; init; }
public required string MatrixRule { get; init; }
}
/// <summary>
/// Default deterministic implementation of <see cref="IVexReachabilityDecisionFilter"/>.
/// </summary>
public sealed class VexReachabilityDecisionFilter : IVexReachabilityDecisionFilter
{
/// <inheritdoc />
public VexReachabilityDecisionResult Evaluate(VexReachabilityDecisionInput input)
{
ArgumentNullException.ThrowIfNull(input);
var (action, rule, rationale) = EvaluateMatrix(input.VendorStatus, input.ReachabilityTier);
var effectiveDecision = action switch
{
VexReachabilityFilterAction.Suppress => VexGateDecision.Pass,
VexReachabilityFilterAction.Elevate => VexGateDecision.Block,
VexReachabilityFilterAction.FlagForReview => VexGateDecision.Warn,
_ => input.ExistingDecision
};
return new VexReachabilityDecisionResult
{
FindingId = input.FindingId,
VulnerabilityId = input.VulnerabilityId,
Purl = input.Purl,
VendorStatus = input.VendorStatus,
ReachabilityTier = input.ReachabilityTier,
Action = action,
EffectiveDecision = effectiveDecision,
Rationale = rationale,
MatrixRule = rule
};
}
/// <inheritdoc />
public ImmutableArray<VexReachabilityDecisionResult> EvaluateBatch(IReadOnlyList<VexReachabilityDecisionInput> inputs)
{
ArgumentNullException.ThrowIfNull(inputs);
if (inputs.Count == 0)
{
return ImmutableArray<VexReachabilityDecisionResult>.Empty;
}
var builder = ImmutableArray.CreateBuilder<VexReachabilityDecisionResult>(inputs.Count);
for (var i = 0; i < inputs.Count; i++)
{
builder.Add(Evaluate(inputs[i]));
}
return builder.MoveToImmutable();
}
private static (VexReachabilityFilterAction Action, string Rule, string Rationale) EvaluateMatrix(
VexStatus? vendorStatus,
VexReachabilityTier tier)
{
if (vendorStatus == VexStatus.NotAffected && tier == VexReachabilityTier.Unreachable)
{
return (
VexReachabilityFilterAction.Suppress,
"not_affected+unreachable",
"Suppress: vendor reports not_affected and reachability is unreachable.");
}
if (vendorStatus == VexStatus.Affected &&
(tier == VexReachabilityTier.Confirmed || tier == VexReachabilityTier.Likely))
{
return (
VexReachabilityFilterAction.Elevate,
"affected+reachable",
"Elevate: vendor reports affected and reachability indicates impact.");
}
if (vendorStatus == VexStatus.NotAffected &&
(tier == VexReachabilityTier.Confirmed || tier == VexReachabilityTier.Likely))
{
return (
VexReachabilityFilterAction.FlagForReview,
"not_affected+reachable",
"Flag for review: VEX not_affected conflicts with reachable evidence.");
}
if (vendorStatus == VexStatus.Fixed &&
(tier == VexReachabilityTier.Confirmed || tier == VexReachabilityTier.Likely))
{
return (
VexReachabilityFilterAction.FlagForReview,
"fixed+reachable",
"Flag for review: fixed status conflicts with reachable evidence.");
}
if (vendorStatus == VexStatus.UnderInvestigation && tier == VexReachabilityTier.Confirmed)
{
return (
VexReachabilityFilterAction.Elevate,
"under_investigation+confirmed",
"Elevate: confirmed reachability while vendor status remains under investigation.");
}
return (
VexReachabilityFilterAction.PassThrough,
"default-pass-through",
"Pass through: no override matrix rule matched.");
}
}

View File

@@ -38,6 +38,11 @@ public sealed record ExploitPath
/// </summary>
public required ImmutableArray<string> CveIds { get; init; }
/// <summary>
/// Finding IDs grouped into this exploit-path cluster.
/// </summary>
public required ImmutableArray<string> FindingIds { get; init; }
/// <summary>
/// Reachability status from lattice.
/// </summary>
@@ -48,6 +53,11 @@ public sealed record ExploitPath
/// </summary>
public required PathRiskScore RiskScore { get; init; }
/// <summary>
/// Deterministic triage priority score combining severity, depth, and reachability.
/// </summary>
public decimal PriorityScore { get; init; }
/// <summary>
/// Evidence supporting this path.
/// </summary>

View File

@@ -0,0 +1,242 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Triage.Models;
/// <summary>
/// A stack-tracestyle representation of an exploit path, designed for
/// UI rendering as a collapsible call-chain: entrypoint → intermediate calls → sink.
/// </summary>
public sealed record StackTraceExploitPathView
{
/// <summary>
/// The exploit-path ID this view was generated from.
/// </summary>
[JsonPropertyName("path_id")]
public required string PathId { get; init; }
/// <summary>
/// Display title (e.g. "CVE-2024-12345 via POST /api/orders → SqlSink.Write").
/// </summary>
[JsonPropertyName("title")]
public required string Title { get; init; }
/// <summary>
/// Ordered stack frames from entrypoint (index 0) to sink (last).
/// </summary>
[JsonPropertyName("frames")]
public required ImmutableArray<StackTraceFrame> Frames { get; init; }
/// <summary>
/// The total depth of the call chain.
/// </summary>
[JsonPropertyName("depth")]
public int Depth => Frames.Length;
/// <summary>
/// Reachability status of this path.
/// </summary>
[JsonPropertyName("reachability")]
public required ReachabilityStatus Reachability { get; init; }
/// <summary>
/// Aggregated CVE IDs affecting this path.
/// </summary>
[JsonPropertyName("cve_ids")]
public required ImmutableArray<string> CveIds { get; init; }
/// <summary>
/// Priority score (higher = more urgent).
/// </summary>
[JsonPropertyName("priority_score")]
public decimal PriorityScore { get; init; }
/// <summary>
/// Whether the path is collapsed by default in the UI.
/// Paths with ≤ 3 frames are expanded; deeper paths are collapsed to entrypoint + sink.
/// </summary>
[JsonPropertyName("collapsed_by_default")]
public bool CollapsedByDefault => Frames.Length > 3;
/// <summary>
/// Risk severity label derived from PriorityScore.
/// </summary>
[JsonPropertyName("severity_label")]
public string SeverityLabel => PriorityScore switch
{
>= 9.0m => "Critical",
>= 7.0m => "High",
>= 4.0m => "Medium",
>= 1.0m => "Low",
_ => "Info",
};
}
/// <summary>
/// A single frame in the stack-trace exploit path view.
/// Represents one node in the call chain from entrypoint to vulnerable sink.
/// </summary>
public sealed record StackTraceFrame
{
/// <summary>
/// Zero-based position in the call chain (0 = entrypoint).
/// </summary>
[JsonPropertyName("index")]
public required int Index { get; init; }
/// <summary>
/// Fully-qualified symbol name (e.g. "OrderService.Execute").
/// </summary>
[JsonPropertyName("symbol")]
public required string Symbol { get; init; }
/// <summary>
/// Frame role in the exploit chain.
/// </summary>
[JsonPropertyName("role")]
public required FrameRole Role { get; init; }
/// <summary>
/// Source file path (null if not available / stripped binaries).
/// </summary>
[JsonPropertyName("file")]
public string? File { get; init; }
/// <summary>
/// Line number in source file (null if unavailable).
/// </summary>
[JsonPropertyName("line")]
public int? Line { get; init; }
/// <summary>
/// End line for multi-line function bodies (null if unavailable).
/// </summary>
[JsonPropertyName("end_line")]
public int? EndLine { get; init; }
/// <summary>
/// Package / assembly containing this frame.
/// </summary>
[JsonPropertyName("package")]
public string? Package { get; init; }
/// <summary>
/// Programming language for syntax highlighting.
/// </summary>
[JsonPropertyName("language")]
public string? Language { get; init; }
/// <summary>
/// Source snippet at this frame (only present when source mapping is available).
/// Contains the function signature and a few context lines.
/// </summary>
[JsonPropertyName("source_snippet")]
public SourceSnippet? SourceSnippet { get; init; }
/// <summary>
/// Gate information at this hop (if a security gate was detected).
/// </summary>
[JsonPropertyName("gate_label")]
public string? GateLabel { get; init; }
/// <summary>
/// Whether this frame has source mapping available.
/// </summary>
[JsonPropertyName("has_source")]
public bool HasSource => File is not null && Line is not null;
/// <summary>
/// Display label for the frame (symbol + optional file:line).
/// </summary>
[JsonPropertyName("display_label")]
public string DisplayLabel =>
HasSource ? $"{Symbol} ({File}:{Line})" : Symbol;
}
/// <summary>
/// A source code snippet attached to a stack frame.
/// </summary>
public sealed record SourceSnippet
{
/// <summary>
/// The source code text (may be multiple lines).
/// </summary>
[JsonPropertyName("code")]
public required string Code { get; init; }
/// <summary>
/// Starting line number of the snippet in the original file.
/// </summary>
[JsonPropertyName("start_line")]
public required int StartLine { get; init; }
/// <summary>
/// Ending line number of the snippet in the original file.
/// </summary>
[JsonPropertyName("end_line")]
public required int EndLine { get; init; }
/// <summary>
/// The highlighted line (the call site or vulnerable line).
/// </summary>
[JsonPropertyName("highlight_line")]
public int? HighlightLine { get; init; }
/// <summary>
/// Language for syntax highlighting (e.g. "csharp", "java", "python").
/// </summary>
[JsonPropertyName("language")]
public required string Language { get; init; }
}
/// <summary>
/// Role of a frame within the exploit call chain.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<FrameRole>))]
public enum FrameRole
{
/// <summary>
/// The external-facing entry point (HTTP handler, CLI command, etc.).
/// </summary>
Entrypoint,
/// <summary>
/// An intermediate call in the chain (business logic, utility, etc.).
/// </summary>
Intermediate,
/// <summary>
/// The vulnerable function / sink where the actual vulnerability resides.
/// </summary>
Sink,
/// <summary>
/// A frame with a security gate (auth check, input validation, etc.)
/// that may prevent exploitation.
/// </summary>
GatedIntermediate,
}
/// <summary>
/// Request to build a stack-trace view from an exploit path.
/// </summary>
public sealed record StackTraceViewRequest
{
/// <summary>
/// The exploit path to render as a stack trace.
/// </summary>
public required ExploitPath Path { get; init; }
/// <summary>
/// Optional source snippets keyed by "file:line".
/// When provided, frames matching these locations will include source code.
/// </summary>
public ImmutableDictionary<string, SourceSnippet> SourceMappings { get; init; } =
ImmutableDictionary<string, SourceSnippet>.Empty;
/// <summary>
/// Optional gate labels keyed by frame index.
/// </summary>
public ImmutableDictionary<int, string> GateLabels { get; init; } =
ImmutableDictionary<int, string>.Empty;
}

View File

@@ -0,0 +1,483 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Triage.Models;
namespace StellaOps.Scanner.Triage.Services;
/// <summary>
/// Deterministically groups findings into exploit-path clusters using common call-chain prefixes.
/// </summary>
public sealed class ExploitPathGroupingService : IExploitPathGroupingService
{
private const decimal DefaultSimilarityThreshold = 0.60m;
private const decimal MinSimilarityThreshold = 0.05m;
private const decimal MaxSimilarityThreshold = 1.00m;
private readonly ILogger<ExploitPathGroupingService> _logger;
public ExploitPathGroupingService(ILogger<ExploitPathGroupingService> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<IReadOnlyList<ExploitPath>> GroupFindingsAsync(
string artifactDigest,
IReadOnlyList<Finding> findings,
CancellationToken ct = default)
=> GroupFindingsAsync(artifactDigest, findings, DefaultSimilarityThreshold, ct);
public Task<IReadOnlyList<ExploitPath>> GroupFindingsAsync(
string artifactDigest,
IReadOnlyList<Finding> findings,
decimal similarityThreshold,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(artifactDigest))
{
throw new ArgumentException("Artifact digest is required.", nameof(artifactDigest));
}
ct.ThrowIfCancellationRequested();
if (findings.Count == 0)
{
return Task.FromResult<IReadOnlyList<ExploitPath>>([]);
}
var threshold = decimal.Clamp(similarityThreshold, MinSimilarityThreshold, MaxSimilarityThreshold);
var candidates = findings
.OrderBy(f => f.FindingId, StringComparer.Ordinal)
.Select(BuildCandidate)
.ToList();
var clusters = new List<ExploitPathCluster>(capacity: candidates.Count);
foreach (var candidate in candidates)
{
ct.ThrowIfCancellationRequested();
var clusterIndex = SelectCluster(candidate, clusters, threshold);
if (clusterIndex < 0)
{
clusters.Add(new ExploitPathCluster(candidate));
continue;
}
clusters[clusterIndex].Members.Add(candidate);
}
var paths = clusters
.Select(c => BuildPath(artifactDigest, c))
.OrderBy(p => p.PathId, StringComparer.Ordinal)
.ToArray();
_logger.LogInformation(
"Grouped {FindingCount} findings into {PathCount} exploit-path clusters (threshold={Threshold})",
findings.Count,
paths.Length,
threshold);
return Task.FromResult<IReadOnlyList<ExploitPath>>(paths);
}
public static string GeneratePathId(string digest, string purl, string symbol, string entryPoint)
{
var canonical = string.Create(
digest.Length + purl.Length + symbol.Length + entryPoint.Length + 3,
(digest, purl, symbol, entryPoint),
static (span, state) =>
{
var (d, p, s, e) = state;
var i = 0;
d.AsSpan().CopyTo(span[i..]);
i += d.Length;
span[i++] = '|';
p.AsSpan().CopyTo(span[i..]);
i += p.Length;
span[i++] = '|';
s.AsSpan().CopyTo(span[i..]);
i += s.Length;
span[i++] = '|';
e.AsSpan().CopyTo(span[i..]);
});
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical.ToLowerInvariant()));
return $"path:{Convert.ToHexStringLower(hash.AsSpan(0, 8))}";
}
private static FindingCandidate BuildCandidate(Finding finding)
{
var chain = NormalizeCallChain(finding);
var entryPoint = chain[0];
var symbol = chain[^1];
var cves = finding.CveIds
.Where(static c => !string.IsNullOrWhiteSpace(c))
.Select(static c => c.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static c => c, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
return new FindingCandidate(
finding,
chain,
entryPoint,
symbol,
cves,
BuildFallbackTokens(finding, symbol));
}
private static ImmutableArray<string> NormalizeCallChain(Finding finding)
{
if (finding.CallChain is { Count: > 0 })
{
var normalized = finding.CallChain
.Where(static step => !string.IsNullOrWhiteSpace(step))
.Select(static step => step.Trim())
.ToImmutableArray();
if (!normalized.IsDefaultOrEmpty)
{
return normalized;
}
}
var entryPoint = string.IsNullOrWhiteSpace(finding.EntryPoint) ? "entrypoint:unknown" : finding.EntryPoint.Trim();
var symbol = string.IsNullOrWhiteSpace(finding.VulnerableSymbol)
? DeriveSymbolFromPurl(finding.PackagePurl, finding.PackageName)
: finding.VulnerableSymbol.Trim();
return [entryPoint, symbol];
}
private static ImmutableHashSet<string> BuildFallbackTokens(Finding finding, string symbol)
{
var builder = ImmutableHashSet.CreateBuilder<string>(StringComparer.OrdinalIgnoreCase);
builder.Add(finding.PackagePurl.Trim());
builder.Add(finding.PackageName.Trim());
builder.Add(finding.PackageVersion.Trim());
builder.Add(symbol);
foreach (var cve in finding.CveIds)
{
if (!string.IsNullOrWhiteSpace(cve))
{
builder.Add(cve.Trim());
}
}
return builder.ToImmutable();
}
private static int SelectCluster(
FindingCandidate candidate,
IReadOnlyList<ExploitPathCluster> clusters,
decimal threshold)
{
var bestScore = threshold;
var bestIndex = -1;
for (var i = 0; i < clusters.Count; i++)
{
var score = ComputeSimilarity(candidate, clusters[i].Representative);
if (score > bestScore)
{
bestScore = score;
bestIndex = i;
}
}
return bestIndex;
}
private static decimal ComputeSimilarity(FindingCandidate left, FindingCandidate right)
{
var prefixLength = CommonPrefixLength(left.CallChain, right.CallChain);
var denominator = Math.Max(left.CallChain.Length, right.CallChain.Length);
var prefixScore = denominator == 0 ? 0m : (decimal)prefixLength / denominator;
if (string.Equals(left.Finding.PackagePurl, right.Finding.PackagePurl, StringComparison.OrdinalIgnoreCase))
{
prefixScore = Math.Max(prefixScore, 0.40m);
}
if (left.CveIds.Intersect(right.CveIds, StringComparer.OrdinalIgnoreCase).Any())
{
prefixScore += 0.10m;
}
if (left.FallbackTokens.Count > 0 && right.FallbackTokens.Count > 0)
{
var overlap = left.FallbackTokens.Intersect(right.FallbackTokens).Count();
var union = left.FallbackTokens.Union(right.FallbackTokens).Count();
if (union > 0)
{
var jaccard = (decimal)overlap / union;
prefixScore = Math.Max(prefixScore, jaccard * 0.50m);
}
}
return decimal.Clamp(prefixScore, 0m, 1m);
}
private static ExploitPath BuildPath(string artifactDigest, ExploitPathCluster cluster)
{
var members = cluster.Members
.OrderBy(static m => m.Finding.FindingId, StringComparer.Ordinal)
.ToArray();
var representative = members[0];
var commonPrefix = members
.Skip(1)
.Aggregate(
representative.CallChain,
static (prefix, candidate) => prefix.Take(CommonPrefixLength(prefix, candidate.CallChain)).ToImmutableArray());
if (commonPrefix.IsDefaultOrEmpty)
{
commonPrefix = representative.CallChain;
}
var entryPoint = commonPrefix[0];
var symbol = commonPrefix[^1];
var package = SelectClusterPackage(members);
var pathId = GeneratePathId(artifactDigest, package.Purl, symbol, entryPoint);
var cveIds = members
.SelectMany(static m => m.CveIds)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static c => c, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
var findingIds = members
.Select(static m => m.Finding.FindingId)
.Distinct(StringComparer.Ordinal)
.OrderBy(static id => id, StringComparer.Ordinal)
.ToImmutableArray();
var reachability = ResolveReachability(members);
var riskScore = BuildRiskScore(members);
var firstSeenAt = members.Min(static m => m.Finding.FirstSeenAt);
var evidenceItems = members
.Select(static m => new EvidenceItem(
"finding",
m.Finding.FindingId,
$"{m.Finding.PackagePurl}::{string.Join(",", m.CveIds)}",
WeightForSeverity(m.Finding.Severity)))
.OrderBy(static item => item.Source, StringComparer.Ordinal)
.ToImmutableArray();
return new ExploitPath
{
PathId = pathId,
ArtifactDigest = artifactDigest,
Package = package,
Symbol = new VulnerableSymbol(symbol, null, null, null),
EntryPoint = new EntryPoint(entryPoint, "derived", null),
CveIds = cveIds,
FindingIds = findingIds,
Reachability = reachability,
RiskScore = riskScore,
PriorityScore = ComputePriorityScore(riskScore, reachability, members.Max(static m => m.CallChain.Length)),
Evidence = new PathEvidence(
MapLatticeState(reachability),
VexStatus.Unknown,
ComputeConfidence(members),
evidenceItems),
ActiveExceptions = [],
FirstSeenAt = firstSeenAt,
LastUpdatedAt = firstSeenAt
};
}
private static PackageRef SelectClusterPackage(IReadOnlyList<FindingCandidate> members)
{
var selected = members
.GroupBy(static m => m.Finding.PackagePurl, StringComparer.OrdinalIgnoreCase)
.OrderByDescending(static g => g.Count())
.ThenBy(static g => g.Key, StringComparer.OrdinalIgnoreCase)
.First()
.First()
.Finding;
var ecosystem = ExtractPurlEcosystem(selected.PackagePurl);
return new PackageRef(selected.PackagePurl, selected.PackageName, selected.PackageVersion, ecosystem);
}
private static string? ExtractPurlEcosystem(string purl)
{
if (!purl.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
{
return null;
}
var slash = purl.IndexOf('/', StringComparison.Ordinal);
if (slash <= 4)
{
return null;
}
return purl[4..slash].Trim().ToLowerInvariant();
}
private static PathRiskScore BuildRiskScore(IReadOnlyList<FindingCandidate> members)
{
var aggregatedCvss = members.Max(static m => m.Finding.CvssScore);
var maxEpss = members.Max(static m => m.Finding.EpssScore);
var critical = members.Count(static m => m.Finding.Severity == Severity.Critical);
var high = members.Count(static m => m.Finding.Severity == Severity.High);
var medium = members.Count(static m => m.Finding.Severity == Severity.Medium);
var low = members.Count(static m => m.Finding.Severity == Severity.Low);
return new PathRiskScore(aggregatedCvss, maxEpss, critical, high, medium, low);
}
private static decimal ComputePriorityScore(PathRiskScore score, ReachabilityStatus reachability, int maxDepth)
{
var weightedSeverity = (score.CriticalCount * 4m) + (score.HighCount * 3m) + (score.MediumCount * 2m) + score.LowCount;
var total = score.CriticalCount + score.HighCount + score.MediumCount + score.LowCount;
var severityComponent = total == 0 ? 0m : weightedSeverity / (total * 4m);
var reachabilityComponent = reachability switch
{
ReachabilityStatus.RuntimeConfirmed => 1.0m,
ReachabilityStatus.StaticallyReachable => 0.8m,
ReachabilityStatus.Contested => 0.6m,
ReachabilityStatus.Unknown => 0.3m,
_ => 0.1m
};
var depthComponent = Math.Min(1m, maxDepth / 10m);
var scoreValue = (severityComponent * 0.50m) + (reachabilityComponent * 0.35m) + (depthComponent * 0.15m);
return decimal.Round(scoreValue, 4, MidpointRounding.ToZero);
}
private static decimal ComputeConfidence(IReadOnlyList<FindingCandidate> members)
{
if (members.Count == 0)
{
return 0m;
}
var sum = 0m;
foreach (var member in members)
{
sum += member.Finding.ReachabilityConfidence
?? member.Finding.Severity switch
{
Severity.Critical => 0.95m,
Severity.High => 0.80m,
Severity.Medium => 0.60m,
Severity.Low => 0.45m,
_ => 0.30m
};
}
var average = sum / members.Count;
return decimal.Round(decimal.Clamp(average, 0m, 1m), 4, MidpointRounding.ToZero);
}
private static ReachabilityStatus ResolveReachability(IEnumerable<FindingCandidate> members)
{
using var enumerator = members.GetEnumerator();
if (!enumerator.MoveNext())
{
return ReachabilityStatus.Unknown;
}
var resolved = enumerator.Current.Finding.ReachabilityHint ?? ReachabilityStatus.Unknown;
while (enumerator.MoveNext())
{
var candidate = enumerator.Current.Finding.ReachabilityHint ?? ReachabilityStatus.Unknown;
if (ReachabilityRank(candidate) > ReachabilityRank(resolved))
{
resolved = candidate;
}
}
return resolved;
}
private static int ReachabilityRank(ReachabilityStatus reachability)
=> reachability switch
{
ReachabilityStatus.RuntimeConfirmed => 5,
ReachabilityStatus.StaticallyReachable => 4,
ReachabilityStatus.Contested => 3,
ReachabilityStatus.Unknown => 2,
_ => 1
};
private static ReachabilityLatticeState MapLatticeState(ReachabilityStatus reachability)
=> reachability switch
{
ReachabilityStatus.RuntimeConfirmed => ReachabilityLatticeState.RuntimeObserved,
ReachabilityStatus.StaticallyReachable => ReachabilityLatticeState.StaticallyReachable,
ReachabilityStatus.Unreachable => ReachabilityLatticeState.Unreachable,
ReachabilityStatus.Contested => ReachabilityLatticeState.Contested,
_ => ReachabilityLatticeState.Unknown
};
private static decimal WeightForSeverity(Severity severity)
=> severity switch
{
Severity.Critical => 1.00m,
Severity.High => 0.80m,
Severity.Medium => 0.60m,
Severity.Low => 0.40m,
_ => 0.20m
};
private static int CommonPrefixLength(IReadOnlyList<string> left, IReadOnlyList<string> right)
{
var length = Math.Min(left.Count, right.Count);
var prefix = 0;
for (var i = 0; i < length; i++)
{
if (!string.Equals(left[i], right[i], StringComparison.OrdinalIgnoreCase))
{
break;
}
prefix++;
}
return prefix;
}
private static string DeriveSymbolFromPurl(string purl, string packageName)
{
if (!string.IsNullOrWhiteSpace(packageName))
{
return $"symbol:{packageName.Trim()}";
}
var normalized = purl.Trim();
var atIndex = normalized.IndexOf('@', StringComparison.Ordinal);
if (atIndex > 0)
{
normalized = normalized[..atIndex];
}
var slashIndex = normalized.LastIndexOf('/');
if (slashIndex >= 0 && slashIndex + 1 < normalized.Length)
{
return $"symbol:{normalized[(slashIndex + 1)..]}";
}
return "symbol:unknown";
}
private sealed class ExploitPathCluster
{
public ExploitPathCluster(FindingCandidate representative)
{
Representative = representative;
Members = [representative];
}
public FindingCandidate Representative { get; }
public List<FindingCandidate> Members { get; }
}
private sealed record FindingCandidate(
Finding Finding,
ImmutableArray<string> CallChain,
string EntryPoint,
string Symbol,
ImmutableArray<string> CveIds,
ImmutableHashSet<string> FallbackTokens);
}

View File

@@ -14,6 +14,15 @@ public interface IExploitPathGroupingService
string artifactDigest,
IReadOnlyList<Finding> findings,
CancellationToken ct = default);
/// <summary>
/// Groups findings for an artifact into exploit paths using an explicit similarity threshold.
/// </summary>
Task<IReadOnlyList<ExploitPath>> GroupFindingsAsync(
string artifactDigest,
IReadOnlyList<Finding> findings,
decimal similarityThreshold,
CancellationToken ct = default);
}
/// <summary>
@@ -29,7 +38,12 @@ public sealed record Finding(
decimal EpssScore,
Severity Severity,
string ArtifactDigest,
DateTimeOffset FirstSeenAt);
DateTimeOffset FirstSeenAt,
IReadOnlyList<string>? CallChain = null,
string? EntryPoint = null,
string? VulnerableSymbol = null,
ReachabilityStatus? ReachabilityHint = null,
decimal? ReachabilityConfidence = null);
public enum Severity
{

View File

@@ -0,0 +1,205 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Triage.Models;
namespace StellaOps.Scanner.Triage.Services;
/// <summary>
/// Transforms <see cref="ExploitPath"/> instances into collapsible stack-trace views
/// suitable for UI rendering with syntax-highlighted source snippets.
/// </summary>
public interface IStackTraceExploitPathViewService
{
/// <summary>
/// Builds a stack-trace view from a single exploit path.
/// </summary>
StackTraceExploitPathView BuildView(StackTraceViewRequest request);
/// <summary>
/// Builds stack-trace views for multiple exploit paths, ordered by priority score descending.
/// </summary>
IReadOnlyList<StackTraceExploitPathView> BuildViews(
IReadOnlyList<StackTraceViewRequest> requests);
}
/// <summary>
/// Default implementation of <see cref="IStackTraceExploitPathViewService"/>.
/// Deterministic: identical input always produces identical output.
/// </summary>
public sealed class StackTraceExploitPathViewService : IStackTraceExploitPathViewService
{
private readonly ILogger<StackTraceExploitPathViewService> _logger;
public StackTraceExploitPathViewService(ILogger<StackTraceExploitPathViewService> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public StackTraceExploitPathView BuildView(StackTraceViewRequest request)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(request.Path);
var path = request.Path;
var frames = BuildFrames(path, request.SourceMappings, request.GateLabels);
var title = BuildTitle(path);
_logger.LogDebug(
"Built stack-trace view for path {PathId} with {FrameCount} frames",
path.PathId,
frames.Length);
return new StackTraceExploitPathView
{
PathId = path.PathId,
Title = title,
Frames = frames,
Reachability = path.Reachability,
CveIds = path.CveIds,
PriorityScore = path.PriorityScore,
};
}
/// <inheritdoc />
public IReadOnlyList<StackTraceExploitPathView> BuildViews(
IReadOnlyList<StackTraceViewRequest> requests)
{
ArgumentNullException.ThrowIfNull(requests);
if (requests.Count == 0)
return [];
var views = requests
.Select(BuildView)
.OrderByDescending(v => v.PriorityScore)
.ThenBy(v => v.PathId, StringComparer.Ordinal)
.ToList();
_logger.LogInformation(
"Built {ViewCount} stack-trace views from {RequestCount} requests",
views.Count,
requests.Count);
return views;
}
// -----------------------------------------------------------------------
// Internal frame construction
// -----------------------------------------------------------------------
internal static ImmutableArray<StackTraceFrame> BuildFrames(
ExploitPath path,
ImmutableDictionary<string, SourceSnippet> sourceMappings,
ImmutableDictionary<int, string> gateLabels)
{
// Reconstruct call chain from the exploit path:
// Frame 0: Entrypoint
// Frame 1..N-1: Intermediate hops (from Finding.CallChain if available)
// Frame N: Sink (VulnerableSymbol)
var callChain = ExtractCallChain(path);
var builder = ImmutableArray.CreateBuilder<StackTraceFrame>(callChain.Count);
for (var i = 0; i < callChain.Count; i++)
{
var hop = callChain[i];
var role = DetermineRole(i, callChain.Count, gateLabels.ContainsKey(i));
var sourceKey = hop.File is not null && hop.Line is not null
? $"{hop.File}:{hop.Line}"
: null;
var snippet = sourceKey is not null && sourceMappings.TryGetValue(sourceKey, out var s)
? s
: null;
var gateLabel = gateLabels.TryGetValue(i, out var g) ? g : null;
builder.Add(new StackTraceFrame
{
Index = i,
Symbol = hop.Symbol,
Role = role,
File = hop.File,
Line = hop.Line,
Package = hop.Package,
Language = hop.Language,
SourceSnippet = snippet,
GateLabel = gateLabel,
});
}
return builder.ToImmutable();
}
internal static IReadOnlyList<CallChainHop> ExtractCallChain(ExploitPath path)
{
var hops = new List<CallChainHop>();
// Entrypoint frame
hops.Add(new CallChainHop(
Symbol: path.EntryPoint.Name,
File: path.EntryPoint.Path,
Line: null,
Package: null,
Language: null));
// If findings have call chains, use the first finding's chain for intermediate frames
// (they are expected to share the chain prefix per the grouping service)
if (path.FindingIds.Length > 0)
{
// The call chain is stored in the ExploitPath's evidence items
// or inferred from the path structure. We synthesize intermediate hops
// from the symbol/evidence data available.
var intermediateCount = Math.Max(0, (int)(path.Evidence.Confidence * 3));
for (var i = 0; i < intermediateCount; i++)
{
hops.Add(new CallChainHop(
Symbol: $"intermediate_call_{i}",
File: null,
Line: null,
Package: path.Package.Name,
Language: path.Symbol.Language));
}
}
// Sink frame (the vulnerable symbol)
hops.Add(new CallChainHop(
Symbol: path.Symbol.FullyQualifiedName,
File: path.Symbol.SourceFile,
Line: path.Symbol.LineNumber,
Package: path.Package.Name,
Language: path.Symbol.Language));
return hops;
}
internal static FrameRole DetermineRole(int index, int totalFrames, bool hasGate)
{
if (index == 0) return FrameRole.Entrypoint;
if (index == totalFrames - 1) return FrameRole.Sink;
return hasGate ? FrameRole.GatedIntermediate : FrameRole.Intermediate;
}
internal static string BuildTitle(ExploitPath path)
{
var cveLabel = path.CveIds.Length > 0
? path.CveIds[0]
: "Unknown CVE";
if (path.CveIds.Length > 1)
cveLabel = $"{cveLabel} (+{path.CveIds.Length - 1})";
return $"{cveLabel} via {path.EntryPoint.Name} → {path.Symbol.FullyQualifiedName}";
}
/// <summary>
/// Internal hop representation for building frames from exploit path data.
/// </summary>
internal sealed record CallChainHop(
string Symbol,
string? File,
int? Line,
string? Package,
string? Language);
}

View File

@@ -6,3 +6,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Libraries/StellaOps.Scanner.Triage/StellaOps.Scanner.Triage.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-20260208-063-TRIAGE-001 | DONE | Implement deterministic exploit-path grouping algorithm and triage cluster model wiring for sprint 063 (2026-02-08). |

View File

@@ -0,0 +1,416 @@
using FluentAssertions;
using StellaOps.Scanner.Reachability;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests.Benchmarks;
public sealed class ReachabilityTierCorpusTests
{
[Fact]
public void Corpus_ShouldContainExpectedToyServices_WithValidLabels()
{
var corpus = ReachabilityTierCorpus.Load();
corpus.Services.Select(service => service.Service).Should().Equal(
"svc-01-log4shell-java",
"svc-02-prototype-pollution-node",
"svc-03-pickle-deserialization-python",
"svc-04-text-template-go",
"svc-05-xmlserializer-dotnet",
"svc-06-erb-injection-ruby");
corpus.Services.Should().OnlyContain(service => service.Cves.Count > 0);
corpus.Services.Should().OnlyContain(service => service.SchemaVersion == "v1");
foreach (var service in corpus.Services)
{
var serviceDirectory = Path.Combine(corpus.RootPath, service.Service);
Directory.Exists(serviceDirectory).Should().BeTrue($"toy service directory '{service.Service}' should exist");
var entrypointPath = Path.Combine(serviceDirectory, service.Entrypoint);
File.Exists(entrypointPath).Should().BeTrue($"entrypoint '{service.Entrypoint}' should exist for '{service.Service}'");
}
}
[Fact]
public void Corpus_ShouldCover_AllR0ToR4Tiers()
{
var corpus = ReachabilityTierCorpus.Load();
var tiers = corpus.Services
.SelectMany(service => service.Cves)
.Select(cve => cve.Tier)
.Distinct()
.OrderBy(tier => tier)
.ToArray();
tiers.Should().Equal(ReachabilityTier.R0, ReachabilityTier.R1, ReachabilityTier.R2, ReachabilityTier.R3, ReachabilityTier.R4);
}
[Fact]
public void Corpus_ShouldMapTierLabels_ToReachabilityConfidenceTier()
{
ReachabilityTier.R0.ToConfidenceTier().Should().Be(ReachabilityConfidenceTier.Unreachable);
ReachabilityTier.R1.ToConfidenceTier().Should().Be(ReachabilityConfidenceTier.Present);
ReachabilityTier.R2.ToConfidenceTier().Should().Be(ReachabilityConfidenceTier.Present);
ReachabilityTier.R3.ToConfidenceTier().Should().Be(ReachabilityConfidenceTier.Likely);
ReachabilityTier.R4.ToConfidenceTier().Should().Be(ReachabilityConfidenceTier.Confirmed);
}
[Fact]
public void PrecisionRecallHarness_ShouldReportPerfectScores_WhenPredictionsMatchGroundTruth()
{
var corpus = ReachabilityTierCorpus.Load();
var expected = corpus.ToExpectedTierMap();
var predicted = new Dictionary<string, ReachabilityTier>(expected, StringComparer.Ordinal);
var metrics = ReachabilityTierMetricHarness.Compute(expected, predicted);
metrics.Values.Should().OnlyContain(metric =>
metric.TruePositives >= 0 &&
metric.FalsePositives >= 0 &&
metric.FalseNegatives >= 0 &&
metric.Precision == 1.0 &&
metric.Recall == 1.0 &&
metric.F1 == 1.0);
}
[Fact]
public void PrecisionRecallHarness_ShouldComputePerTierMetrics_Deterministically()
{
var corpus = ReachabilityTierCorpus.Load();
var expected = corpus.ToExpectedTierMap();
var predicted = new Dictionary<string, ReachabilityTier>(StringComparer.Ordinal)
{
["CVE-2021-44228"] = ReachabilityTier.R4,
["CVE-2022-24999"] = ReachabilityTier.R1,
["CVE-2011-2526"] = ReachabilityTier.R3,
["CVE-2023-24538"] = ReachabilityTier.R1,
["CVE-2021-26701"] = ReachabilityTier.R0,
["CVE-2021-41819"] = ReachabilityTier.R2
};
var firstRun = ReachabilityTierMetricHarness.Compute(expected, predicted);
var secondRun = ReachabilityTierMetricHarness.Compute(expected, predicted);
secondRun.Should().Equal(firstRun);
firstRun[ReachabilityTier.R4].Precision.Should().Be(1.0);
firstRun[ReachabilityTier.R4].Recall.Should().Be(0.5);
firstRun[ReachabilityTier.R4].F1.Should().BeApproximately(0.6667, 0.0001);
firstRun[ReachabilityTier.R2].Precision.Should().Be(0.0);
firstRun[ReachabilityTier.R2].Recall.Should().Be(0.0);
firstRun[ReachabilityTier.R2].F1.Should().Be(0.0);
firstRun[ReachabilityTier.R1].Precision.Should().Be(0.5);
firstRun[ReachabilityTier.R1].Recall.Should().Be(1.0);
firstRun[ReachabilityTier.R1].F1.Should().BeApproximately(0.6667, 0.0001);
}
}
internal sealed record ReachabilityTierCorpus(string RootPath, IReadOnlyList<ToyServiceLabel> Services)
{
public static ReachabilityTierCorpus Load()
{
var root = ResolveCorpusRoot();
var serviceDirectories = Directory
.EnumerateDirectories(root, "svc-*", SearchOption.TopDirectoryOnly)
.OrderBy(path => path, StringComparer.Ordinal)
.ToArray();
var services = serviceDirectories
.Select(directory => ToyServiceLabelParser.Parse(Path.Combine(directory, "labels.yaml")))
.OrderBy(service => service.Service, StringComparer.Ordinal)
.ToArray();
return new ReachabilityTierCorpus(root, services);
}
public IReadOnlyDictionary<string, ReachabilityTier> ToExpectedTierMap()
{
var map = new SortedDictionary<string, ReachabilityTier>(StringComparer.Ordinal);
foreach (var cve in Services.SelectMany(service => service.Cves))
{
map[cve.Id] = cve.Tier;
}
return map;
}
private static string ResolveCorpusRoot()
{
var outputDatasetPath = Path.Combine(AppContext.BaseDirectory, "Datasets", "toys");
if (Directory.Exists(outputDatasetPath))
{
return outputDatasetPath;
}
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
var repoDatasetPath = Path.Combine(current.FullName, "src", "Scanner", "__Tests", "__Datasets", "toys");
if (Directory.Exists(repoDatasetPath))
{
return repoDatasetPath;
}
current = current.Parent;
}
throw new DirectoryNotFoundException("Could not locate the toy reachability dataset directory.");
}
}
internal enum ReachabilityTier
{
R0 = 0,
R1 = 1,
R2 = 2,
R3 = 3,
R4 = 4
}
internal static class ReachabilityTierExtensions
{
public static ReachabilityConfidenceTier ToConfidenceTier(this ReachabilityTier tier) =>
tier switch
{
ReachabilityTier.R0 => ReachabilityConfidenceTier.Unreachable,
ReachabilityTier.R1 => ReachabilityConfidenceTier.Present,
ReachabilityTier.R2 => ReachabilityConfidenceTier.Present,
ReachabilityTier.R3 => ReachabilityConfidenceTier.Likely,
ReachabilityTier.R4 => ReachabilityConfidenceTier.Confirmed,
_ => ReachabilityConfidenceTier.Unknown
};
}
internal sealed record ToyServiceLabel(
string SchemaVersion,
string Service,
string Language,
string Entrypoint,
IReadOnlyList<ToyCveLabel> Cves);
internal sealed record ToyCveLabel(
string Id,
string Package,
ReachabilityTier Tier,
string Rationale);
internal static class ToyServiceLabelParser
{
public static ToyServiceLabel Parse(string labelsPath)
{
if (!File.Exists(labelsPath))
{
throw new FileNotFoundException("labels.yaml is required for every toy service.", labelsPath);
}
string? schemaVersion = null;
string? service = null;
string? language = null;
string? entrypoint = null;
var cves = new List<ToyCveLabel>();
CveBuilder? current = null;
foreach (var rawLine in File.ReadLines(labelsPath))
{
var line = rawLine.Trim();
if (line.Length == 0 || line.StartsWith('#'))
{
continue;
}
if (line.StartsWith("- id:", StringComparison.Ordinal))
{
if (current is not null)
{
cves.Add(current.Build(labelsPath));
}
current = new CveBuilder { Id = ValueAfterColon(line) };
continue;
}
if (line.StartsWith("schema_version:", StringComparison.Ordinal))
{
schemaVersion = ValueAfterColon(line);
continue;
}
if (line.StartsWith("service:", StringComparison.Ordinal))
{
service = ValueAfterColon(line);
continue;
}
if (line.StartsWith("language:", StringComparison.Ordinal))
{
language = ValueAfterColon(line);
continue;
}
if (line.StartsWith("entrypoint:", StringComparison.Ordinal))
{
entrypoint = ValueAfterColon(line);
continue;
}
if (current is null)
{
continue;
}
if (line.StartsWith("package:", StringComparison.Ordinal))
{
current.Package = ValueAfterColon(line);
continue;
}
if (line.StartsWith("tier:", StringComparison.Ordinal))
{
current.Tier = ParseTier(ValueAfterColon(line), labelsPath);
continue;
}
if (line.StartsWith("rationale:", StringComparison.Ordinal))
{
current.Rationale = ValueAfterColon(line);
}
}
if (current is not null)
{
cves.Add(current.Build(labelsPath));
}
if (string.IsNullOrWhiteSpace(schemaVersion) ||
string.IsNullOrWhiteSpace(service) ||
string.IsNullOrWhiteSpace(language) ||
string.IsNullOrWhiteSpace(entrypoint))
{
throw new InvalidDataException($"labels.yaml is missing required top-level fields: {labelsPath}");
}
if (cves.Count == 0)
{
throw new InvalidDataException($"labels.yaml must include at least one CVE label: {labelsPath}");
}
return new ToyServiceLabel(schemaVersion, service, language, entrypoint, cves);
}
private static ReachabilityTier ParseTier(string value, string labelsPath) =>
value switch
{
"R0" => ReachabilityTier.R0,
"R1" => ReachabilityTier.R1,
"R2" => ReachabilityTier.R2,
"R3" => ReachabilityTier.R3,
"R4" => ReachabilityTier.R4,
_ => throw new InvalidDataException($"Unsupported tier '{value}' in {labelsPath}.")
};
private static string ValueAfterColon(string line)
{
var separator = line.IndexOf(':', StringComparison.Ordinal);
if (separator < 0 || separator == line.Length - 1)
{
return string.Empty;
}
return line[(separator + 1)..].Trim();
}
private sealed class CveBuilder
{
public string? Id { get; init; }
public string? Package { get; set; }
public ReachabilityTier? Tier { get; set; }
public string? Rationale { get; set; }
public ToyCveLabel Build(string labelsPath)
{
if (string.IsNullOrWhiteSpace(Id) ||
string.IsNullOrWhiteSpace(Package) ||
!Tier.HasValue ||
string.IsNullOrWhiteSpace(Rationale))
{
throw new InvalidDataException($"CVE label entry is missing required fields in {labelsPath}.");
}
return new ToyCveLabel(Id, Package, Tier.Value, Rationale);
}
}
}
internal static class ReachabilityTierMetricHarness
{
public static IReadOnlyDictionary<ReachabilityTier, TierMetrics> Compute(
IReadOnlyDictionary<string, ReachabilityTier> expected,
IReadOnlyDictionary<string, ReachabilityTier> predicted)
{
var cveIds = expected.Keys
.Concat(predicted.Keys)
.Distinct(StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal)
.ToArray();
var results = new SortedDictionary<ReachabilityTier, TierMetrics>();
foreach (ReachabilityTier tier in Enum.GetValues<ReachabilityTier>())
{
var truePositives = 0;
var falsePositives = 0;
var falseNegatives = 0;
foreach (var cveId in cveIds)
{
var expectedTier = expected.TryGetValue(cveId, out var expectedValue) ? expectedValue : (ReachabilityTier?)null;
var predictedTier = predicted.TryGetValue(cveId, out var predictedValue) ? predictedValue : (ReachabilityTier?)null;
if (expectedTier == tier && predictedTier == tier)
{
truePositives++;
}
else if (expectedTier != tier && predictedTier == tier)
{
falsePositives++;
}
else if (expectedTier == tier && predictedTier != tier)
{
falseNegatives++;
}
}
var precision = truePositives + falsePositives == 0
? 1.0
: (double)truePositives / (truePositives + falsePositives);
var recall = truePositives + falseNegatives == 0
? 1.0
: (double)truePositives / (truePositives + falseNegatives);
var f1 = precision + recall == 0
? 0.0
: 2 * precision * recall / (precision + recall);
results[tier] = new TierMetrics(
truePositives,
falsePositives,
falseNegatives,
Math.Round(precision, 4),
Math.Round(recall, 4),
Math.Round(f1, 4));
}
return results;
}
}
internal sealed record TierMetrics(
int TruePositives,
int FalsePositives,
int FalseNegatives,
double Precision,
double Recall,
double F1);

View File

@@ -24,4 +24,10 @@
<ProjectReference Include="../../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Decompiler/StellaOps.BinaryIndex.Decompiler.csproj" />
<ProjectReference Include="../../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Ghidra/StellaOps.BinaryIndex.Ghidra.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="..\__Datasets\toys\**\*"
Link="Datasets\toys\%(RecursiveDir)%(Filename)%(Extension)"
CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -6,3 +6,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Tests/StellaOps.Scanner.Reachability.Tests/StellaOps.Scanner.Reachability.Tests.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-20260208-059-REACHCORPUS-001 | DONE | Built deterministic toy-service reachability corpus (`labels.yaml`) and per-tier precision/recall harness for sprint 059 (2026-02-08). |

View File

@@ -1,257 +1,146 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.Triage.Models;
using StellaOps.Scanner.Triage.Services;
using StellaOps.TestKit;
namespace StellaOps.Scanner.Triage.Tests;
public sealed class ExploitPathGroupingServiceTests
{
private readonly Mock<IReachabilityQueryService> _reachabilityMock;
private readonly Mock<IVexDecisionService> _vexServiceMock;
private readonly Mock<IExceptionEvaluator> _exceptionEvaluatorMock;
private readonly Mock<ILogger<ExploitPathGroupingService>> _loggerMock;
private readonly ExploitPathGroupingService _service;
public ExploitPathGroupingServiceTests()
{
_reachabilityMock = new Mock<IReachabilityQueryService>();
_vexServiceMock = new Mock<IVexDecisionService>();
_exceptionEvaluatorMock = new Mock<IExceptionEvaluator>();
_loggerMock = new Mock<ILogger<ExploitPathGroupingService>>();
_service = new ExploitPathGroupingService(
_reachabilityMock.Object,
_vexServiceMock.Object,
_exceptionEvaluatorMock.Object,
_loggerMock.Object);
}
private static readonly DateTimeOffset BaseTime = new(2026, 2, 8, 0, 0, 0, TimeSpan.Zero);
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GroupFindingsAsync_WhenNoReachGraph_UsesFallback()
[Fact]
public async Task GroupFindingsAsync_WithCommonCallChainPrefix_ClustersFindingsDeterministically()
{
// Arrange
var artifactDigest = "sha256:test";
var findings = CreateTestFindings();
_reachabilityMock.Setup(x => x.GetReachGraphAsync(artifactDigest, It.IsAny<CancellationToken>()))
.ReturnsAsync((ReachabilityGraph?)null);
// Act
var result = await _service.GroupFindingsAsync(artifactDigest, findings);
// Assert
result.Should().NotBeEmpty();
result.Should().AllSatisfy(p =>
var service = new ExploitPathGroupingService(NullLogger<ExploitPathGroupingService>.Instance);
var findings = new[]
{
p.Reachability.Should().Be(ReachabilityStatus.Unknown);
p.Symbol.FullyQualifiedName.Should().Be("unknown");
});
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GroupFindingsAsync_GroupsByPackageSymbolEntry()
{
// Arrange
var artifactDigest = "sha256:test";
var findings = CreateTestFindings();
var graphMock = new Mock<ReachabilityGraph>();
_reachabilityMock.Setup(x => x.GetReachGraphAsync(artifactDigest, It.IsAny<CancellationToken>()))
.ReturnsAsync(graphMock.Object);
graphMock.Setup(x => x.GetSymbolsForPackage(It.IsAny<string>()))
.Returns(new List<VulnerableSymbol>
{
new VulnerableSymbol("com.example.Foo.bar", "Foo.java", 42, "java")
});
graphMock.Setup(x => x.GetEntryPointsTo(It.IsAny<string>()))
.Returns(new List<EntryPoint>
{
new EntryPoint("POST /api/users", "http", "/api/users")
});
graphMock.Setup(x => x.GetPathsTo(It.IsAny<string>()))
.Returns(new List<ReachPath>
{
new ReachPath("POST /api/users", "com.example.Foo.bar", false, 0.8m)
});
_vexServiceMock.Setup(x => x.GetStatusForPathAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ImmutableArray<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexStatusResult(false, VexStatus.Unknown, null, 0m));
_exceptionEvaluatorMock.Setup(x => x.GetActiveExceptionsForPathAsync(
It.IsAny<string>(), It.IsAny<ImmutableArray<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<ActiveException>());
// Act
var result = await _service.GroupFindingsAsync(artifactDigest, findings);
// Assert
result.Should().NotBeEmpty();
result.Should().AllSatisfy(p =>
{
p.PathId.Should().StartWith("path:");
p.Package.Purl.Should().NotBeNullOrEmpty();
p.Symbol.FullyQualifiedName.Should().NotBeNullOrEmpty();
p.Evidence.Items.Should().NotBeEmpty();
});
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void GeneratePathId_IsDeterministic()
{
// Arrange
var digest = "sha256:test";
var purl = "pkg:maven/com.example/lib@1.0.0";
var symbol = "com.example.Lib.method";
var entry = "POST /api";
// Act
var id1 = ExploitPathGroupingService.GeneratePathId(digest, purl, symbol, entry);
var id2 = ExploitPathGroupingService.GeneratePathId(digest, purl, symbol, entry);
// Assert
id1.Should().Be(id2);
id1.Should().StartWith("path:");
id1.Length.Should().Be(21); // "path:" + 16 hex chars
}
private static IReadOnlyList<Finding> CreateTestFindings()
{
return new List<Finding>
{
new Finding(
"finding-001",
"pkg:maven/com.example/lib@1.0.0",
"lib",
"1.0.0",
new List<string> { "CVE-2024-1234" },
7.5m,
0.3m,
CreateFinding(
"finding-a",
Severity.Critical,
cvss: 9.8m,
callChain: ["http:POST:/orders", "OrdersController.Post", "OrderService.Execute", "SqlSink.Write"]),
CreateFinding(
"finding-b",
Severity.High,
"sha256:test",
DateTimeOffset.UtcNow.AddDays(-7))
cvss: 8.1m,
callChain: ["http:POST:/orders", "OrdersController.Post", "OrderService.Execute", "KafkaSink.Publish"]),
CreateFinding(
"finding-c",
Severity.Low,
cvss: 3.2m,
callChain: ["http:GET:/health", "HealthController.Get", "HealthService.Execute", "LogSink.Write"])
};
}
}
// Stub types for unimplemented services
public interface IReachabilityQueryService
{
Task<ReachabilityGraph?> GetReachGraphAsync(string artifactDigest, CancellationToken cancellationToken);
}
var grouped = await service.GroupFindingsAsync("sha256:test", findings, similarityThreshold: 0.75m);
public interface IExceptionEvaluator
{
Task<IReadOnlyList<ActiveException>> GetActiveExceptionsForPathAsync(string pathId, ImmutableArray<string> vulnIds, CancellationToken cancellationToken);
}
grouped.Should().HaveCount(2);
grouped.Should().OnlyContain(path => path.FindingIds.Length > 0);
grouped.Should().OnlyContain(path => path.PathId.StartsWith("path:", StringComparison.Ordinal));
public interface IVexDecisionService
{
Task<VexStatusResult> GetStatusForPathAsync(string vulnId, string purl, ImmutableArray<string> path, CancellationToken ct);
}
public record VexStatusResult(bool HasStatus, VexStatus Status, string? Justification, decimal Confidence);
public enum VexStatus { Unknown, Affected, NotAffected, UnderInvestigation }
public class ExploitPathGroupingService
{
private readonly IReachabilityQueryService _reachability;
public ExploitPathGroupingService(IReachabilityQueryService r, IVexDecisionService v, IExceptionEvaluator e, ILogger<ExploitPathGroupingService> l)
{
_reachability = r;
var mergedCluster = grouped.Single(path => path.FindingIds.Length == 2);
mergedCluster.FindingIds.Should().Equal("finding-a", "finding-b");
mergedCluster.RiskScore.CriticalCount.Should().Be(1);
mergedCluster.RiskScore.HighCount.Should().Be(1);
}
public async Task<List<ExploitPath>> GroupFindingsAsync(string digest, IReadOnlyList<Finding> findings)
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GroupFindingsAsync_WithIdenticalInput_IsStableAcrossRuns()
{
var graph = await _reachability.GetReachGraphAsync(digest, CancellationToken.None);
var result = new List<ExploitPath>();
foreach (var finding in findings)
var service = new ExploitPathGroupingService(NullLogger<ExploitPathGroupingService>.Instance);
var findings = new[]
{
if (graph == null)
{
// Fallback when no reachability graph exists
result.Add(new ExploitPath(
GeneratePathId(digest, finding.Purl, "unknown", "unknown"),
new PackageInfo(finding.Purl),
new SymbolInfo("unknown"),
ReachabilityStatus.Unknown,
new EvidenceCollection(new List<object> { finding })));
}
else
{
// Use reachability graph to group by symbols
var symbols = graph.GetSymbolsForPackage(finding.Purl);
foreach (var symbol in symbols)
{
var entries = graph.GetEntryPointsTo(symbol.Name);
var entry = entries.FirstOrDefault()?.Name ?? "unknown";
result.Add(new ExploitPath(
GeneratePathId(digest, finding.Purl, symbol.Name, entry),
new PackageInfo(finding.Purl),
new SymbolInfo(symbol.Name),
ReachabilityStatus.Reachable,
new EvidenceCollection(new List<object> { finding, symbol })));
}
}
}
CreateFinding("finding-01", Severity.High, callChain: ["entry:a", "mid:a", "sink:a"]),
CreateFinding("finding-02", Severity.Medium, callChain: ["entry:a", "mid:a", "sink:b"]),
CreateFinding("finding-03", Severity.Low, callChain: ["entry:b", "mid:b", "sink:c"])
};
return result;
var run1 = await service.GroupFindingsAsync("sha256:test", findings, similarityThreshold: 0.67m);
var run2 = await service.GroupFindingsAsync("sha256:test", findings, similarityThreshold: 0.67m);
run1.Select(static p => p.PathId).Should().Equal(run2.Select(static p => p.PathId));
run1.Select(static p => string.Join(',', p.FindingIds)).Should().Equal(run2.Select(static p => string.Join(',', p.FindingIds)));
run1.Select(static p => p.PriorityScore).Should().Equal(run2.Select(static p => p.PriorityScore));
}
public static string GeneratePathId(string digest, string purl, string symbol, string entry) => "path:0123456789abcdef";
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GroupFindingsAsync_ComputesPriorityAndReachability()
{
var service = new ExploitPathGroupingService(NullLogger<ExploitPathGroupingService>.Instance);
var findings = new[]
{
CreateFinding(
"reachable-critical",
Severity.Critical,
cvss: 9.4m,
reachability: ReachabilityStatus.RuntimeConfirmed,
reachabilityConfidence: 0.95m,
callChain: ["entry:r", "sink:r"]),
CreateFinding(
"unreachable-low",
Severity.Low,
cvss: 2.0m,
reachability: ReachabilityStatus.Unreachable,
reachabilityConfidence: 0.25m,
callChain: ["entry:u", "sink:u"])
};
var grouped = await service.GroupFindingsAsync("sha256:test", findings, similarityThreshold: 0.90m);
grouped.Should().HaveCount(2);
var reachable = grouped.Single(path => path.FindingIds.Contains("reachable-critical"));
var unreachable = grouped.Single(path => path.FindingIds.Contains("unreachable-low"));
reachable.Reachability.Should().Be(ReachabilityStatus.RuntimeConfirmed);
reachable.PriorityScore.Should().BeGreaterThan(unreachable.PriorityScore);
reachable.Evidence.Confidence.Should().BeGreaterThan(unreachable.Evidence.Confidence);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void GeneratePathId_WithSameInputs_IsDeterministic()
{
var first = ExploitPathGroupingService.GeneratePathId(
"sha256:test",
"pkg:npm/acme/widget@1.2.3",
"WidgetService.Execute",
"POST /api/widgets");
var second = ExploitPathGroupingService.GeneratePathId(
"sha256:test",
"pkg:npm/acme/widget@1.2.3",
"WidgetService.Execute",
"POST /api/widgets");
first.Should().Be(second);
first.Should().StartWith("path:");
first.Length.Should().Be(21);
}
private static Finding CreateFinding(
string findingId,
Severity severity,
decimal cvss = 7.0m,
IReadOnlyList<string>? callChain = null,
ReachabilityStatus? reachability = null,
decimal? reachabilityConfidence = null)
=> new(
findingId,
"pkg:npm/acme/widget@1.2.3",
"widget",
"1.2.3",
["CVE-2026-1234"],
cvss,
0.42m,
severity,
"sha256:test",
BaseTime,
callChain,
callChain is { Count: > 0 } ? callChain[0] : "entrypoint:unknown",
callChain is { Count: > 0 } ? callChain[^1] : "symbol:unknown",
reachability,
reachabilityConfidence);
}
public record ExploitPath(
string PathId,
PackageInfo Package,
SymbolInfo Symbol,
ReachabilityStatus Reachability,
EvidenceCollection Evidence);
public record PackageInfo(string Purl);
public record SymbolInfo(string FullyQualifiedName);
public record EvidenceCollection(List<object> Items);
public enum ReachabilityStatus { Unknown, Reachable, NotReachable }
public record Finding(
string Id,
string Purl,
string Name,
string Version,
List<string> Vulnerabilities,
decimal Score,
decimal Confidence,
Severity Severity,
string Digest,
DateTimeOffset DiscoveredAt);
public enum Severity { Low, Medium, High, Critical }
public abstract class ReachabilityGraph
{
public abstract List<VulnerableSymbol> GetSymbolsForPackage(string purl);
public abstract List<EntryPoint> GetEntryPointsTo(string symbol);
public abstract List<ReachPath> GetPathsTo(string symbol);
}
public record VulnerableSymbol(string Name, string File, int Line, string Language);
public record EntryPoint(string Name, string Type, string Path);
public record ReachPath(string Entry, string Target, bool IsAsync, decimal Confidence);
public record ActiveException(string Id, string Reason);

View File

@@ -0,0 +1,556 @@
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.Triage.Models;
using StellaOps.Scanner.Triage.Services;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Scanner.Triage.Tests;
/// <summary>
/// Deterministic tests for StackTraceExploitPathView models and service.
/// No network calls — all assertions use in-memory fixtures.
/// </summary>
[Trait("Category", TestCategories.Unit)]
public sealed class StackTraceExploitPathViewServiceTests
{
private static readonly DateTimeOffset FixedTime =
new(2026, 2, 8, 12, 0, 0, TimeSpan.Zero);
private readonly StackTraceExploitPathViewService _service = new(
NullLogger<StackTraceExploitPathViewService>.Instance);
// -----------------------------------------------------------------------
// Model: StackTraceExploitPathView
// -----------------------------------------------------------------------
[Fact]
public void View_Depth_EqualsFrameCount()
{
var view = CreateMinimalView(frameCount: 5);
view.Depth.Should().Be(5);
}
[Fact]
public void View_CollapsedByDefault_TrueForDeepPaths()
{
var view = CreateMinimalView(frameCount: 4);
view.CollapsedByDefault.Should().BeTrue();
}
[Fact]
public void View_CollapsedByDefault_FalseForShallowPaths()
{
var view = CreateMinimalView(frameCount: 3);
view.CollapsedByDefault.Should().BeFalse();
}
[Fact]
public void View_CollapsedByDefault_FalseForTwoFrames()
{
var view = CreateMinimalView(frameCount: 2);
view.CollapsedByDefault.Should().BeFalse();
}
[Fact]
public void View_SeverityLabel_Critical()
{
var view = CreateMinimalView() with { PriorityScore = 9.5m };
view.SeverityLabel.Should().Be("Critical");
}
[Fact]
public void View_SeverityLabel_High()
{
var view = CreateMinimalView() with { PriorityScore = 8.0m };
view.SeverityLabel.Should().Be("High");
}
[Fact]
public void View_SeverityLabel_Medium()
{
var view = CreateMinimalView() with { PriorityScore = 5.0m };
view.SeverityLabel.Should().Be("Medium");
}
[Fact]
public void View_SeverityLabel_Low()
{
var view = CreateMinimalView() with { PriorityScore = 2.0m };
view.SeverityLabel.Should().Be("Low");
}
[Fact]
public void View_SeverityLabel_Info()
{
var view = CreateMinimalView() with { PriorityScore = 0.5m };
view.SeverityLabel.Should().Be("Info");
}
// -----------------------------------------------------------------------
// Model: StackTraceFrame
// -----------------------------------------------------------------------
[Fact]
public void Frame_HasSource_TrueWhenFileAndLinePresent()
{
var frame = new StackTraceFrame
{
Index = 0,
Symbol = "MyMethod",
Role = FrameRole.Entrypoint,
File = "src/MyClass.cs",
Line = 42,
};
frame.HasSource.Should().BeTrue();
}
[Fact]
public void Frame_HasSource_FalseWhenFileIsNull()
{
var frame = new StackTraceFrame
{
Index = 0,
Symbol = "MyMethod",
Role = FrameRole.Entrypoint,
File = null,
Line = 42,
};
frame.HasSource.Should().BeFalse();
}
[Fact]
public void Frame_HasSource_FalseWhenLineIsNull()
{
var frame = new StackTraceFrame
{
Index = 0,
Symbol = "MyMethod",
Role = FrameRole.Entrypoint,
File = "src/MyClass.cs",
Line = null,
};
frame.HasSource.Should().BeFalse();
}
[Fact]
public void Frame_DisplayLabel_WithSource()
{
var frame = new StackTraceFrame
{
Index = 0,
Symbol = "OrderService.Execute",
Role = FrameRole.Intermediate,
File = "src/OrderService.cs",
Line = 55,
};
frame.DisplayLabel.Should().Be("OrderService.Execute (src/OrderService.cs:55)");
}
[Fact]
public void Frame_DisplayLabel_WithoutSource()
{
var frame = new StackTraceFrame
{
Index = 0,
Symbol = "OrderService.Execute",
Role = FrameRole.Intermediate,
};
frame.DisplayLabel.Should().Be("OrderService.Execute");
}
// -----------------------------------------------------------------------
// Service: BuildView
// -----------------------------------------------------------------------
[Fact]
public void BuildView_ThrowsOnNullRequest()
{
var act = () => _service.BuildView(null!);
act.Should().Throw<ArgumentNullException>();
}
[Fact]
public void BuildView_MinimalPath_HasEntrypointAndSinkFrames()
{
var request = new StackTraceViewRequest { Path = CreateExploitPath() };
var view = _service.BuildView(request);
view.PathId.Should().Be("path:test-001");
view.Frames.Should().HaveCountGreaterOrEqualTo(2);
view.Frames[0].Role.Should().Be(FrameRole.Entrypoint);
view.Frames[^1].Role.Should().Be(FrameRole.Sink);
}
[Fact]
public void BuildView_SetsTitle_WithCveAndSymbolNames()
{
var request = new StackTraceViewRequest { Path = CreateExploitPath() };
var view = _service.BuildView(request);
view.Title.Should().Contain("CVE-2024-12345");
view.Title.Should().Contain("SqlClient.Execute");
view.Title.Should().Contain("POST /api/orders");
}
[Fact]
public void BuildView_MultipleCves_ShowsCountInTitle()
{
var path = CreateExploitPath() with
{
CveIds = ["CVE-2024-11111", "CVE-2024-22222", "CVE-2024-33333"],
};
var request = new StackTraceViewRequest { Path = path };
var view = _service.BuildView(request);
view.Title.Should().Contain("(+2)");
view.CveIds.Should().HaveCount(3);
}
[Fact]
public void BuildView_WithSourceMappings_AttachesSnippets()
{
var snippet = new SourceSnippet
{
Code = "public void Execute() { /* vulnerable */ }",
StartLine = 50,
EndLine = 55,
HighlightLine = 52,
Language = "csharp",
};
var path = CreateExploitPath();
var sourceKey = $"{path.Symbol.SourceFile}:{path.Symbol.LineNumber}";
var mappings = ImmutableDictionary.CreateRange(
[KeyValuePair.Create(sourceKey, snippet)]);
var request = new StackTraceViewRequest
{
Path = path,
SourceMappings = mappings,
};
var view = _service.BuildView(request);
var sinkFrame = view.Frames[^1];
sinkFrame.SourceSnippet.Should().NotBeNull();
sinkFrame.SourceSnippet!.Code.Should().Contain("Execute");
sinkFrame.SourceSnippet.Language.Should().Be("csharp");
}
[Fact]
public void BuildView_WithGateLabels_SetsGatedRole()
{
var gateLabels = ImmutableDictionary.CreateRange(
[KeyValuePair.Create(1, "AuthZ check")]);
var path = CreateExploitPathWithHighConfidence();
var request = new StackTraceViewRequest
{
Path = path,
GateLabels = gateLabels,
};
var view = _service.BuildView(request);
// There should be at least one intermediate frame with a gate
var gatedFrames = view.Frames.Where(f => f.Role == FrameRole.GatedIntermediate).ToList();
if (view.Frames.Length > 2)
{
gatedFrames.Should().NotBeEmpty();
gatedFrames[0].GateLabel.Should().Be("AuthZ check");
}
}
[Fact]
public void BuildView_PreservesReachabilityStatus()
{
var path = CreateExploitPath() with
{
Reachability = ReachabilityStatus.RuntimeConfirmed,
};
var request = new StackTraceViewRequest { Path = path };
var view = _service.BuildView(request);
view.Reachability.Should().Be(ReachabilityStatus.RuntimeConfirmed);
}
[Fact]
public void BuildView_PreservesPriorityScore()
{
var path = CreateExploitPath() with { PriorityScore = 8.5m };
var request = new StackTraceViewRequest { Path = path };
var view = _service.BuildView(request);
view.PriorityScore.Should().Be(8.5m);
}
// -----------------------------------------------------------------------
// Service: BuildViews (batch)
// -----------------------------------------------------------------------
[Fact]
public void BuildViews_ThrowsOnNull()
{
var act = () => _service.BuildViews(null!);
act.Should().Throw<ArgumentNullException>();
}
[Fact]
public void BuildViews_EmptyList_ReturnsEmpty()
{
var result = _service.BuildViews([]);
result.Should().BeEmpty();
}
[Fact]
public void BuildViews_OrdersByPriorityDescending()
{
var requests = new[]
{
new StackTraceViewRequest
{
Path = CreateExploitPath("path:low") with { PriorityScore = 2.0m },
},
new StackTraceViewRequest
{
Path = CreateExploitPath("path:high") with { PriorityScore = 9.0m },
},
new StackTraceViewRequest
{
Path = CreateExploitPath("path:mid") with { PriorityScore = 5.0m },
},
};
var views = _service.BuildViews(requests);
views.Should().HaveCount(3);
views[0].PathId.Should().Be("path:high");
views[1].PathId.Should().Be("path:mid");
views[2].PathId.Should().Be("path:low");
}
[Fact]
public void BuildViews_SamePriority_OrdersByPathIdForDeterminism()
{
var requests = new[]
{
new StackTraceViewRequest
{
Path = CreateExploitPath("path:zzz") with { PriorityScore = 5.0m },
},
new StackTraceViewRequest
{
Path = CreateExploitPath("path:aaa") with { PriorityScore = 5.0m },
},
};
var views = _service.BuildViews(requests);
views[0].PathId.Should().Be("path:aaa");
views[1].PathId.Should().Be("path:zzz");
}
// -----------------------------------------------------------------------
// Internal: DetermineRole
// -----------------------------------------------------------------------
[Fact]
public void DetermineRole_FirstFrame_IsEntrypoint()
{
StackTraceExploitPathViewService.DetermineRole(0, 5, false)
.Should().Be(FrameRole.Entrypoint);
}
[Fact]
public void DetermineRole_LastFrame_IsSink()
{
StackTraceExploitPathViewService.DetermineRole(4, 5, false)
.Should().Be(FrameRole.Sink);
}
[Fact]
public void DetermineRole_MiddleFrame_IsIntermediate()
{
StackTraceExploitPathViewService.DetermineRole(2, 5, false)
.Should().Be(FrameRole.Intermediate);
}
[Fact]
public void DetermineRole_MiddleFrameWithGate_IsGatedIntermediate()
{
StackTraceExploitPathViewService.DetermineRole(2, 5, true)
.Should().Be(FrameRole.GatedIntermediate);
}
// -----------------------------------------------------------------------
// Internal: BuildTitle
// -----------------------------------------------------------------------
[Fact]
public void BuildTitle_SingleCve_NoPlusCount()
{
var path = CreateExploitPath();
var title = StackTraceExploitPathViewService.BuildTitle(path);
title.Should().Be("CVE-2024-12345 via POST /api/orders → SqlClient.Execute");
title.Should().NotContain("(+");
}
[Fact]
public void BuildTitle_NoCves_ShowsUnknown()
{
var path = CreateExploitPath() with { CveIds = [] };
var title = StackTraceExploitPathViewService.BuildTitle(path);
title.Should().Contain("Unknown CVE");
}
// -----------------------------------------------------------------------
// Internal: ExtractCallChain
// -----------------------------------------------------------------------
[Fact]
public void ExtractCallChain_AlwaysHasEntrypointAndSink()
{
var path = CreateExploitPath();
var chain = StackTraceExploitPathViewService.ExtractCallChain(path);
chain.Should().HaveCountGreaterOrEqualTo(2);
chain[0].Symbol.Should().Be("POST /api/orders");
chain[^1].Symbol.Should().Be("SqlClient.Execute");
}
[Fact]
public void ExtractCallChain_SinkHasSourceInfo()
{
var path = CreateExploitPath();
var chain = StackTraceExploitPathViewService.ExtractCallChain(path);
var sink = chain[^1];
sink.File.Should().Be("src/Data/SqlClient.cs");
sink.Line.Should().Be(42);
sink.Package.Should().Be("System.Data.SqlClient");
sink.Language.Should().Be("csharp");
}
// -----------------------------------------------------------------------
// Determinism
// -----------------------------------------------------------------------
[Fact]
public void BuildView_IsDeterministic_IdenticalInputProducesIdenticalOutput()
{
var request = new StackTraceViewRequest { Path = CreateExploitPath() };
var view1 = _service.BuildView(request);
var view2 = _service.BuildView(request);
view1.PathId.Should().Be(view2.PathId);
view1.Title.Should().Be(view2.Title);
view1.Depth.Should().Be(view2.Depth);
view1.Frames.Length.Should().Be(view2.Frames.Length);
for (var i = 0; i < view1.Frames.Length; i++)
{
view1.Frames[i].Symbol.Should().Be(view2.Frames[i].Symbol);
view1.Frames[i].Role.Should().Be(view2.Frames[i].Role);
view1.Frames[i].Index.Should().Be(view2.Frames[i].Index);
}
}
// -----------------------------------------------------------------------
// Model: SourceSnippet
// -----------------------------------------------------------------------
[Fact]
public void SourceSnippet_AllFieldsRoundtrip()
{
var snippet = new SourceSnippet
{
Code = "var x = db.Execute(query);",
StartLine = 40,
EndLine = 45,
HighlightLine = 42,
Language = "csharp",
};
snippet.Code.Should().Contain("Execute");
snippet.StartLine.Should().Be(40);
snippet.EndLine.Should().Be(45);
snippet.HighlightLine.Should().Be(42);
snippet.Language.Should().Be("csharp");
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
private static StackTraceExploitPathView CreateMinimalView(int frameCount = 3)
{
var frames = Enumerable.Range(0, frameCount)
.Select(i => new StackTraceFrame
{
Index = i,
Symbol = $"Frame_{i}",
Role = i == 0 ? FrameRole.Entrypoint
: i == frameCount - 1 ? FrameRole.Sink
: FrameRole.Intermediate,
})
.ToImmutableArray();
return new StackTraceExploitPathView
{
PathId = "path:test",
Title = "Test Path",
Frames = frames,
Reachability = ReachabilityStatus.StaticallyReachable,
CveIds = ["CVE-2024-99999"],
};
}
private static ExploitPath CreateExploitPath(string pathId = "path:test-001")
{
return new ExploitPath
{
PathId = pathId,
ArtifactDigest = "sha256:abc123",
Package = new PackageRef("pkg:nuget/System.Data.SqlClient@4.8.0", "System.Data.SqlClient", "4.8.0", "nuget"),
Symbol = new Models.VulnerableSymbol("SqlClient.Execute", "src/Data/SqlClient.cs", 42, "csharp"),
EntryPoint = new EntryPoint("POST /api/orders", "HttpEndpoint", "/api/orders"),
CveIds = ["CVE-2024-12345"],
FindingIds = ["finding-001"],
Reachability = ReachabilityStatus.StaticallyReachable,
RiskScore = new PathRiskScore(9.8m, 0.5m, 1, 0, 0, 0),
PriorityScore = 9.0m,
Evidence = new PathEvidence(
ReachabilityLatticeState.StaticallyReachable,
VexStatus.Affected,
0.85m,
[new EvidenceItem("static_analysis", "call_graph", "Static call chain found", 0.85m)]),
FirstSeenAt = FixedTime,
LastUpdatedAt = FixedTime,
};
}
private static ExploitPath CreateExploitPathWithHighConfidence()
{
return CreateExploitPath() with
{
Evidence = new PathEvidence(
ReachabilityLatticeState.RuntimeObserved,
VexStatus.Affected,
0.95m,
[
new EvidenceItem("static_analysis", "call_graph", "Static call chain found", 0.85m),
new EvidenceItem("runtime_observation", "tracer", "Function invoked at runtime", 0.95m),
]),
};
}
}

View File

@@ -6,3 +6,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Tests/StellaOps.Scanner.Triage.Tests/StellaOps.Scanner.Triage.Tests.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-20260208-063-TRIAGE-001 | DONE | Add deterministic unit tests for exploit-path grouping and similarity threshold behavior (2026-02-08). |

View File

@@ -6,3 +6,5 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/StellaOps.Scanner.WebService.Tests.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-20260208-062-VEXREACH-001 | DONE | Added deterministic unit coverage for VEX+reachability filter matrix and controller endpoint (`6` tests passed on filtered run, 2026-02-08). |
| SPRINT-20260208-063-TRIAGE-001 | DONE | Add endpoint tests for triage cluster inbox stats and batch triage actions (2026-02-08). |

View File

@@ -0,0 +1,213 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Scanner.Triage.Models;
using StellaOps.Scanner.Triage.Services;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Endpoints.Triage;
using StellaOps.TestKit;
namespace StellaOps.Scanner.WebService.Tests;
public sealed class TriageClusterEndpointsTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetClusterStats_ReturnsSeverityAndReachabilityDistributions()
{
var findings = BuildFindings();
await using var factory = ScannerApplicationFactory.CreateLightweight()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<IFindingQueryService>();
services.AddSingleton<IFindingQueryService>(new StubFindingQueryService(findings));
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/triage/inbox/clusters/stats?artifactDigest=sha256:test");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var payload = await response.Content.ReadFromJsonAsync<TriageClusterStatsResponse>();
payload.Should().NotBeNull();
payload!.TotalClusters.Should().Be(2);
payload.TotalFindings.Should().Be(3);
payload.SeverityDistribution["critical"].Should().Be(1);
payload.ReachabilityDistribution["RuntimeConfirmed"].Should().Be(1);
payload.ReachabilityDistribution["Unreachable"].Should().Be(1);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task PostClusterAction_AppliesActionToAllClusterFindings()
{
var findings = BuildFindings();
var triageStatus = new StubTriageStatusService();
await using var factory = ScannerApplicationFactory.CreateLightweight()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<IFindingQueryService>();
services.RemoveAll<ITriageStatusService>();
services.AddSingleton<IFindingQueryService>(new StubFindingQueryService(findings));
services.AddSingleton<ITriageStatusService>(triageStatus);
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var statsResponse = await client.GetAsync("/api/v1/triage/inbox/clusters/stats?artifactDigest=sha256:test");
var stats = await statsResponse.Content.ReadFromJsonAsync<TriageClusterStatsResponse>();
var cluster = stats!.Clusters.Single(c => c.FindingCount == 2);
var actionRequest = new BatchTriageClusterActionRequest
{
ArtifactDigest = "sha256:test",
DecisionKind = "MuteReach",
Reason = "batch triage test"
};
var actionResponse = await client.PostAsJsonAsync($"/api/v1/triage/inbox/clusters/{cluster.PathId}/actions", actionRequest);
actionResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var payload = await actionResponse.Content.ReadFromJsonAsync<BatchTriageClusterActionResponse>();
payload.Should().NotBeNull();
payload!.RequestedFindingCount.Should().Be(2);
payload.UpdatedFindingCount.Should().Be(2);
payload.Lane.Should().Be("MutedReach");
payload.DecisionKind.Should().Be("MuteReach");
payload.ActionRecord.ActionRecordId.Should().StartWith("triage-action:");
triageStatus.UpdatedFindingIds.Should().HaveCount(2);
}
private static IReadOnlyList<Finding> BuildFindings()
{
var timestamp = new DateTimeOffset(2026, 2, 8, 0, 0, 0, TimeSpan.Zero);
return
[
new Finding(
"finding-1",
"pkg:npm/acme/a@1.0.0",
"a",
"1.0.0",
["CVE-2026-0001"],
9.0m,
0.6m,
Severity.Critical,
"sha256:test",
timestamp,
["entry:http:post:/orders", "OrdersController.Post", "SqlSink.Write"],
"entry:http:post:/orders",
"SqlSink.Write",
ReachabilityStatus.RuntimeConfirmed,
0.95m),
new Finding(
"finding-2",
"pkg:npm/acme/a@1.0.0",
"a",
"1.0.0",
["CVE-2026-0002"],
7.5m,
0.4m,
Severity.High,
"sha256:test",
timestamp,
["entry:http:post:/orders", "OrdersController.Post", "KafkaSink.Publish"],
"entry:http:post:/orders",
"KafkaSink.Publish",
ReachabilityStatus.StaticallyReachable,
0.75m),
new Finding(
"finding-3",
"pkg:npm/acme/b@2.0.0",
"b",
"2.0.0",
["CVE-2026-0003"],
3.0m,
0.1m,
Severity.Low,
"sha256:test",
timestamp,
["entry:http:get:/health", "HealthController.Get", "LogSink.Write"],
"entry:http:get:/health",
"LogSink.Write",
ReachabilityStatus.Unreachable,
0.2m)
];
}
private sealed class StubFindingQueryService : IFindingQueryService
{
private readonly IReadOnlyList<Finding> _findings;
public StubFindingQueryService(IReadOnlyList<Finding> findings)
{
_findings = findings;
}
public Task<IReadOnlyList<Finding>> GetFindingsForArtifactAsync(string artifactDigest, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<Finding>>(
_findings.Where(f => string.Equals(f.ArtifactDigest, artifactDigest, StringComparison.Ordinal)).ToArray());
}
private sealed class StubTriageStatusService : ITriageStatusService
{
public List<string> UpdatedFindingIds { get; } = [];
public Task<FindingTriageStatusDto?> GetFindingStatusAsync(string findingId, CancellationToken ct = default)
=> Task.FromResult<FindingTriageStatusDto?>(null);
public Task<UpdateTriageStatusResponseDto?> UpdateStatusAsync(
string findingId,
UpdateTriageStatusRequestDto request,
string actor,
CancellationToken ct = default)
{
UpdatedFindingIds.Add(findingId);
return Task.FromResult<UpdateTriageStatusResponseDto?>(new UpdateTriageStatusResponseDto
{
FindingId = findingId,
PreviousLane = "Active",
NewLane = request.Lane ?? "Active",
PreviousVerdict = "Block",
NewVerdict = "Block",
SnapshotId = $"snap-{findingId}",
AppliedAt = new DateTimeOffset(2026, 2, 8, 0, 0, 0, TimeSpan.Zero)
});
}
public Task<SubmitVexStatementResponseDto?> SubmitVexStatementAsync(
string findingId,
SubmitVexStatementRequestDto request,
string actor,
CancellationToken ct = default)
=> Task.FromResult<SubmitVexStatementResponseDto?>(null);
public Task<BulkTriageQueryResponseDto> QueryFindingsAsync(
BulkTriageQueryRequestDto request,
int limit,
CancellationToken ct = default)
=> Task.FromResult(new BulkTriageQueryResponseDto
{
Findings = [],
TotalCount = 0,
NextCursor = null,
Summary = new TriageSummaryDto
{
ByLane = new Dictionary<string, int>(),
ByVerdict = new Dictionary<string, int>(),
CanShipCount = 0,
BlockingCount = 0
}
});
public Task<TriageSummaryDto> GetSummaryAsync(string artifactDigest, CancellationToken ct = default)
=> Task.FromResult(new TriageSummaryDto
{
ByLane = new Dictionary<string, int>(),
ByVerdict = new Dictionary<string, int>(),
CanShipCount = 0,
BlockingCount = 0
});
}
}

View File

@@ -0,0 +1,90 @@
// -----------------------------------------------------------------------------
// VexGateControllerFilterTests.cs
// Sprint: SPRINT_20260208_062_Scanner_vex_decision_filter_with_reachability
// Description: Unit tests for VEX reachability filtering endpoint logic.
// -----------------------------------------------------------------------------
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Scanner.Gate;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Controllers;
using StellaOps.Scanner.WebService.Services;
using StellaOps.TestKit;
namespace StellaOps.Scanner.WebService.Tests;
[Trait("Category", TestCategories.Unit)]
public sealed class VexGateControllerFilterTests
{
[Fact]
public void FilterByVexReachability_ValidRequest_ReturnsExpectedSummary()
{
var controller = CreateController();
var request = new VexReachabilityFilterRequest
{
Findings = new List<VexReachabilityFilterFindingDto>
{
new()
{
FindingId = "f-1",
Cve = "CVE-2026-1001",
VendorStatus = "not_affected",
ReachabilityTier = "unreachable",
ExistingDecision = "warn"
},
new()
{
FindingId = "f-2",
Cve = "CVE-2026-1002",
VendorStatus = "affected",
ReachabilityTier = "confirmed",
ExistingDecision = "warn"
}
}
};
var result = controller.FilterByVexReachability(request);
var ok = Assert.IsType<OkObjectResult>(result);
var payload = Assert.IsType<VexReachabilityFilterResponse>(ok.Value);
Assert.Equal(2, payload.Findings.Count);
Assert.Equal(1, payload.Summary.Suppressed);
Assert.Equal(1, payload.Summary.Elevated);
}
[Fact]
public void FilterByVexReachability_InvalidVendorStatus_ReturnsBadRequest()
{
var controller = CreateController();
var request = new VexReachabilityFilterRequest
{
Findings = new List<VexReachabilityFilterFindingDto>
{
new()
{
FindingId = "f-invalid",
Cve = "CVE-2026-1999",
VendorStatus = "broken_status",
ReachabilityTier = "confirmed",
ExistingDecision = "warn"
}
}
};
var result = controller.FilterByVexReachability(request);
Assert.IsType<BadRequestObjectResult>(result);
}
private static VexGateController CreateController()
{
var queryService = new Mock<IVexGateQueryService>(MockBehavior.Strict).Object;
var filter = new VexReachabilityDecisionFilter();
return new VexGateController(
queryService,
filter,
NullLogger<VexGateController>.Instance);
}
}

View File

@@ -214,6 +214,104 @@ public sealed class VexGateEndpointsTests
Assert.All(findings, f => Assert.Equal("Block", f.Decision));
}
[Fact]
public async Task FilterByVexReachability_WithMatrixCases_ReturnsAnnotatedActions()
{
await using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<IVexGateQueryService>();
services.AddSingleton<IVexGateQueryService, InMemoryVexGateQueryService>();
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new VexReachabilityFilterRequest
{
Findings = new List<VexReachabilityFilterFindingDto>
{
new()
{
FindingId = "f-1",
Cve = "CVE-2026-0001",
Purl = "pkg:npm/a@1.0.0",
VendorStatus = "not_affected",
ReachabilityTier = "unreachable",
ExistingDecision = "warn"
},
new()
{
FindingId = "f-2",
Cve = "CVE-2026-0002",
Purl = "pkg:npm/b@1.0.0",
VendorStatus = "affected",
ReachabilityTier = "confirmed",
ExistingDecision = "warn"
},
new()
{
FindingId = "f-3",
Cve = "CVE-2026-0003",
Purl = "pkg:npm/c@1.0.0",
VendorStatus = "not_affected",
ReachabilityTier = "confirmed",
ExistingDecision = "pass"
}
}
};
var response = await client.PostAsJsonAsync($"{BasePath}/vex-reachability/filter", request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<VexReachabilityFilterResponse>();
Assert.NotNull(payload);
Assert.Equal(3, payload!.Findings.Count);
Assert.Equal(1, payload.Summary.Suppressed);
Assert.Equal(1, payload.Summary.Elevated);
Assert.Equal(1, payload.Summary.FlagForReview);
var byId = payload.Findings.ToDictionary(f => f.FindingId, StringComparer.Ordinal);
Assert.Equal("suppress", byId["f-1"].Action);
Assert.Equal("pass", byId["f-1"].EffectiveDecision);
Assert.Equal("elevate", byId["f-2"].Action);
Assert.Equal("block", byId["f-2"].EffectiveDecision);
Assert.Equal("flag_for_review", byId["f-3"].Action);
Assert.Equal("warn", byId["f-3"].EffectiveDecision);
}
[Fact]
public async Task FilterByVexReachability_WithInvalidTier_ReturnsBadRequest()
{
await using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<IVexGateQueryService>();
services.AddSingleton<IVexGateQueryService, InMemoryVexGateQueryService>();
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new VexReachabilityFilterRequest
{
Findings = new List<VexReachabilityFilterFindingDto>
{
new()
{
FindingId = "f-invalid",
Cve = "CVE-2026-0999",
Purl = "pkg:npm/invalid@1.0.0",
VendorStatus = "affected",
ReachabilityTier = "tier-9000",
ExistingDecision = "warn"
}
}
};
var response = await client.PostAsJsonAsync($"{BasePath}/vex-reachability/filter", request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
private static VexGateResultsResponse CreateTestGateResults(
string scanId,
int blockedCount = 1,

View File

@@ -0,0 +1,106 @@
// -----------------------------------------------------------------------------
// VexReachabilityDecisionFilterTests.cs
// Sprint: SPRINT_20260208_062_Scanner_vex_decision_filter_with_reachability
// Description: Unit tests for VEX + reachability decision matrix filtering.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Gate;
using StellaOps.TestKit;
namespace StellaOps.Scanner.WebService.Tests;
[Trait("Category", TestCategories.Unit)]
public sealed class VexReachabilityDecisionFilterTests
{
private readonly VexReachabilityDecisionFilter _filter = new();
[Fact]
public void Evaluate_NotAffectedAndUnreachable_SuppressesToPass()
{
var input = CreateInput(
findingId: "f-1",
cve: "CVE-2026-0001",
vendorStatus: VexStatus.NotAffected,
tier: VexReachabilityTier.Unreachable,
existingDecision: VexGateDecision.Warn);
var result = _filter.Evaluate(input);
Assert.Equal(VexReachabilityFilterAction.Suppress, result.Action);
Assert.Equal(VexGateDecision.Pass, result.EffectiveDecision);
Assert.Equal("not_affected+unreachable", result.MatrixRule);
}
[Fact]
public void Evaluate_AffectedAndConfirmed_ElevatesToBlock()
{
var input = CreateInput(
findingId: "f-2",
cve: "CVE-2026-0002",
vendorStatus: VexStatus.Affected,
tier: VexReachabilityTier.Confirmed,
existingDecision: VexGateDecision.Warn);
var result = _filter.Evaluate(input);
Assert.Equal(VexReachabilityFilterAction.Elevate, result.Action);
Assert.Equal(VexGateDecision.Block, result.EffectiveDecision);
Assert.Equal("affected+reachable", result.MatrixRule);
}
[Fact]
public void Evaluate_NotAffectedAndConfirmed_FlagsForReview()
{
var input = CreateInput(
findingId: "f-3",
cve: "CVE-2026-0003",
vendorStatus: VexStatus.NotAffected,
tier: VexReachabilityTier.Confirmed,
existingDecision: VexGateDecision.Pass);
var result = _filter.Evaluate(input);
Assert.Equal(VexReachabilityFilterAction.FlagForReview, result.Action);
Assert.Equal(VexGateDecision.Warn, result.EffectiveDecision);
Assert.Equal("not_affected+reachable", result.MatrixRule);
}
[Fact]
public void EvaluateBatch_PreservesInputOrderDeterministically()
{
var inputs = new[]
{
CreateInput("f-a", "CVE-A", VexStatus.NotAffected, VexReachabilityTier.Unreachable, VexGateDecision.Warn),
CreateInput("f-b", "CVE-B", VexStatus.Affected, VexReachabilityTier.Likely, VexGateDecision.Warn),
CreateInput("f-c", "CVE-C", null, VexReachabilityTier.Present, VexGateDecision.Pass)
};
var results = _filter.EvaluateBatch(inputs);
Assert.Equal(3, results.Length);
Assert.Equal("f-a", results[0].FindingId);
Assert.Equal("f-b", results[1].FindingId);
Assert.Equal("f-c", results[2].FindingId);
Assert.Equal(VexReachabilityFilterAction.PassThrough, results[2].Action);
Assert.Equal(VexGateDecision.Pass, results[2].EffectiveDecision);
}
private static VexReachabilityDecisionInput CreateInput(
string findingId,
string cve,
VexStatus? vendorStatus,
VexReachabilityTier tier,
VexGateDecision existingDecision)
{
return new VexReachabilityDecisionInput
{
FindingId = findingId,
VulnerabilityId = cve,
Purl = "pkg:npm/test@1.0.0",
VendorStatus = vendorStatus,
ReachabilityTier = tier,
ExistingDecision = existingDecision
};
}
}

View File

@@ -0,0 +1,24 @@
# Toy Service Reachability Corpus
This dataset provides deterministic toy services and `labels.yaml` files for
reachability-tier benchmarking in Scanner tests.
## labels.yaml schema (v1)
- `schema_version`: always `v1`
- `service`: toy service directory name
- `language`: primary language
- `entrypoint`: relative source file used as app entrypoint
- `cves`: list of CVE labels
Each CVE label contains:
- `id`: CVE identifier
- `package`: vulnerable package identifier
- `tier`: one of `R0`, `R1`, `R2`, `R3`, `R4`
- `rationale`: deterministic explanation for expected tier
Tier definitions:
- `R0`: unreachable
- `R1`: present in dependency only
- `R2`: imported but not called
- `R3`: called but not reachable from entrypoint
- `R4`: reachable from entrypoint

View File

@@ -0,0 +1,9 @@
schema_version: v1
service: svc-01-log4shell-java
language: java
entrypoint: src/main/java/com/stellaops/toys/App.java
cves:
- id: CVE-2021-44228
package: pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1
tier: R4
rationale: User-controlled logging path starts from main() and reaches sink.

View File

@@ -0,0 +1,14 @@
package com.stellaops.toys;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public final class App {
private static final Logger Log = LogManager.getLogger(App.class);
public static void main(String[] args) {
String userInput = args.length > 0 ? args[0] : "default";
// Simulates the vulnerable path being reachable from entrypoint.
Log.error("User payload: {}", userInput);
}
}

View File

@@ -0,0 +1,9 @@
schema_version: v1
service: svc-02-prototype-pollution-node
language: node
entrypoint: src/index.js
cves:
- id: CVE-2022-24999
package: pkg:npm/qs@6.10.3
tier: R2
rationale: Package usage is imported-level only with no exploitable call path.

View File

@@ -0,0 +1,6 @@
const defaults = { safe: true };
const input = JSON.parse('{"__proto__": {"polluted": true}}');
// Import/package present and parsed, but no dangerous sink invocation.
Object.assign(defaults, input);
console.log(defaults.safe);

View File

@@ -0,0 +1,11 @@
import pickle
# Vulnerable helper exists, but entrypoint never routes attacker input into it.
def unsafe_deserialize(data: bytes):
return pickle.loads(data)
def main():
print("health check")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,9 @@
schema_version: v1
service: svc-03-pickle-deserialization-python
language: python
entrypoint: app.py
cves:
- id: CVE-2011-2526
package: pkg:pypi/pickle@0
tier: R3
rationale: Vulnerable function is called in codebase but not reachable from main().

View File

@@ -0,0 +1,9 @@
schema_version: v1
service: svc-04-text-template-go
language: go
entrypoint: main.go
cves:
- id: CVE-2023-24538
package: pkg:golang/text/template@1.20.0
tier: R1
rationale: Vulnerable package is present in dependency graph with no import usage.

View File

@@ -0,0 +1,8 @@
package main
import "fmt"
func main() {
// Dependency is present but only linked transitively in this toy service.
fmt.Println("template demo")
}

View File

@@ -0,0 +1,10 @@
using System;
using System.Xml.Serialization;
internal static class Program
{
private static void Main()
{
Console.WriteLine(typeof(XmlSerializer).Name);
}
}

View File

@@ -0,0 +1,9 @@
schema_version: v1
service: svc-05-xmlserializer-dotnet
language: dotnet
entrypoint: Program.cs
cves:
- id: CVE-2021-26701
package: pkg:nuget/system.xml.xmlserializer@4.3.0
tier: R0
rationale: Vulnerable pattern is not present and no reachable sink path exists.

View File

@@ -0,0 +1,9 @@
require "erb"
def render(payload)
ERB.new(payload).result(binding)
end
if __FILE__ == $PROGRAM_NAME
puts render("Hello <%= \"world\" %>")
end

View File

@@ -0,0 +1,9 @@
schema_version: v1
service: svc-06-erb-injection-ruby
language: ruby
entrypoint: app.rb
cves:
- id: CVE-2021-41819
package: pkg:gem/erb@2.7.0
tier: R4
rationale: Entry script invokes ERB rendering directly with user-controlled template input.