partly or unimplemented features - now implemented
This commit is contained in:
@@ -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);
|
||||
@@ -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>
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
]),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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). |
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
24
src/Scanner/__Tests/__Datasets/toys/README.md
Normal file
24
src/Scanner/__Tests/__Datasets/toys/README.md
Normal 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
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
@@ -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()
|
||||
@@ -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().
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
using System.Xml.Serialization;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
private static void Main()
|
||||
{
|
||||
Console.WriteLine(typeof(XmlSerializer).Name);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,9 @@
|
||||
require "erb"
|
||||
|
||||
def render(payload)
|
||||
ERB.new(payload).result(binding)
|
||||
end
|
||||
|
||||
if __FILE__ == $PROGRAM_NAME
|
||||
puts render("Hello <%= \"world\" %>")
|
||||
end
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user