- Implement ProofChainTestFixture for PostgreSQL-backed integration tests. - Create StellaOps.Integration.ProofChain project with necessary dependencies. - Add ReachabilityIntegrationTests to validate call graph extraction and reachability analysis. - Introduce ReachabilityTestFixture for managing corpus and fixture paths. - Establish StellaOps.Integration.Reachability project with required references. - Develop UnknownsWorkflowTests to cover the unknowns lifecycle: detection, ranking, escalation, and resolution. - Create StellaOps.Integration.Unknowns project with dependencies for unknowns workflow.
281 lines
9.8 KiB
C#
281 lines
9.8 KiB
C#
// -----------------------------------------------------------------------------
|
|
// ReachabilityIntegrationTests.cs
|
|
// Sprint: SPRINT_3500_0004_0003_integration_tests_corpus
|
|
// Task: T2 - Reachability Integration Tests
|
|
// Description: End-to-end tests for call graph extraction and reachability analysis
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using System.Text.Json;
|
|
using FluentAssertions;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.Integration.Reachability;
|
|
|
|
/// <summary>
|
|
/// End-to-end integration tests for reachability workflow.
|
|
/// Tests: call graph extraction → entrypoint discovery → reachability analysis
|
|
/// → explanation output → graph attestation signing.
|
|
/// </summary>
|
|
public class ReachabilityIntegrationTests : IClassFixture<ReachabilityTestFixture>
|
|
{
|
|
private readonly ReachabilityTestFixture _fixture;
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true
|
|
};
|
|
|
|
public ReachabilityIntegrationTests(ReachabilityTestFixture fixture)
|
|
{
|
|
_fixture = fixture;
|
|
}
|
|
|
|
#region T2-AC1: Test .NET call graph extraction
|
|
|
|
[Fact]
|
|
public async Task DotNetCallGraph_ExtractsNodes_FromCorpusFixture()
|
|
{
|
|
// Arrange
|
|
var corpusPath = _fixture.GetCorpusPath("dotnet");
|
|
var callGraphPath = Path.Combine(corpusPath, "callgraph.static.json");
|
|
|
|
// Act - Load and parse the call graph
|
|
var callGraphJson = await File.ReadAllTextAsync(callGraphPath);
|
|
var callGraph = JsonSerializer.Deserialize<CallGraphModel>(callGraphJson, JsonOptions);
|
|
|
|
// Assert
|
|
callGraph.Should().NotBeNull();
|
|
callGraph!.Nodes.Should().NotBeEmpty();
|
|
callGraph.Edges.Should().NotBeEmpty();
|
|
callGraph.Nodes.Should().Contain(n => n.IsEntrypoint == true);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DotNetCallGraph_IdentifiesEntrypoints_ForKestrelApp()
|
|
{
|
|
// Arrange
|
|
var corpusPath = _fixture.GetCorpusPath("dotnet");
|
|
var callGraphPath = Path.Combine(corpusPath, "callgraph.static.json");
|
|
var callGraphJson = await File.ReadAllTextAsync(callGraphPath);
|
|
var callGraph = JsonSerializer.Deserialize<CallGraphModel>(callGraphJson, JsonOptions);
|
|
|
|
// Act
|
|
var entrypoints = callGraph!.Nodes.Where(n => n.IsEntrypoint == true).ToList();
|
|
|
|
// Assert
|
|
entrypoints.Should().NotBeEmpty("Kestrel apps should have HTTP entrypoints");
|
|
entrypoints.Should().Contain(e =>
|
|
e.Symbol?.Contains("Controller", StringComparison.OrdinalIgnoreCase) == true ||
|
|
e.Symbol?.Contains("Endpoint", StringComparison.OrdinalIgnoreCase) == true ||
|
|
e.Symbol?.Contains("Handler", StringComparison.OrdinalIgnoreCase) == true);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region T2-AC2: Test Java call graph extraction
|
|
|
|
[Fact]
|
|
public async Task JavaCallGraph_ExtractsNodes_FromCorpusFixture()
|
|
{
|
|
// Arrange - Java corpus may not exist, skip if missing
|
|
var corpusPath = _fixture.GetCorpusPath("java");
|
|
var callGraphPath = Path.Combine(corpusPath, "callgraph.static.json");
|
|
|
|
if (!File.Exists(callGraphPath))
|
|
{
|
|
// Skip test if Java corpus not available
|
|
return;
|
|
}
|
|
|
|
// Act
|
|
var callGraphJson = await File.ReadAllTextAsync(callGraphPath);
|
|
var callGraph = JsonSerializer.Deserialize<CallGraphModel>(callGraphJson, JsonOptions);
|
|
|
|
// Assert
|
|
callGraph.Should().NotBeNull();
|
|
callGraph!.Nodes.Should().NotBeEmpty();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region T2-AC3: Test entrypoint discovery
|
|
|
|
[Fact]
|
|
public async Task EntrypointDiscovery_FindsWebEntrypoints_InDotNetCorpus()
|
|
{
|
|
// Arrange
|
|
var corpusPath = _fixture.GetCorpusPath("dotnet");
|
|
var callGraphPath = Path.Combine(corpusPath, "callgraph.static.json");
|
|
var callGraphJson = await File.ReadAllTextAsync(callGraphPath);
|
|
var callGraph = JsonSerializer.Deserialize<CallGraphModel>(callGraphJson, JsonOptions);
|
|
|
|
// Act
|
|
var entrypoints = callGraph!.Nodes.Where(n => n.IsEntrypoint == true).ToList();
|
|
var webEntrypoints = entrypoints.Where(e =>
|
|
e.Symbol?.Contains("Get", StringComparison.OrdinalIgnoreCase) == true ||
|
|
e.Symbol?.Contains("Post", StringComparison.OrdinalIgnoreCase) == true ||
|
|
e.Symbol?.Contains("Handle", StringComparison.OrdinalIgnoreCase) == true).ToList();
|
|
|
|
// Assert
|
|
webEntrypoints.Should().NotBeEmpty("Web applications should have HTTP handler entrypoints");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region T2-AC4: Test reachability computation
|
|
|
|
[Fact]
|
|
public async Task ReachabilityComputation_FindsPath_ToVulnerableFunction()
|
|
{
|
|
// Arrange
|
|
var corpusPath = _fixture.GetCorpusPath("dotnet");
|
|
var groundTruthPath = Path.Combine(corpusPath, "ground-truth.json");
|
|
var groundTruthJson = await File.ReadAllTextAsync(groundTruthPath);
|
|
var groundTruth = JsonSerializer.Deserialize<GroundTruthModel>(groundTruthJson, JsonOptions);
|
|
|
|
// Assert
|
|
groundTruth.Should().NotBeNull();
|
|
groundTruth!.Paths.Should().NotBeEmpty("Ground truth should contain reachability paths");
|
|
|
|
// Verify at least one path is marked as reachable
|
|
var reachablePaths = groundTruth.Paths.Where(p => p.Reachable).ToList();
|
|
reachablePaths.Should().NotBeEmpty("At least one vulnerability should be reachable");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReachabilityComputation_DistinguishesReachableFromUnreachable()
|
|
{
|
|
// Arrange
|
|
var corpusPath = _fixture.GetCorpusPath("dotnet");
|
|
var groundTruthPath = Path.Combine(corpusPath, "ground-truth.json");
|
|
var groundTruthJson = await File.ReadAllTextAsync(groundTruthPath);
|
|
var groundTruth = JsonSerializer.Deserialize<GroundTruthModel>(groundTruthJson, JsonOptions);
|
|
|
|
// Assert
|
|
groundTruth.Should().NotBeNull();
|
|
|
|
// Check that reachable paths have non-empty call chains
|
|
foreach (var path in groundTruth!.Paths.Where(p => p.Reachable))
|
|
{
|
|
path.CallChain.Should().NotBeEmpty(
|
|
"Reachable paths must have call chain evidence");
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region T2-AC5: Test reachability explanation output
|
|
|
|
[Fact]
|
|
public async Task ReachabilityExplanation_ContainsCallPath_ForReachableVuln()
|
|
{
|
|
// Arrange
|
|
var corpusPath = _fixture.GetCorpusPath("dotnet");
|
|
var groundTruthPath = Path.Combine(corpusPath, "ground-truth.json");
|
|
var groundTruthJson = await File.ReadAllTextAsync(groundTruthPath);
|
|
var groundTruth = JsonSerializer.Deserialize<GroundTruthModel>(groundTruthJson, JsonOptions);
|
|
|
|
// Act
|
|
var reachablePath = groundTruth!.Paths.FirstOrDefault(p => p.Reachable);
|
|
|
|
// Assert
|
|
reachablePath.Should().NotBeNull("Should have at least one reachable path");
|
|
reachablePath!.CallChain.Should().HaveCountGreaterThan(1,
|
|
"Call chain should show path from entrypoint to vulnerable code");
|
|
reachablePath.Confidence.Should().BeGreaterThan(0,
|
|
"Reachable paths should have confidence > 0");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReachabilityExplanation_IncludesConfidenceTier()
|
|
{
|
|
// Arrange
|
|
var corpusPath = _fixture.GetCorpusPath("dotnet");
|
|
var groundTruthPath = Path.Combine(corpusPath, "ground-truth.json");
|
|
var groundTruthJson = await File.ReadAllTextAsync(groundTruthPath);
|
|
var groundTruth = JsonSerializer.Deserialize<GroundTruthModel>(groundTruthJson, JsonOptions);
|
|
|
|
// Assert
|
|
foreach (var path in groundTruth!.Paths.Where(p => p.Reachable))
|
|
{
|
|
path.Tier.Should().NotBeNullOrEmpty(
|
|
"Reachable paths should have a confidence tier (confirmed/likely/present)");
|
|
path.Tier.Should().BeOneOf("confirmed", "likely", "present",
|
|
"Tier should be one of the defined values");
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region T2-AC6: Test graph attestation signing
|
|
|
|
[Fact]
|
|
public async Task GraphAttestation_HasValidVexFile_InCorpus()
|
|
{
|
|
// Arrange
|
|
var corpusPath = _fixture.GetCorpusPath("dotnet");
|
|
var vexPath = Path.Combine(corpusPath, "vex.openvex.json");
|
|
|
|
// Act
|
|
var vexExists = File.Exists(vexPath);
|
|
|
|
// Assert
|
|
vexExists.Should().BeTrue("Corpus should include VEX attestation file");
|
|
|
|
if (vexExists)
|
|
{
|
|
var vexJson = await File.ReadAllTextAsync(vexPath);
|
|
var vex = JsonSerializer.Deserialize<VexDocument>(vexJson, JsonOptions);
|
|
|
|
vex.Should().NotBeNull();
|
|
vex!.Context.Should().Contain("openvex");
|
|
vex.Statements.Should().NotBeEmpty();
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region DTOs
|
|
|
|
private sealed record CallGraphModel(
|
|
IReadOnlyList<CallGraphNode> Nodes,
|
|
IReadOnlyList<CallGraphEdge> Edges,
|
|
string? Version,
|
|
string? Language);
|
|
|
|
private sealed record CallGraphNode(
|
|
string NodeId,
|
|
string? Symbol,
|
|
string? File,
|
|
int? Line,
|
|
bool? IsEntrypoint,
|
|
bool? IsSink);
|
|
|
|
private sealed record CallGraphEdge(
|
|
string SourceId,
|
|
string TargetId,
|
|
string? CallKind);
|
|
|
|
private sealed record GroundTruthModel(
|
|
string CveId,
|
|
string? Language,
|
|
IReadOnlyList<ReachabilityPath> Paths);
|
|
|
|
private sealed record ReachabilityPath(
|
|
string VulnerableFunction,
|
|
bool Reachable,
|
|
IReadOnlyList<string> CallChain,
|
|
double Confidence,
|
|
string? Tier);
|
|
|
|
private sealed record VexDocument(
|
|
string Context,
|
|
IReadOnlyList<VexStatement> Statements);
|
|
|
|
private sealed record VexStatement(
|
|
string Vulnerability,
|
|
string Status,
|
|
string? Justification);
|
|
|
|
#endregion
|
|
}
|