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

@@ -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);
}