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

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