202 lines
6.9 KiB
C#
202 lines
6.9 KiB
C#
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
|
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Moq;
|
|
using StellaOps.Scanner.Reachability;
|
|
// Models are defined in the StellaOps.Scanner.Reachability namespace
|
|
using Xunit;
|
|
|
|
using StellaOps.TestKit;
|
|
namespace StellaOps.Scanner.Reachability.Tests;
|
|
|
|
/// <summary>
|
|
/// Unit tests for SubgraphExtractor.
|
|
/// Tests the bounded BFS algorithm and subgraph extraction logic.
|
|
/// </summary>
|
|
public class SubgraphExtractorTests
|
|
{
|
|
private readonly Mock<IRichGraphStore> _graphStoreMock;
|
|
private readonly Mock<IEntryPointResolver> _entryPointResolverMock;
|
|
private readonly Mock<IVulnSurfaceService> _vulnSurfaceServiceMock;
|
|
private readonly SubgraphExtractor _extractor;
|
|
private static CancellationToken TestCancellationToken => TestContext.Current.CancellationToken;
|
|
|
|
public SubgraphExtractorTests()
|
|
{
|
|
_graphStoreMock = new Mock<IRichGraphStore>();
|
|
_entryPointResolverMock = new Mock<IEntryPointResolver>();
|
|
_vulnSurfaceServiceMock = new Mock<IVulnSurfaceService>();
|
|
|
|
_extractor = new SubgraphExtractor(
|
|
_graphStoreMock.Object,
|
|
_entryPointResolverMock.Object,
|
|
_vulnSurfaceServiceMock.Object,
|
|
NullLogger<SubgraphExtractor>.Instance
|
|
);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task ResolveAsync_WithSinglePath_ReturnsCorrectSubgraph()
|
|
{
|
|
// Arrange
|
|
var graphHash = "blake3:abc123";
|
|
var buildId = "gnu-build-id:test";
|
|
var componentRef = "pkg:maven/log4j@2.14.1";
|
|
var vulnId = "CVE-2021-44228";
|
|
|
|
var graph = CreateSimpleGraph();
|
|
_graphStoreMock
|
|
.Setup(x => x.FetchGraphAsync(graphHash, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(graph);
|
|
|
|
_entryPointResolverMock
|
|
.Setup(x => x.ResolveAsync(graph, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<EntryPoint>
|
|
{
|
|
new EntryPoint("main", "main()")
|
|
});
|
|
|
|
_vulnSurfaceServiceMock
|
|
.Setup(x => x.GetAffectedSymbolsAsync(vulnId, componentRef, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<AffectedSymbol>
|
|
{
|
|
new AffectedSymbol("vulnerable", "vulnerable()", null)
|
|
});
|
|
|
|
var request = new ReachabilityResolutionRequest(
|
|
graphHash, buildId, componentRef, vulnId, "sha256:policy", ResolverOptions.Default);
|
|
|
|
// Act
|
|
var result = await _extractor.ResolveAsync(request, TestCancellationToken);
|
|
|
|
// Assert
|
|
Assert.NotNull(result);
|
|
Assert.Equal(vulnId, result.VulnId);
|
|
Assert.Equal(componentRef, result.ComponentRef);
|
|
Assert.True(result.Nodes.Count > 0);
|
|
Assert.True(result.Edges.Count > 0);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task ResolveAsync_NoReachablePath_ReturnsNull()
|
|
{
|
|
// Arrange
|
|
var graphHash = "blake3:abc123";
|
|
var buildId = "gnu-build-id:test";
|
|
var componentRef = "pkg:maven/safe-lib@1.0.0";
|
|
var vulnId = "CVE-9999-99999";
|
|
|
|
var graph = CreateDisconnectedGraph();
|
|
_graphStoreMock
|
|
.Setup(x => x.FetchGraphAsync(graphHash, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(graph);
|
|
|
|
_entryPointResolverMock
|
|
.Setup(x => x.ResolveAsync(graph, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<EntryPoint>
|
|
{
|
|
new EntryPoint("main", "main()")
|
|
});
|
|
|
|
_vulnSurfaceServiceMock
|
|
.Setup(x => x.GetAffectedSymbolsAsync(vulnId, componentRef, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<AffectedSymbol>
|
|
{
|
|
new AffectedSymbol("isolated", "isolated()", null)
|
|
});
|
|
|
|
var request = new ReachabilityResolutionRequest(
|
|
graphHash, buildId, componentRef, vulnId, "sha256:policy", ResolverOptions.Default);
|
|
|
|
// Act
|
|
var result = await _extractor.ResolveAsync(request, TestCancellationToken);
|
|
|
|
// Assert
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task ResolveAsync_DeterministicOrdering_ProducesSameHash()
|
|
{
|
|
// Arrange
|
|
var graphHash = "blake3:abc123";
|
|
var buildId = "gnu-build-id:test";
|
|
var componentRef = "pkg:maven/log4j@2.14.1";
|
|
var vulnId = "CVE-2021-44228";
|
|
|
|
var graph = CreateSimpleGraph();
|
|
_graphStoreMock
|
|
.Setup(x => x.FetchGraphAsync(graphHash, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(graph);
|
|
|
|
_entryPointResolverMock
|
|
.Setup(x => x.ResolveAsync(graph, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<EntryPoint>
|
|
{
|
|
new EntryPoint("main", "main()")
|
|
});
|
|
|
|
_vulnSurfaceServiceMock
|
|
.Setup(x => x.GetAffectedSymbolsAsync(vulnId, componentRef, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<AffectedSymbol>
|
|
{
|
|
new AffectedSymbol("vulnerable", "vulnerable()", null)
|
|
});
|
|
|
|
var request = new ReachabilityResolutionRequest(
|
|
graphHash, buildId, componentRef, vulnId, "sha256:policy", ResolverOptions.Default);
|
|
|
|
// Act
|
|
var result1 = await _extractor.ResolveAsync(request, TestCancellationToken);
|
|
var result2 = await _extractor.ResolveAsync(request, TestCancellationToken);
|
|
|
|
// Assert
|
|
Assert.NotNull(result1);
|
|
Assert.NotNull(result2);
|
|
|
|
// Both should produce same node/edge ordering
|
|
Assert.Equal(
|
|
string.Join(",", result1.Nodes.Select(n => n.Symbol)),
|
|
string.Join(",", result2.Nodes.Select(n => n.Symbol))
|
|
);
|
|
}
|
|
|
|
private RichGraphV1 CreateSimpleGraph()
|
|
{
|
|
// Simple graph: main -> process -> vulnerable
|
|
return new RichGraphV1(
|
|
GraphHash: "blake3:abc123",
|
|
ToolchainDigest: "sha256:tool123",
|
|
Nodes: new List<GraphNode>
|
|
{
|
|
new GraphNode("main", "main()", null),
|
|
new GraphNode("process", "process()", null),
|
|
new GraphNode("vulnerable", "vulnerable()", null)
|
|
},
|
|
Edges: new List<GraphEdge>
|
|
{
|
|
new GraphEdge("main", "process", null, 0.95),
|
|
new GraphEdge("process", "vulnerable", null, 0.90)
|
|
}
|
|
);
|
|
}
|
|
|
|
private RichGraphV1 CreateDisconnectedGraph()
|
|
{
|
|
// Disconnected graph: main (isolated) and vulnerable (isolated)
|
|
return new RichGraphV1(
|
|
GraphHash: "blake3:abc123",
|
|
ToolchainDigest: "sha256:tool123",
|
|
Nodes: new List<GraphNode>
|
|
{
|
|
new GraphNode("main", "main()", null),
|
|
new GraphNode("isolated", "isolated()", null)
|
|
},
|
|
Edges: new List<GraphEdge>() // No edges
|
|
);
|
|
}
|
|
}
|