// ----------------------------------------------------------------------------- // SurfaceAwareReachabilityIntegrationTests.cs // Sprint: SPRINT_3700_0004_0001_reachability_integration (REACH-013) // Description: End-to-end integration tests for surface-aware reachability analysis. // ----------------------------------------------------------------------------- using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using FluentAssertions; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using StellaOps.Scanner.Reachability.Surfaces; using Xunit; namespace StellaOps.Scanner.Reachability.Tests; /// /// Integration tests for the surface-aware reachability analyzer. /// Tests the complete flow from vulnerability input through surface query to reachability result. /// public sealed class SurfaceAwareReachabilityIntegrationTests : IDisposable { private readonly InMemorySurfaceRepository _surfaceRepo; private readonly InMemoryCallGraphAccessor _callGraphAccessor; private readonly InMemoryReachabilityGraphService _graphService; private readonly SurfaceQueryService _surfaceQueryService; private readonly SurfaceAwareReachabilityAnalyzer _analyzer; private readonly IMemoryCache _cache; public SurfaceAwareReachabilityIntegrationTests() { _surfaceRepo = new InMemorySurfaceRepository(); _callGraphAccessor = new InMemoryCallGraphAccessor(); _graphService = new InMemoryReachabilityGraphService(); _cache = new MemoryCache(new MemoryCacheOptions()); _surfaceQueryService = new SurfaceQueryService( _surfaceRepo, _cache, NullLogger.Instance, new SurfaceQueryOptions { EnableCaching = true }); _analyzer = new SurfaceAwareReachabilityAnalyzer( _surfaceQueryService, _graphService, NullLogger.Instance); } public void Dispose() { _cache.Dispose(); } #region Confirmed Reachable Tests [Fact] public async Task AnalyzeAsync_WhenTriggerMethodIsReachable_ReturnsConfirmedTier() { // Arrange: Create a call graph with path to vulnerable method // Entrypoint → Controller → Service → VulnerableLib.Deserialize() _callGraphAccessor.AddEntrypoint("API.UsersController::GetUser"); _callGraphAccessor.AddEdge("API.UsersController::GetUser", "API.UserService::FetchUser"); _callGraphAccessor.AddEdge("API.UserService::FetchUser", "Newtonsoft.Json.JsonConvert::DeserializeObject"); // Add surface with trigger method var surfaceId = Guid.NewGuid(); _surfaceRepo.AddSurface(new SurfaceInfo { Id = surfaceId, CveId = "CVE-2023-1234", Ecosystem = "nuget", PackageName = "Newtonsoft.Json", VulnVersion = "12.0.1", FixedVersion = "12.0.3", ComputedAt = DateTimeOffset.UtcNow, TriggerCount = 1 }); _surfaceRepo.AddTriggers(surfaceId, new List { new() { MethodKey = "Newtonsoft.Json.JsonConvert::DeserializeObject", MethodName = "DeserializeObject", DeclaringType = "JsonConvert" } }); // Configure graph service to find path _graphService.AddReachablePath( entrypoint: "API.UsersController::GetUser", sink: "Newtonsoft.Json.JsonConvert::DeserializeObject", pathMethods: new[] { "API.UsersController::GetUser", "API.UserService::FetchUser", "Newtonsoft.Json.JsonConvert::DeserializeObject" }); var request = new SurfaceAwareReachabilityRequest { Vulnerabilities = new List { new() { CveId = "CVE-2023-1234", Ecosystem = "nuget", PackageName = "Newtonsoft.Json", Version = "12.0.1" } }, CallGraph = _callGraphAccessor }; // Act var result = await _analyzer.AnalyzeAsync(request); // Assert result.Findings.Should().HaveCount(1); var finding = result.Findings[0]; finding.ConfidenceTier.Should().Be(ReachabilityConfidenceTier.Confirmed); finding.SinkSource.Should().Be(SinkSource.Surface); finding.Witnesses.Should().NotBeEmpty(); result.ConfirmedReachable.Should().Be(1); } [Fact] public async Task AnalyzeAsync_WhenMultipleTriggerMethodsAreReachable_ReturnsMultipleWitnesses() { // Arrange: Create call graph with paths to multiple triggers _callGraphAccessor.AddEntrypoint("API.Controller::Action1"); _callGraphAccessor.AddEntrypoint("API.Controller::Action2"); _callGraphAccessor.AddEdge("API.Controller::Action1", "VulnLib::Method1"); _callGraphAccessor.AddEdge("API.Controller::Action2", "VulnLib::Method2"); var surfaceId = Guid.NewGuid(); _surfaceRepo.AddSurface(new SurfaceInfo { Id = surfaceId, CveId = "CVE-2024-5678", Ecosystem = "npm", PackageName = "vulnerable-lib", VulnVersion = "1.0.0", FixedVersion = "1.0.1", ComputedAt = DateTimeOffset.UtcNow, TriggerCount = 2 }); _surfaceRepo.AddTriggers(surfaceId, new List { new() { MethodKey = "VulnLib::Method1", MethodName = "Method1", DeclaringType = "VulnLib" }, new() { MethodKey = "VulnLib::Method2", MethodName = "Method2", DeclaringType = "VulnLib" } }); _graphService.AddReachablePath("API.Controller::Action1", "VulnLib::Method1", new[] { "API.Controller::Action1", "VulnLib::Method1" }); _graphService.AddReachablePath("API.Controller::Action2", "VulnLib::Method2", new[] { "API.Controller::Action2", "VulnLib::Method2" }); var request = new SurfaceAwareReachabilityRequest { Vulnerabilities = new List { new() { CveId = "CVE-2024-5678", Ecosystem = "npm", PackageName = "vulnerable-lib", Version = "1.0.0" } }, CallGraph = _callGraphAccessor }; // Act var result = await _analyzer.AnalyzeAsync(request); // Assert result.Findings.Should().HaveCount(1); var finding = result.Findings[0]; finding.ConfidenceTier.Should().Be(ReachabilityConfidenceTier.Confirmed); finding.Witnesses.Should().HaveCountGreaterOrEqualTo(2); } #endregion #region Unreachable Tests [Fact] public async Task AnalyzeAsync_WhenTriggerMethodNotReachable_ReturnsUnreachableTier() { // Arrange: Surface exists but no path to trigger _callGraphAccessor.AddEntrypoint("API.Controller::Action"); _callGraphAccessor.AddEdge("API.Controller::Action", "SafeLib::SafeMethod"); var surfaceId = Guid.NewGuid(); _surfaceRepo.AddSurface(new SurfaceInfo { Id = surfaceId, CveId = "CVE-2023-9999", Ecosystem = "nuget", PackageName = "Vulnerable.Package", VulnVersion = "2.0.0", FixedVersion = "2.0.1", ComputedAt = DateTimeOffset.UtcNow, TriggerCount = 1 }); _surfaceRepo.AddTriggers(surfaceId, new List { new() { MethodKey = "Vulnerable.Package::DangerousMethod", MethodName = "DangerousMethod", DeclaringType = "Vulnerable.Package" } }); // No paths configured in graph service = unreachable var request = new SurfaceAwareReachabilityRequest { Vulnerabilities = new List { new() { CveId = "CVE-2023-9999", Ecosystem = "nuget", PackageName = "Vulnerable.Package", Version = "2.0.0" } }, CallGraph = _callGraphAccessor }; // Act var result = await _analyzer.AnalyzeAsync(request); // Assert result.Findings.Should().HaveCount(1); var finding = result.Findings[0]; finding.ConfidenceTier.Should().Be(ReachabilityConfidenceTier.Unreachable); finding.SinkSource.Should().Be(SinkSource.Surface); finding.Witnesses.Should().BeEmpty(); result.Unreachable.Should().Be(1); } #endregion #region Likely Reachable (Fallback) Tests [Fact] public async Task AnalyzeAsync_WhenNoSurfaceButPackageApiCalled_ReturnsLikelyTier() { // Arrange: No surface exists, but package API is called _callGraphAccessor.AddEntrypoint("API.Controller::Action"); _callGraphAccessor.AddEdge("API.Controller::Action", "UnknownLib.Client::DoSomething"); // Configure graph service for fallback path detection _graphService.AddReachablePath("API.Controller::Action", "UnknownLib.Client::DoSomething", new[] { "API.Controller::Action", "UnknownLib.Client::DoSomething" }); // No surface - will trigger fallback mode var request = new SurfaceAwareReachabilityRequest { Vulnerabilities = new List { new() { CveId = "CVE-2024-0001", Ecosystem = "nuget", PackageName = "UnknownLib", Version = "1.0.0" } }, CallGraph = _callGraphAccessor }; // Act var result = await _analyzer.AnalyzeAsync(request); // Assert result.Findings.Should().HaveCount(1); var finding = result.Findings[0]; // Without surface, should be either Likely or Present depending on fallback analysis finding.SinkSource.Should().BeOneOf(SinkSource.PackageApi, SinkSource.FallbackAll); finding.ConfidenceTier.Should().BeOneOf( ReachabilityConfidenceTier.Likely, ReachabilityConfidenceTier.Present); } #endregion #region Present Only Tests [Fact] public async Task AnalyzeAsync_WhenNoCallGraphData_ReturnsPresentTier() { // Arrange: No surface, no call graph paths var request = new SurfaceAwareReachabilityRequest { Vulnerabilities = new List { new() { CveId = "CVE-2024-9999", Ecosystem = "npm", PackageName = "mystery-lib", Version = "0.0.1" } }, CallGraph = null // No call graph available }; // Act var result = await _analyzer.AnalyzeAsync(request); // Assert result.Findings.Should().HaveCount(1); var finding = result.Findings[0]; finding.ConfidenceTier.Should().Be(ReachabilityConfidenceTier.Present); finding.SinkSource.Should().Be(SinkSource.FallbackAll); } #endregion #region Multiple Vulnerabilities Tests [Fact] public async Task AnalyzeAsync_WithMultipleVulnerabilities_ReturnsCorrectTiersForEach() { // Arrange: Set up mixed scenario _callGraphAccessor.AddEntrypoint("API.Controller::Action"); _callGraphAccessor.AddEdge("API.Controller::Action", "Lib1::Method"); // Vuln 1: Surface + path = Confirmed var surface1 = Guid.NewGuid(); _surfaceRepo.AddSurface(new SurfaceInfo { Id = surface1, CveId = "CVE-2024-0001", Ecosystem = "nuget", PackageName = "Lib1", VulnVersion = "1.0.0", FixedVersion = "1.0.1", ComputedAt = DateTimeOffset.UtcNow, TriggerCount = 1 }); _surfaceRepo.AddTriggers(surface1, new List { new() { MethodKey = "Lib1::Method", MethodName = "Method", DeclaringType = "Lib1" } }); _graphService.AddReachablePath("API.Controller::Action", "Lib1::Method", new[] { "API.Controller::Action", "Lib1::Method" }); // Vuln 2: Surface but no path = Unreachable var surface2 = Guid.NewGuid(); _surfaceRepo.AddSurface(new SurfaceInfo { Id = surface2, CveId = "CVE-2024-0002", Ecosystem = "nuget", PackageName = "Lib2", VulnVersion = "2.0.0", FixedVersion = "2.0.1", ComputedAt = DateTimeOffset.UtcNow, TriggerCount = 1 }); _surfaceRepo.AddTriggers(surface2, new List { new() { MethodKey = "Lib2::DangerousMethod", MethodName = "DangerousMethod", DeclaringType = "Lib2" } }); // No path to Lib2 = unreachable var request = new SurfaceAwareReachabilityRequest { Vulnerabilities = new List { new() { CveId = "CVE-2024-0001", Ecosystem = "nuget", PackageName = "Lib1", Version = "1.0.0" }, new() { CveId = "CVE-2024-0002", Ecosystem = "nuget", PackageName = "Lib2", Version = "2.0.0" } }, CallGraph = _callGraphAccessor }; // Act var result = await _analyzer.AnalyzeAsync(request); // Assert result.Findings.Should().HaveCount(2); result.ConfirmedReachable.Should().Be(1); result.Unreachable.Should().Be(1); var confirmed = result.Findings.First(f => f.CveId == "CVE-2024-0001"); confirmed.ConfidenceTier.Should().Be(ReachabilityConfidenceTier.Confirmed); var unreachable = result.Findings.First(f => f.CveId == "CVE-2024-0002"); unreachable.ConfidenceTier.Should().Be(ReachabilityConfidenceTier.Unreachable); } #endregion #region Surface Caching Tests [Fact] public async Task AnalyzeAsync_CachesSurfaceQueries_DoesNotQueryTwice() { // Arrange var surfaceId = Guid.NewGuid(); _surfaceRepo.AddSurface(new SurfaceInfo { Id = surfaceId, CveId = "CVE-2024-CACHED", Ecosystem = "nuget", PackageName = "CachedLib", VulnVersion = "1.0.0", FixedVersion = "1.0.1", ComputedAt = DateTimeOffset.UtcNow, TriggerCount = 1 }); _surfaceRepo.AddTriggers(surfaceId, new List { new() { MethodKey = "CachedLib::Method", MethodName = "Method", DeclaringType = "CachedLib" } }); _callGraphAccessor.AddEntrypoint("App::Main"); _callGraphAccessor.AddEdge("App::Main", "CachedLib::Method"); _graphService.AddReachablePath("App::Main", "CachedLib::Method", new[] { "App::Main", "CachedLib::Method" }); var request = new SurfaceAwareReachabilityRequest { Vulnerabilities = new List { new() { CveId = "CVE-2024-CACHED", Ecosystem = "nuget", PackageName = "CachedLib", Version = "1.0.0" } }, CallGraph = _callGraphAccessor }; // Act: Query twice await _analyzer.AnalyzeAsync(request); var initialQueryCount = _surfaceRepo.QueryCount; await _analyzer.AnalyzeAsync(request); var finalQueryCount = _surfaceRepo.QueryCount; // Assert: Should use cache, not query again finalQueryCount.Should().Be(initialQueryCount, "second analysis should use cached surface data"); } #endregion #region Test Infrastructure /// /// In-memory implementation of ISurfaceRepository for testing. /// private sealed class InMemorySurfaceRepository : ISurfaceRepository { private readonly Dictionary _surfaces = new(); private readonly Dictionary> _triggers = new(); private readonly Dictionary> _sinks = new(); public int QueryCount { get; private set; } public void AddSurface(SurfaceInfo surface) { var key = $"{surface.CveId}|{surface.Ecosystem}|{surface.PackageName}|{surface.VulnVersion}"; _surfaces[key] = surface; } public void AddTriggers(Guid surfaceId, List triggers) { _triggers[surfaceId] = triggers; } public void AddSinks(Guid surfaceId, List sinks) { _sinks[surfaceId] = sinks; } public Task GetSurfaceAsync(string cveId, string ecosystem, string packageName, string version, CancellationToken ct = default) { QueryCount++; var key = $"{cveId}|{ecosystem}|{packageName}|{version}"; _surfaces.TryGetValue(key, out var surface); return Task.FromResult(surface); } public Task> GetTriggersAsync(Guid surfaceId, int maxCount, CancellationToken ct = default) { return Task.FromResult>( _triggers.TryGetValue(surfaceId, out var triggers) ? triggers : new List()); } public Task> GetSinksAsync(Guid surfaceId, CancellationToken ct = default) { return Task.FromResult>( _sinks.TryGetValue(surfaceId, out var sinks) ? sinks : new List()); } public Task ExistsAsync(string cveId, string ecosystem, string packageName, string version, CancellationToken ct = default) { var key = $"{cveId}|{ecosystem}|{packageName}|{version}"; return Task.FromResult(_surfaces.ContainsKey(key)); } } /// /// In-memory implementation of ICallGraphAccessor for testing. /// private sealed class InMemoryCallGraphAccessor : ICallGraphAccessor { private readonly HashSet _entrypoints = new(); private readonly Dictionary> _callees = new(); private readonly HashSet _methods = new(); public void AddEntrypoint(string methodKey) { _entrypoints.Add(methodKey); _methods.Add(methodKey); } public void AddEdge(string caller, string callee) { if (!_callees.ContainsKey(caller)) _callees[caller] = new List(); _callees[caller].Add(callee); _methods.Add(caller); _methods.Add(callee); } public IReadOnlyList GetEntrypoints() => _entrypoints.ToList(); public IReadOnlyList GetCallees(string methodKey) => _callees.TryGetValue(methodKey, out var callees) ? callees : new List(); public bool ContainsMethod(string methodKey) => _methods.Contains(methodKey); } /// /// In-memory implementation of IReachabilityGraphService for testing. /// private sealed class InMemoryReachabilityGraphService : IReachabilityGraphService { private readonly List _paths = new(); public void AddReachablePath(string entrypoint, string sink, string[] pathMethods) { _paths.Add(new ReachablePath { EntrypointMethodKey = entrypoint, SinkMethodKey = sink, PathLength = pathMethods.Length, PathMethodKeys = pathMethods.ToList() }); } public Task> FindPathsToSinksAsync( ICallGraphAccessor callGraph, IReadOnlyList sinkMethodKeys, CancellationToken cancellationToken = default) { // Return paths that match any of the requested sinks var matchingPaths = _paths .Where(p => sinkMethodKeys.Contains(p.SinkMethodKey)) .ToList(); return Task.FromResult>(matchingPaths); } } #endregion }