Files
git.stella-ops.org/src/Scanner/__Tests/StellaOps.Scanner.Reachability.Tests/SurfaceAwareReachabilityIntegrationTests.cs
StellaOps Bot 5fc469ad98 feat: Add VEX Status Chip component and integration tests for reachability drift detection
- Introduced `VexStatusChipComponent` to display VEX status with color coding and tooltips.
- Implemented integration tests for reachability drift detection, covering various scenarios including drift detection, determinism, and error handling.
- Enhanced `ScannerToSignalsReachabilityTests` with a null implementation of `ICallGraphSyncService` for better test isolation.
- Updated project references to include the new Reachability Drift library.
2025-12-20 01:26:42 +02:00

537 lines
20 KiB
C#

// -----------------------------------------------------------------------------
// 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;
/// <summary>
/// Integration tests for the surface-aware reachability analyzer.
/// Tests the complete flow from vulnerability input through surface query to reachability result.
/// </summary>
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<SurfaceQueryService>.Instance,
new SurfaceQueryOptions { EnableCaching = true });
_analyzer = new SurfaceAwareReachabilityAnalyzer(
_surfaceQueryService,
_graphService,
NullLogger<SurfaceAwareReachabilityAnalyzer>.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<TriggerMethodInfo>
{
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<VulnerabilityInfo>
{
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<TriggerMethodInfo>
{
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<VulnerabilityInfo>
{
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<TriggerMethodInfo>
{
new() { MethodKey = "Vulnerable.Package::DangerousMethod", MethodName = "DangerousMethod", DeclaringType = "Vulnerable.Package" }
});
// No paths configured in graph service = unreachable
var request = new SurfaceAwareReachabilityRequest
{
Vulnerabilities = new List<VulnerabilityInfo>
{
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<VulnerabilityInfo>
{
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<VulnerabilityInfo>
{
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<TriggerMethodInfo>
{
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<TriggerMethodInfo>
{
new() { MethodKey = "Lib2::DangerousMethod", MethodName = "DangerousMethod", DeclaringType = "Lib2" }
});
// No path to Lib2 = unreachable
var request = new SurfaceAwareReachabilityRequest
{
Vulnerabilities = new List<VulnerabilityInfo>
{
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<TriggerMethodInfo>
{
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<VulnerabilityInfo>
{
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
/// <summary>
/// In-memory implementation of ISurfaceRepository for testing.
/// </summary>
private sealed class InMemorySurfaceRepository : ISurfaceRepository
{
private readonly Dictionary<string, SurfaceInfo> _surfaces = new();
private readonly Dictionary<Guid, List<TriggerMethodInfo>> _triggers = new();
private readonly Dictionary<Guid, List<string>> _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<TriggerMethodInfo> triggers)
{
_triggers[surfaceId] = triggers;
}
public void AddSinks(Guid surfaceId, List<string> sinks)
{
_sinks[surfaceId] = sinks;
}
public Task<SurfaceInfo?> 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<IReadOnlyList<TriggerMethodInfo>> GetTriggersAsync(Guid surfaceId, int maxCount, CancellationToken ct = default)
{
return Task.FromResult<IReadOnlyList<TriggerMethodInfo>>(
_triggers.TryGetValue(surfaceId, out var triggers) ? triggers : new List<TriggerMethodInfo>());
}
public Task<IReadOnlyList<string>> GetSinksAsync(Guid surfaceId, CancellationToken ct = default)
{
return Task.FromResult<IReadOnlyList<string>>(
_sinks.TryGetValue(surfaceId, out var sinks) ? sinks : new List<string>());
}
public Task<bool> ExistsAsync(string cveId, string ecosystem, string packageName, string version, CancellationToken ct = default)
{
var key = $"{cveId}|{ecosystem}|{packageName}|{version}";
return Task.FromResult(_surfaces.ContainsKey(key));
}
}
/// <summary>
/// In-memory implementation of ICallGraphAccessor for testing.
/// </summary>
private sealed class InMemoryCallGraphAccessor : ICallGraphAccessor
{
private readonly HashSet<string> _entrypoints = new();
private readonly Dictionary<string, List<string>> _callees = new();
private readonly HashSet<string> _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<string>();
_callees[caller].Add(callee);
_methods.Add(caller);
_methods.Add(callee);
}
public IReadOnlyList<string> GetEntrypoints() => _entrypoints.ToList();
public IReadOnlyList<string> GetCallees(string methodKey) =>
_callees.TryGetValue(methodKey, out var callees) ? callees : new List<string>();
public bool ContainsMethod(string methodKey) => _methods.Contains(methodKey);
}
/// <summary>
/// In-memory implementation of IReachabilityGraphService for testing.
/// </summary>
private sealed class InMemoryReachabilityGraphService : IReachabilityGraphService
{
private readonly List<ReachablePath> _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<IReadOnlyList<ReachablePath>> FindPathsToSinksAsync(
ICallGraphAccessor callGraph,
IReadOnlyList<string> 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<IReadOnlyList<ReachablePath>>(matchingPaths);
}
}
#endregion
}