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