tests fixes and sprints work
This commit is contained in:
@@ -0,0 +1,465 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DependencyReachabilityIntegrationTests.cs
|
||||
// Sprint: SPRINT_20260119_022_Scanner_dependency_reachability
|
||||
// Task: TASK-022-012 - Integration tests and accuracy measurement
|
||||
// Description: Integration tests using realistic SBOM structures from npm, Maven, and Python
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Concelier.SbomIntegration.Parsing;
|
||||
using StellaOps.Scanner.Reachability.Dependencies;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests using realistic SBOM structures to validate reachability inference accuracy.
|
||||
/// </summary>
|
||||
public sealed class DependencyReachabilityIntegrationTests
|
||||
{
|
||||
private readonly ParsedSbomParser _parser;
|
||||
|
||||
public DependencyReachabilityIntegrationTests()
|
||||
{
|
||||
var loggerMock = new Mock<ILogger<ParsedSbomParser>>();
|
||||
_parser = new ParsedSbomParser(loggerMock.Object);
|
||||
}
|
||||
|
||||
#region npm Project Tests
|
||||
|
||||
// Sprint: SPRINT_20260119_022 TASK-022-012 - npm project with deep dependencies
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Analyze_NpmProjectWithDeepDependencies_TracksTransitiveReachability()
|
||||
{
|
||||
// Arrange - Realistic npm project with lodash -> underscore chain
|
||||
var sbomJson = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"component": {
|
||||
"type": "application",
|
||||
"name": "my-web-app",
|
||||
"version": "1.0.0",
|
||||
"bom-ref": "pkg:npm/my-web-app@1.0.0"
|
||||
}
|
||||
},
|
||||
"components": [
|
||||
{"type": "library", "bom-ref": "pkg:npm/express@4.18.2", "name": "express", "version": "4.18.2", "purl": "pkg:npm/express@4.18.2"},
|
||||
{"type": "library", "bom-ref": "pkg:npm/body-parser@1.20.2", "name": "body-parser", "version": "1.20.2", "purl": "pkg:npm/body-parser@1.20.2"},
|
||||
{"type": "library", "bom-ref": "pkg:npm/bytes@3.1.2", "name": "bytes", "version": "3.1.2", "purl": "pkg:npm/bytes@3.1.2"},
|
||||
{"type": "library", "bom-ref": "pkg:npm/depd@2.0.0", "name": "depd", "version": "2.0.0", "purl": "pkg:npm/depd@2.0.0"},
|
||||
{"type": "library", "bom-ref": "pkg:npm/jest@29.7.0", "name": "jest", "version": "29.7.0", "purl": "pkg:npm/jest@29.7.0", "scope": "optional"}
|
||||
],
|
||||
"dependencies": [
|
||||
{"ref": "pkg:npm/my-web-app@1.0.0", "dependsOn": ["pkg:npm/express@4.18.2", "pkg:npm/jest@29.7.0"]},
|
||||
{"ref": "pkg:npm/express@4.18.2", "dependsOn": ["pkg:npm/body-parser@1.20.2"]},
|
||||
{"ref": "pkg:npm/body-parser@1.20.2", "dependsOn": ["pkg:npm/bytes@3.1.2", "pkg:npm/depd@2.0.0"]}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(sbomJson));
|
||||
var parsedSbom = await _parser.ParseAsync(stream, SbomFormat.CycloneDX);
|
||||
|
||||
var policy = new ReachabilityPolicy
|
||||
{
|
||||
ScopeHandling = new ReachabilityScopePolicy
|
||||
{
|
||||
IncludeRuntime = true,
|
||||
IncludeOptional = OptionalDependencyHandling.AsPotentiallyReachable
|
||||
}
|
||||
};
|
||||
|
||||
var combiner = new ReachGraphReachabilityCombiner();
|
||||
|
||||
// Act
|
||||
var report = combiner.Analyze(parsedSbom, callGraph: null, policy);
|
||||
|
||||
// Assert - Verify transitive dependencies are reachable
|
||||
report.ComponentReachability["pkg:npm/express@4.18.2"].Should().Be(ReachabilityStatus.Reachable);
|
||||
report.ComponentReachability["pkg:npm/body-parser@1.20.2"].Should().Be(ReachabilityStatus.Reachable);
|
||||
report.ComponentReachability["pkg:npm/bytes@3.1.2"].Should().Be(ReachabilityStatus.Reachable);
|
||||
report.ComponentReachability["pkg:npm/depd@2.0.0"].Should().Be(ReachabilityStatus.Reachable);
|
||||
|
||||
// Test dependency (optional scope) should be potentially reachable
|
||||
report.ComponentReachability["pkg:npm/jest@29.7.0"].Should().Be(ReachabilityStatus.PotentiallyReachable);
|
||||
|
||||
// Verify statistics
|
||||
report.Statistics.TotalComponents.Should().BeGreaterThanOrEqualTo(5);
|
||||
report.Statistics.ReachableComponents.Should().BeGreaterThanOrEqualTo(4);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Java/Maven Project Tests
|
||||
|
||||
// Sprint: SPRINT_20260119_022 TASK-022-012 - Maven project with transitive dependencies
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Analyze_MavenProjectWithTransitiveDependencies_TracksAllPaths()
|
||||
{
|
||||
// Arrange - Realistic Maven project structure with Spring Boot
|
||||
var sbomJson = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"component": {
|
||||
"type": "application",
|
||||
"name": "spring-boot-app",
|
||||
"version": "3.2.0",
|
||||
"bom-ref": "pkg:maven/com.example/spring-boot-app@3.2.0"
|
||||
}
|
||||
},
|
||||
"components": [
|
||||
{"type": "library", "bom-ref": "pkg:maven/org.springframework.boot/spring-boot-starter-web@3.2.0", "name": "spring-boot-starter-web", "version": "3.2.0", "purl": "pkg:maven/org.springframework.boot/spring-boot-starter-web@3.2.0"},
|
||||
{"type": "library", "bom-ref": "pkg:maven/org.springframework/spring-web@6.1.0", "name": "spring-web", "version": "6.1.0", "purl": "pkg:maven/org.springframework/spring-web@6.1.0"},
|
||||
{"type": "library", "bom-ref": "pkg:maven/org.springframework/spring-core@6.1.0", "name": "spring-core", "version": "6.1.0", "purl": "pkg:maven/org.springframework/spring-core@6.1.0"},
|
||||
{"type": "library", "bom-ref": "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.16.0", "name": "jackson-databind", "version": "2.16.0", "purl": "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.16.0"},
|
||||
{"type": "library", "bom-ref": "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.16.0", "name": "jackson-core", "version": "2.16.0", "purl": "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.16.0"},
|
||||
{"type": "library", "bom-ref": "pkg:maven/org.junit.jupiter/junit-jupiter@5.10.0", "name": "junit-jupiter", "version": "5.10.0", "purl": "pkg:maven/org.junit.jupiter/junit-jupiter@5.10.0", "scope": "optional"}
|
||||
],
|
||||
"dependencies": [
|
||||
{"ref": "pkg:maven/com.example/spring-boot-app@3.2.0", "dependsOn": ["pkg:maven/org.springframework.boot/spring-boot-starter-web@3.2.0", "pkg:maven/org.junit.jupiter/junit-jupiter@5.10.0"]},
|
||||
{"ref": "pkg:maven/org.springframework.boot/spring-boot-starter-web@3.2.0", "dependsOn": ["pkg:maven/org.springframework/spring-web@6.1.0", "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.16.0"]},
|
||||
{"ref": "pkg:maven/org.springframework/spring-web@6.1.0", "dependsOn": ["pkg:maven/org.springframework/spring-core@6.1.0"]},
|
||||
{"ref": "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.16.0", "dependsOn": ["pkg:maven/com.fasterxml.jackson.core/jackson-core@2.16.0"]}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(sbomJson));
|
||||
var parsedSbom = await _parser.ParseAsync(stream, SbomFormat.CycloneDX);
|
||||
|
||||
var combiner = new ReachGraphReachabilityCombiner();
|
||||
|
||||
// Act
|
||||
var report = combiner.Analyze(parsedSbom, callGraph: null, policy: null);
|
||||
|
||||
// Assert - All runtime transitive dependencies should be reachable
|
||||
report.ComponentReachability["pkg:maven/org.springframework.boot/spring-boot-starter-web@3.2.0"]
|
||||
.Should().Be(ReachabilityStatus.Reachable);
|
||||
report.ComponentReachability["pkg:maven/org.springframework/spring-web@6.1.0"]
|
||||
.Should().Be(ReachabilityStatus.Reachable);
|
||||
report.ComponentReachability["pkg:maven/org.springframework/spring-core@6.1.0"]
|
||||
.Should().Be(ReachabilityStatus.Reachable);
|
||||
report.ComponentReachability["pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.16.0"]
|
||||
.Should().Be(ReachabilityStatus.Reachable);
|
||||
report.ComponentReachability["pkg:maven/com.fasterxml.jackson.core/jackson-core@2.16.0"]
|
||||
.Should().Be(ReachabilityStatus.Reachable);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Python Project Tests
|
||||
|
||||
// Sprint: SPRINT_20260119_022 TASK-022-012 - Python project with optional dependencies
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Analyze_PythonProjectWithOptionalDependencies_FiltersByScope()
|
||||
{
|
||||
// Arrange - Realistic Python project with Django and optional extras
|
||||
var sbomJson = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"component": {
|
||||
"type": "application",
|
||||
"name": "django-api",
|
||||
"version": "1.0.0",
|
||||
"bom-ref": "pkg:pypi/django-api@1.0.0"
|
||||
}
|
||||
},
|
||||
"components": [
|
||||
{"type": "library", "bom-ref": "pkg:pypi/django@5.0", "name": "django", "version": "5.0", "purl": "pkg:pypi/django@5.0"},
|
||||
{"type": "library", "bom-ref": "pkg:pypi/djangorestframework@3.14.0", "name": "djangorestframework", "version": "3.14.0", "purl": "pkg:pypi/djangorestframework@3.14.0"},
|
||||
{"type": "library", "bom-ref": "pkg:pypi/pytz@2024.1", "name": "pytz", "version": "2024.1", "purl": "pkg:pypi/pytz@2024.1"},
|
||||
{"type": "library", "bom-ref": "pkg:pypi/pytest@8.0.0", "name": "pytest", "version": "8.0.0", "purl": "pkg:pypi/pytest@8.0.0", "scope": "optional"},
|
||||
{"type": "library", "bom-ref": "pkg:pypi/coverage@7.4.0", "name": "coverage", "version": "7.4.0", "purl": "pkg:pypi/coverage@7.4.0", "scope": "optional"},
|
||||
{"type": "library", "bom-ref": "pkg:pypi/orphan-lib@1.0.0", "name": "orphan-lib", "version": "1.0.0", "purl": "pkg:pypi/orphan-lib@1.0.0"}
|
||||
],
|
||||
"dependencies": [
|
||||
{"ref": "pkg:pypi/django-api@1.0.0", "dependsOn": ["pkg:pypi/django@5.0", "pkg:pypi/djangorestframework@3.14.0", "pkg:pypi/pytest@8.0.0"]},
|
||||
{"ref": "pkg:pypi/django@5.0", "dependsOn": ["pkg:pypi/pytz@2024.1"]},
|
||||
{"ref": "pkg:pypi/pytest@8.0.0", "dependsOn": ["pkg:pypi/coverage@7.4.0"]}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(sbomJson));
|
||||
var parsedSbom = await _parser.ParseAsync(stream, SbomFormat.CycloneDX);
|
||||
|
||||
var policy = new ReachabilityPolicy
|
||||
{
|
||||
ScopeHandling = new ReachabilityScopePolicy
|
||||
{
|
||||
IncludeRuntime = true,
|
||||
IncludeOptional = OptionalDependencyHandling.AsPotentiallyReachable
|
||||
}
|
||||
};
|
||||
|
||||
var combiner = new ReachGraphReachabilityCombiner();
|
||||
|
||||
// Act
|
||||
var report = combiner.Analyze(parsedSbom, callGraph: null, policy);
|
||||
|
||||
// Assert - Runtime deps should be reachable
|
||||
report.ComponentReachability["pkg:pypi/django@5.0"].Should().Be(ReachabilityStatus.Reachable);
|
||||
report.ComponentReachability["pkg:pypi/djangorestframework@3.14.0"].Should().Be(ReachabilityStatus.Reachable);
|
||||
report.ComponentReachability["pkg:pypi/pytz@2024.1"].Should().Be(ReachabilityStatus.Reachable);
|
||||
|
||||
// Test deps should be potentially reachable
|
||||
report.ComponentReachability["pkg:pypi/pytest@8.0.0"].Should().Be(ReachabilityStatus.PotentiallyReachable);
|
||||
report.ComponentReachability["pkg:pypi/coverage@7.4.0"].Should().Be(ReachabilityStatus.PotentiallyReachable);
|
||||
|
||||
// Orphan (no dependency path) should be unreachable
|
||||
report.ComponentReachability["pkg:pypi/orphan-lib@1.0.0"].Should().Be(ReachabilityStatus.Unreachable);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region False Positive Reduction Tests
|
||||
|
||||
// Sprint: SPRINT_20260119_022 TASK-022-012 - Measure false positive reduction
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Analyze_SbomWithUnreachableVulnerabilities_CalculatesReductionMetrics()
|
||||
{
|
||||
// Arrange - SBOM with mix of reachable and unreachable components
|
||||
var sbomJson = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"component": {
|
||||
"type": "application",
|
||||
"name": "test-app",
|
||||
"version": "1.0.0",
|
||||
"bom-ref": "pkg:npm/test-app@1.0.0"
|
||||
}
|
||||
},
|
||||
"components": [
|
||||
{"type": "library", "bom-ref": "pkg:npm/used-lib@1.0.0", "name": "used-lib", "version": "1.0.0", "purl": "pkg:npm/used-lib@1.0.0"},
|
||||
{"type": "library", "bom-ref": "pkg:npm/unused-lib@1.0.0", "name": "unused-lib", "version": "1.0.0", "purl": "pkg:npm/unused-lib@1.0.0"},
|
||||
{"type": "library", "bom-ref": "pkg:npm/another-unused@2.0.0", "name": "another-unused", "version": "2.0.0", "purl": "pkg:npm/another-unused@2.0.0"},
|
||||
{"type": "library", "bom-ref": "pkg:npm/deep-dep@1.0.0", "name": "deep-dep", "version": "1.0.0", "purl": "pkg:npm/deep-dep@1.0.0"}
|
||||
],
|
||||
"dependencies": [
|
||||
{"ref": "pkg:npm/test-app@1.0.0", "dependsOn": ["pkg:npm/used-lib@1.0.0"]},
|
||||
{"ref": "pkg:npm/used-lib@1.0.0", "dependsOn": ["pkg:npm/deep-dep@1.0.0"]}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(sbomJson));
|
||||
var parsedSbom = await _parser.ParseAsync(stream, SbomFormat.CycloneDX);
|
||||
|
||||
var combiner = new ReachGraphReachabilityCombiner();
|
||||
|
||||
// Act
|
||||
var report = combiner.Analyze(parsedSbom, callGraph: null, policy: null);
|
||||
|
||||
// Assert - Verify statistics show reduction potential
|
||||
// Note: Total includes the root app component from metadata
|
||||
report.Statistics.TotalComponents.Should().Be(5); // 4 libs + 1 root app
|
||||
report.Statistics.ReachableComponents.Should().Be(3); // root app + used-lib + deep-dep
|
||||
report.Statistics.UnreachableComponents.Should().Be(2); // unused-lib and another-unused
|
||||
|
||||
// Verify specific components
|
||||
report.ComponentReachability["pkg:npm/used-lib@1.0.0"].Should().Be(ReachabilityStatus.Reachable);
|
||||
report.ComponentReachability["pkg:npm/deep-dep@1.0.0"].Should().Be(ReachabilityStatus.Reachable);
|
||||
report.ComponentReachability["pkg:npm/unused-lib@1.0.0"].Should().Be(ReachabilityStatus.Unreachable);
|
||||
report.ComponentReachability["pkg:npm/another-unused@2.0.0"].Should().Be(ReachabilityStatus.Unreachable);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Case Tests
|
||||
|
||||
// Sprint: SPRINT_20260119_022 TASK-022-012 - Diamond dependency pattern
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Analyze_DiamondDependencyPattern_MarksAllPathsReachable()
|
||||
{
|
||||
// Arrange - Classic diamond: A -> B, C; B -> D; C -> D
|
||||
var sbomJson = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"component": {
|
||||
"type": "application",
|
||||
"name": "diamond-app",
|
||||
"version": "1.0.0",
|
||||
"bom-ref": "pkg:npm/diamond-app@1.0.0"
|
||||
}
|
||||
},
|
||||
"components": [
|
||||
{"type": "library", "bom-ref": "pkg:npm/left-branch@1.0.0", "name": "left-branch", "version": "1.0.0", "purl": "pkg:npm/left-branch@1.0.0"},
|
||||
{"type": "library", "bom-ref": "pkg:npm/right-branch@1.0.0", "name": "right-branch", "version": "1.0.0", "purl": "pkg:npm/right-branch@1.0.0"},
|
||||
{"type": "library", "bom-ref": "pkg:npm/shared-dep@1.0.0", "name": "shared-dep", "version": "1.0.0", "purl": "pkg:npm/shared-dep@1.0.0"}
|
||||
],
|
||||
"dependencies": [
|
||||
{"ref": "pkg:npm/diamond-app@1.0.0", "dependsOn": ["pkg:npm/left-branch@1.0.0", "pkg:npm/right-branch@1.0.0"]},
|
||||
{"ref": "pkg:npm/left-branch@1.0.0", "dependsOn": ["pkg:npm/shared-dep@1.0.0"]},
|
||||
{"ref": "pkg:npm/right-branch@1.0.0", "dependsOn": ["pkg:npm/shared-dep@1.0.0"]}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(sbomJson));
|
||||
var parsedSbom = await _parser.ParseAsync(stream, SbomFormat.CycloneDX);
|
||||
|
||||
var combiner = new ReachGraphReachabilityCombiner();
|
||||
|
||||
// Act
|
||||
var report = combiner.Analyze(parsedSbom, callGraph: null, policy: null);
|
||||
|
||||
// Assert - All components should be reachable
|
||||
report.ComponentReachability["pkg:npm/left-branch@1.0.0"].Should().Be(ReachabilityStatus.Reachable);
|
||||
report.ComponentReachability["pkg:npm/right-branch@1.0.0"].Should().Be(ReachabilityStatus.Reachable);
|
||||
report.ComponentReachability["pkg:npm/shared-dep@1.0.0"].Should().Be(ReachabilityStatus.Reachable);
|
||||
|
||||
// Note: Statistics include the root app component
|
||||
report.Statistics.ReachableComponents.Should().Be(4); // 3 libs + 1 root app
|
||||
report.Statistics.UnreachableComponents.Should().Be(0);
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260119_022 TASK-022-012 - Circular dependency detection
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Analyze_CircularDependency_HandlesWithoutInfiniteLoop()
|
||||
{
|
||||
// Arrange - Circular: A -> B -> C -> A
|
||||
var sbomJson = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"component": {
|
||||
"type": "application",
|
||||
"name": "circular-app",
|
||||
"version": "1.0.0",
|
||||
"bom-ref": "pkg:npm/circular-app@1.0.0"
|
||||
}
|
||||
},
|
||||
"components": [
|
||||
{"type": "library", "bom-ref": "pkg:npm/lib-a@1.0.0", "name": "lib-a", "version": "1.0.0", "purl": "pkg:npm/lib-a@1.0.0"},
|
||||
{"type": "library", "bom-ref": "pkg:npm/lib-b@1.0.0", "name": "lib-b", "version": "1.0.0", "purl": "pkg:npm/lib-b@1.0.0"},
|
||||
{"type": "library", "bom-ref": "pkg:npm/lib-c@1.0.0", "name": "lib-c", "version": "1.0.0", "purl": "pkg:npm/lib-c@1.0.0"}
|
||||
],
|
||||
"dependencies": [
|
||||
{"ref": "pkg:npm/circular-app@1.0.0", "dependsOn": ["pkg:npm/lib-a@1.0.0"]},
|
||||
{"ref": "pkg:npm/lib-a@1.0.0", "dependsOn": ["pkg:npm/lib-b@1.0.0"]},
|
||||
{"ref": "pkg:npm/lib-b@1.0.0", "dependsOn": ["pkg:npm/lib-c@1.0.0"]},
|
||||
{"ref": "pkg:npm/lib-c@1.0.0", "dependsOn": ["pkg:npm/lib-a@1.0.0"]}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(sbomJson));
|
||||
var parsedSbom = await _parser.ParseAsync(stream, SbomFormat.CycloneDX);
|
||||
|
||||
var combiner = new ReachGraphReachabilityCombiner();
|
||||
|
||||
// Act - Should complete without hanging
|
||||
var report = combiner.Analyze(parsedSbom, callGraph: null, policy: null);
|
||||
|
||||
// Assert - All in the cycle should be reachable
|
||||
report.ComponentReachability["pkg:npm/lib-a@1.0.0"].Should().Be(ReachabilityStatus.Reachable);
|
||||
report.ComponentReachability["pkg:npm/lib-b@1.0.0"].Should().Be(ReachabilityStatus.Reachable);
|
||||
report.ComponentReachability["pkg:npm/lib-c@1.0.0"].Should().Be(ReachabilityStatus.Reachable);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Accuracy Baseline Tests
|
||||
|
||||
// Sprint: SPRINT_20260119_022 TASK-022-012 - Establish accuracy baseline
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Analyze_KnownScenario_MatchesExpectedResults()
|
||||
{
|
||||
// Arrange - Controlled scenario with known expected results
|
||||
var sbomJson = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"component": {
|
||||
"type": "application",
|
||||
"name": "accuracy-test",
|
||||
"version": "1.0.0",
|
||||
"bom-ref": "pkg:npm/accuracy-test@1.0.0"
|
||||
}
|
||||
},
|
||||
"components": [
|
||||
{"type": "library", "bom-ref": "pkg:npm/runtime-a@1.0.0", "name": "runtime-a", "version": "1.0.0", "purl": "pkg:npm/runtime-a@1.0.0"},
|
||||
{"type": "library", "bom-ref": "pkg:npm/runtime-b@1.0.0", "name": "runtime-b", "version": "1.0.0", "purl": "pkg:npm/runtime-b@1.0.0"},
|
||||
{"type": "library", "bom-ref": "pkg:npm/dev-only@1.0.0", "name": "dev-only", "version": "1.0.0", "purl": "pkg:npm/dev-only@1.0.0", "scope": "optional"},
|
||||
{"type": "library", "bom-ref": "pkg:npm/orphan@1.0.0", "name": "orphan", "version": "1.0.0", "purl": "pkg:npm/orphan@1.0.0"}
|
||||
],
|
||||
"dependencies": [
|
||||
{"ref": "pkg:npm/accuracy-test@1.0.0", "dependsOn": ["pkg:npm/runtime-a@1.0.0", "pkg:npm/dev-only@1.0.0"]},
|
||||
{"ref": "pkg:npm/runtime-a@1.0.0", "dependsOn": ["pkg:npm/runtime-b@1.0.0"]}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(sbomJson));
|
||||
var parsedSbom = await _parser.ParseAsync(stream, SbomFormat.CycloneDX);
|
||||
|
||||
var policy = new ReachabilityPolicy
|
||||
{
|
||||
ScopeHandling = new ReachabilityScopePolicy
|
||||
{
|
||||
IncludeRuntime = true,
|
||||
IncludeOptional = OptionalDependencyHandling.AsPotentiallyReachable
|
||||
}
|
||||
};
|
||||
|
||||
var combiner = new ReachGraphReachabilityCombiner();
|
||||
|
||||
// Act
|
||||
var report = combiner.Analyze(parsedSbom, callGraph: null, policy);
|
||||
|
||||
// Assert - Verify exact expected outcomes
|
||||
var expected = new Dictionary<string, ReachabilityStatus>
|
||||
{
|
||||
["pkg:npm/runtime-a@1.0.0"] = ReachabilityStatus.Reachable,
|
||||
["pkg:npm/runtime-b@1.0.0"] = ReachabilityStatus.Reachable,
|
||||
["pkg:npm/dev-only@1.0.0"] = ReachabilityStatus.PotentiallyReachable,
|
||||
["pkg:npm/orphan@1.0.0"] = ReachabilityStatus.Unreachable
|
||||
};
|
||||
|
||||
foreach (var (purl, expectedStatus) in expected)
|
||||
{
|
||||
report.ComponentReachability[purl].Should().Be(expectedStatus,
|
||||
because: $"component {purl} should have status {expectedStatus}");
|
||||
}
|
||||
|
||||
// Verify no false negatives (reachable marked as unreachable)
|
||||
report.ComponentReachability
|
||||
.Where(kv => kv.Value == ReachabilityStatus.Unreachable)
|
||||
.Should().OnlyContain(kv => kv.Key == "pkg:npm/orphan@1.0.0",
|
||||
because: "only the orphan component should be unreachable");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Scanner.Reachability.Dependencies;
|
||||
using StellaOps.Scanner.Reachability.Dependencies.Reporting;
|
||||
using StellaOps.Scanner.Sarif;
|
||||
using StellaOps.Scanner.Sarif.Fingerprints;
|
||||
using StellaOps.Scanner.Sarif.Rules;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
using static StellaOps.Scanner.Reachability.Tests.DependencyTestData;
|
||||
using ReachabilityStatus = StellaOps.Scanner.Reachability.Dependencies.ReachabilityStatus;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests;
|
||||
|
||||
public sealed class DependencyReachabilityReporterTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task BuildReport_EmitsFilteredFindingsAndSarif()
|
||||
{
|
||||
var sbom = BuildSbom(
|
||||
components:
|
||||
[
|
||||
Component("app", type: "application", purl: "pkg:npm/app@1.0.0"),
|
||||
Component("lib-a", purl: "pkg:npm/lib-a@1.0.0"),
|
||||
Component("lib-b", purl: "pkg:npm/lib-b@1.0.0")
|
||||
],
|
||||
dependencies:
|
||||
[
|
||||
Dependency("app", ["lib-a"], DependencyScope.Runtime),
|
||||
Dependency("lib-a", ["lib-b"], DependencyScope.Runtime)
|
||||
],
|
||||
rootRef: "app");
|
||||
|
||||
var policy = new ReachabilityPolicy
|
||||
{
|
||||
Reporting = new ReachabilityReportingPolicy
|
||||
{
|
||||
ShowFilteredVulnerabilities = true,
|
||||
IncludeReachabilityPaths = true
|
||||
}
|
||||
};
|
||||
|
||||
var combiner = new ReachGraphReachabilityCombiner();
|
||||
var reachabilityReport = combiner.Analyze(sbom, callGraph: null, policy);
|
||||
|
||||
var matchedAt = new DateTimeOffset(2025, 1, 2, 3, 4, 5, TimeSpan.Zero);
|
||||
var matches = new[]
|
||||
{
|
||||
new SbomAdvisoryMatch
|
||||
{
|
||||
Id = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
|
||||
SbomId = Guid.Empty,
|
||||
SbomDigest = "sha256:deadbeef",
|
||||
CanonicalId = Guid.Parse("11111111-1111-1111-1111-111111111111"),
|
||||
Purl = "pkg:npm/lib-a@1.0.0",
|
||||
Method = MatchMethod.ExactPurl,
|
||||
IsReachable = true,
|
||||
IsDeployed = false,
|
||||
MatchedAt = matchedAt
|
||||
},
|
||||
new SbomAdvisoryMatch
|
||||
{
|
||||
Id = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
|
||||
SbomId = Guid.Empty,
|
||||
SbomDigest = "sha256:deadbeef",
|
||||
CanonicalId = Guid.Parse("22222222-2222-2222-2222-222222222222"),
|
||||
Purl = "pkg:npm/lib-b@1.0.0",
|
||||
Method = MatchMethod.ExactPurl,
|
||||
IsReachable = false,
|
||||
IsDeployed = false,
|
||||
MatchedAt = matchedAt
|
||||
}
|
||||
};
|
||||
|
||||
var reachabilityMap = new Dictionary<string, ReachabilityStatus>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["pkg:npm/lib-a@1.0.0"] = ReachabilityStatus.Reachable,
|
||||
["pkg:npm/lib-b@1.0.0"] = ReachabilityStatus.Unreachable
|
||||
};
|
||||
|
||||
var severityMap = new Dictionary<Guid, string?>
|
||||
{
|
||||
[Guid.Parse("11111111-1111-1111-1111-111111111111")] = "high",
|
||||
[Guid.Parse("22222222-2222-2222-2222-222222222222")] = "medium"
|
||||
};
|
||||
|
||||
var filter = new VulnerabilityReachabilityFilter();
|
||||
var filterResult = filter.Apply(matches, reachabilityMap, policy, severityMap);
|
||||
|
||||
var advisorySummaries = new Dictionary<Guid, DependencyReachabilityAdvisorySummary>
|
||||
{
|
||||
[Guid.Parse("11111111-1111-1111-1111-111111111111")] = new DependencyReachabilityAdvisorySummary
|
||||
{
|
||||
CanonicalId = Guid.Parse("11111111-1111-1111-1111-111111111111"),
|
||||
VulnerabilityId = "CVE-2025-0001",
|
||||
Severity = "high",
|
||||
Title = "lib-a issue"
|
||||
},
|
||||
[Guid.Parse("22222222-2222-2222-2222-222222222222")] = new DependencyReachabilityAdvisorySummary
|
||||
{
|
||||
CanonicalId = Guid.Parse("22222222-2222-2222-2222-222222222222"),
|
||||
VulnerabilityId = "CVE-2025-0002",
|
||||
Severity = "medium",
|
||||
Title = "lib-b issue"
|
||||
}
|
||||
};
|
||||
|
||||
var ruleRegistry = new SarifRuleRegistry();
|
||||
var fingerprintGenerator = new FingerprintGenerator(ruleRegistry);
|
||||
var reporter = new DependencyReachabilityReporter(new SarifExportService(
|
||||
ruleRegistry,
|
||||
fingerprintGenerator));
|
||||
var report = reporter.BuildReport(sbom, reachabilityReport, filterResult, advisorySummaries, policy);
|
||||
|
||||
report.Vulnerabilities.Should().ContainSingle();
|
||||
report.FilteredVulnerabilities.Should().ContainSingle();
|
||||
report.Summary.VulnerabilityStatistics.FilteredVulnerabilities.Should().Be(1);
|
||||
|
||||
var purlLookup = sbom.Components
|
||||
.Where(component => !string.IsNullOrWhiteSpace(component.BomRef))
|
||||
.ToDictionary(component => component.BomRef!, component => component.Purl, StringComparer.Ordinal);
|
||||
var dot = reporter.ExportGraphViz(
|
||||
reachabilityReport.Graph,
|
||||
reachabilityReport.ComponentReachability,
|
||||
purlLookup);
|
||||
dot.Should().Contain("digraph");
|
||||
dot.Should().Contain("\"app\"");
|
||||
|
||||
var sarif = await reporter.ExportSarifAsync(report, "1.2.3", includeFiltered: true);
|
||||
sarif.Runs.Should().NotBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Scanner.Reachability.Dependencies;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
using static StellaOps.Scanner.Reachability.Tests.DependencyTestData;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests;
|
||||
|
||||
public sealed class DependencyGraphBuilderTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Build_UsesMetadataRootAndDependencies()
|
||||
{
|
||||
var sbom = BuildSbom(
|
||||
components:
|
||||
[
|
||||
Component("app", type: "application"),
|
||||
Component("lib-a"),
|
||||
Component("lib-b")
|
||||
],
|
||||
dependencies:
|
||||
[
|
||||
Dependency("app", ["lib-a"], DependencyScope.Runtime),
|
||||
Dependency("lib-a", ["lib-b"], DependencyScope.Optional)
|
||||
],
|
||||
rootRef: "app");
|
||||
|
||||
var builder = new DependencyGraphBuilder();
|
||||
|
||||
var graph = builder.Build(sbom);
|
||||
|
||||
graph.Nodes.Should().Contain(new[] { "app", "lib-a", "lib-b" });
|
||||
graph.Edges.Should().ContainKey("app");
|
||||
graph.Edges["app"].Should().ContainSingle(edge =>
|
||||
edge.From == "app" &&
|
||||
edge.To == "lib-a" &&
|
||||
edge.Scope == DependencyScope.Runtime);
|
||||
graph.Edges["lib-a"].Should().ContainSingle(edge =>
|
||||
edge.From == "lib-a" &&
|
||||
edge.To == "lib-b" &&
|
||||
edge.Scope == DependencyScope.Optional);
|
||||
graph.Roots.Should().ContainSingle().Which.Should().Be("app");
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260119_022 TASK-022-011 - Linear chain test
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Build_LinearChain_CreatesCorrectGraph()
|
||||
{
|
||||
var sbom = BuildSbom(
|
||||
components:
|
||||
[
|
||||
Component("a", type: "application"),
|
||||
Component("b"),
|
||||
Component("c"),
|
||||
Component("d")
|
||||
],
|
||||
dependencies:
|
||||
[
|
||||
Dependency("a", ["b"], DependencyScope.Runtime),
|
||||
Dependency("b", ["c"], DependencyScope.Runtime),
|
||||
Dependency("c", ["d"], DependencyScope.Runtime)
|
||||
],
|
||||
rootRef: "a");
|
||||
|
||||
var builder = new DependencyGraphBuilder();
|
||||
var graph = builder.Build(sbom);
|
||||
|
||||
graph.Nodes.Should().HaveCount(4);
|
||||
graph.Edges["a"].Should().ContainSingle(e => e.To == "b");
|
||||
graph.Edges["b"].Should().ContainSingle(e => e.To == "c");
|
||||
graph.Edges["c"].Should().ContainSingle(e => e.To == "d");
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260119_022 TASK-022-011 - Diamond dependency test
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Build_DiamondDependency_CreatesCorrectGraph()
|
||||
{
|
||||
// Diamond: A -> B -> D
|
||||
// A -> C -> D
|
||||
var sbom = BuildSbom(
|
||||
components:
|
||||
[
|
||||
Component("a", type: "application"),
|
||||
Component("b"),
|
||||
Component("c"),
|
||||
Component("d")
|
||||
],
|
||||
dependencies:
|
||||
[
|
||||
Dependency("a", ["b", "c"], DependencyScope.Runtime),
|
||||
Dependency("b", ["d"], DependencyScope.Runtime),
|
||||
Dependency("c", ["d"], DependencyScope.Runtime)
|
||||
],
|
||||
rootRef: "a");
|
||||
|
||||
var builder = new DependencyGraphBuilder();
|
||||
var graph = builder.Build(sbom);
|
||||
|
||||
graph.Nodes.Should().HaveCount(4);
|
||||
graph.Edges["a"].Should().HaveCount(2);
|
||||
graph.Edges["b"].Should().ContainSingle(e => e.To == "d");
|
||||
graph.Edges["c"].Should().ContainSingle(e => e.To == "d");
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260119_022 TASK-022-011 - Circular dependency test
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Build_CircularDependency_HandlesCorrectly()
|
||||
{
|
||||
// Circular: A -> B -> C -> A
|
||||
var sbom = BuildSbom(
|
||||
components:
|
||||
[
|
||||
Component("a", type: "application"),
|
||||
Component("b"),
|
||||
Component("c")
|
||||
],
|
||||
dependencies:
|
||||
[
|
||||
Dependency("a", ["b"], DependencyScope.Runtime),
|
||||
Dependency("b", ["c"], DependencyScope.Runtime),
|
||||
Dependency("c", ["a"], DependencyScope.Runtime)
|
||||
],
|
||||
rootRef: "a");
|
||||
|
||||
var builder = new DependencyGraphBuilder();
|
||||
var graph = builder.Build(sbom);
|
||||
|
||||
graph.Nodes.Should().HaveCount(3);
|
||||
graph.Edges["a"].Should().ContainSingle(e => e.To == "b");
|
||||
graph.Edges["b"].Should().ContainSingle(e => e.To == "c");
|
||||
graph.Edges["c"].Should().ContainSingle(e => e.To == "a");
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260119_022 TASK-022-011 - Empty SBOM test
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Build_EmptySbom_ReturnsEmptyGraph()
|
||||
{
|
||||
var sbom = BuildSbom(
|
||||
components: [],
|
||||
dependencies: []);
|
||||
|
||||
var builder = new DependencyGraphBuilder();
|
||||
var graph = builder.Build(sbom);
|
||||
|
||||
graph.Nodes.Should().BeEmpty();
|
||||
graph.Edges.Should().BeEmpty();
|
||||
graph.Roots.Should().BeEmpty();
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260119_022 TASK-022-011 - Multiple roots test
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Build_MultipleRoots_DetectsAllRoots()
|
||||
{
|
||||
var sbom = BuildSbom(
|
||||
components:
|
||||
[
|
||||
Component("app-1", type: "application"),
|
||||
Component("app-2", type: "application"),
|
||||
Component("shared-lib")
|
||||
],
|
||||
dependencies:
|
||||
[
|
||||
Dependency("app-1", ["shared-lib"], DependencyScope.Runtime),
|
||||
Dependency("app-2", ["shared-lib"], DependencyScope.Runtime)
|
||||
]);
|
||||
|
||||
var builder = new DependencyGraphBuilder();
|
||||
var graph = builder.Build(sbom);
|
||||
|
||||
graph.Roots.Should().BeEquivalentTo(["app-1", "app-2"]);
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260119_022 TASK-022-011 - Missing dependency target
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Build_MissingDependencyTarget_HandlesGracefully()
|
||||
{
|
||||
var sbom = BuildSbom(
|
||||
components:
|
||||
[
|
||||
Component("app", type: "application")
|
||||
],
|
||||
dependencies:
|
||||
[
|
||||
Dependency("app", ["missing-lib"], DependencyScope.Runtime)
|
||||
],
|
||||
rootRef: "app");
|
||||
|
||||
var builder = new DependencyGraphBuilder();
|
||||
var graph = builder.Build(sbom);
|
||||
|
||||
// Should still build graph even with missing target
|
||||
graph.Nodes.Should().Contain("app");
|
||||
graph.Edges["app"].Should().ContainSingle(e => e.To == "missing-lib");
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260119_022 TASK-022-011 - Mixed scope dependencies
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Build_MixedScopes_PreservesAllScopes()
|
||||
{
|
||||
var sbom = BuildSbom(
|
||||
components:
|
||||
[
|
||||
Component("app", type: "application"),
|
||||
Component("runtime-lib"),
|
||||
Component("dev-lib"),
|
||||
Component("test-lib"),
|
||||
Component("optional-lib")
|
||||
],
|
||||
dependencies:
|
||||
[
|
||||
Dependency("app", ["runtime-lib"], DependencyScope.Runtime),
|
||||
Dependency("app", ["dev-lib"], DependencyScope.Development),
|
||||
Dependency("app", ["test-lib"], DependencyScope.Test),
|
||||
Dependency("app", ["optional-lib"], DependencyScope.Optional)
|
||||
],
|
||||
rootRef: "app");
|
||||
|
||||
var builder = new DependencyGraphBuilder();
|
||||
var graph = builder.Build(sbom);
|
||||
|
||||
var edges = graph.Edges["app"];
|
||||
edges.Should().HaveCount(4);
|
||||
edges.Should().Contain(e => e.To == "runtime-lib" && e.Scope == DependencyScope.Runtime);
|
||||
edges.Should().Contain(e => e.To == "dev-lib" && e.Scope == DependencyScope.Development);
|
||||
edges.Should().Contain(e => e.To == "test-lib" && e.Scope == DependencyScope.Test);
|
||||
edges.Should().Contain(e => e.To == "optional-lib" && e.Scope == DependencyScope.Optional);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class EntryPointDetectorTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DetectEntryPoints_IncludesPolicyAndSbomSignals()
|
||||
{
|
||||
var sbom = BuildSbom(
|
||||
components:
|
||||
[
|
||||
Component("root", type: "application"),
|
||||
Component("worker", type: "application")
|
||||
],
|
||||
dependencies: [],
|
||||
rootRef: "root");
|
||||
|
||||
var policy = new ReachabilityPolicy
|
||||
{
|
||||
EntryPoints = new ReachabilityEntryPointPolicy
|
||||
{
|
||||
Additional = ["extra-entry"]
|
||||
}
|
||||
};
|
||||
|
||||
var detector = new EntryPointDetector();
|
||||
|
||||
var entryPoints = detector.DetectEntryPoints(sbom, policy);
|
||||
|
||||
entryPoints.Should().Contain(new[] { "extra-entry", "root", "worker" });
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DetectEntryPoints_FallsBackToAllComponents()
|
||||
{
|
||||
var sbom = BuildSbom(
|
||||
components:
|
||||
[
|
||||
Component("lib-a", type: "library"),
|
||||
Component("lib-b", type: "library")
|
||||
],
|
||||
dependencies: []);
|
||||
|
||||
var detector = new EntryPointDetector();
|
||||
|
||||
var entryPoints = detector.DetectEntryPoints(sbom);
|
||||
|
||||
entryPoints.Should().BeEquivalentTo(new[] { "lib-a", "lib-b" });
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260119_022 TASK-022-011 - Policy disables SBOM detection
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DetectEntryPoints_PolicyDisablesSbomDetection_OnlyUsesAdditional()
|
||||
{
|
||||
var sbom = BuildSbom(
|
||||
components:
|
||||
[
|
||||
Component("app", type: "application"),
|
||||
Component("lib")
|
||||
],
|
||||
dependencies: [],
|
||||
rootRef: "app");
|
||||
|
||||
var policy = new ReachabilityPolicy
|
||||
{
|
||||
EntryPoints = new ReachabilityEntryPointPolicy
|
||||
{
|
||||
DetectFromSbom = false,
|
||||
Additional = ["custom-entry"]
|
||||
}
|
||||
};
|
||||
|
||||
var detector = new EntryPointDetector();
|
||||
|
||||
var entryPoints = detector.DetectEntryPoints(sbom, policy);
|
||||
|
||||
entryPoints.Should().ContainSingle("custom-entry");
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260119_022 TASK-022-011 - Empty SBOM entry points
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DetectEntryPoints_EmptySbom_ReturnsEmpty()
|
||||
{
|
||||
var sbom = BuildSbom(
|
||||
components: [],
|
||||
dependencies: []);
|
||||
|
||||
var detector = new EntryPointDetector();
|
||||
|
||||
var entryPoints = detector.DetectEntryPoints(sbom);
|
||||
|
||||
entryPoints.Should().BeEmpty();
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260119_022 TASK-022-011 - Entry points from container type
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DetectEntryPoints_ContainerComponent_TreatedAsEntryPoint()
|
||||
{
|
||||
var sbom = BuildSbom(
|
||||
components:
|
||||
[
|
||||
Component("my-container", type: "container"),
|
||||
Component("lib")
|
||||
],
|
||||
dependencies:
|
||||
[
|
||||
Dependency("my-container", ["lib"], DependencyScope.Runtime)
|
||||
]);
|
||||
|
||||
var detector = new EntryPointDetector();
|
||||
|
||||
var entryPoints = detector.DetectEntryPoints(sbom);
|
||||
|
||||
entryPoints.Should().Contain("my-container");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StaticReachabilityAnalyzerTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Analyze_RespectsScopeHandling()
|
||||
{
|
||||
var graph = new DependencyGraph
|
||||
{
|
||||
Nodes = ["app", "runtime-lib", "dev-lib", "optional-lib"],
|
||||
Edges = new Dictionary<string, ImmutableArray<DependencyEdge>>
|
||||
{
|
||||
["app"] =
|
||||
[
|
||||
new DependencyEdge { From = "app", To = "runtime-lib", Scope = DependencyScope.Runtime },
|
||||
new DependencyEdge { From = "app", To = "optional-lib", Scope = DependencyScope.Optional }
|
||||
],
|
||||
["runtime-lib"] =
|
||||
[
|
||||
new DependencyEdge { From = "runtime-lib", To = "dev-lib", Scope = DependencyScope.Development }
|
||||
]
|
||||
}.ToImmutableDictionary(StringComparer.Ordinal)
|
||||
};
|
||||
|
||||
var policy = new ReachabilityPolicy
|
||||
{
|
||||
ScopeHandling = new ReachabilityScopePolicy
|
||||
{
|
||||
IncludeRuntime = true,
|
||||
IncludeOptional = OptionalDependencyHandling.AsPotentiallyReachable,
|
||||
IncludeDevelopment = false,
|
||||
IncludeTest = false
|
||||
}
|
||||
};
|
||||
|
||||
var analyzer = new StaticReachabilityAnalyzer();
|
||||
|
||||
var report = analyzer.Analyze(graph, ["app"], policy);
|
||||
|
||||
report.ComponentReachability["app"].Should().Be(ReachabilityStatus.Reachable);
|
||||
report.ComponentReachability["runtime-lib"].Should().Be(ReachabilityStatus.Reachable);
|
||||
report.ComponentReachability["optional-lib"].Should().Be(ReachabilityStatus.PotentiallyReachable);
|
||||
report.ComponentReachability["dev-lib"].Should().Be(ReachabilityStatus.Unreachable);
|
||||
|
||||
report.Findings.Should().Contain(finding =>
|
||||
finding.ComponentRef == "optional-lib" &&
|
||||
finding.Path.SequenceEqual(new[] { "app", "optional-lib" }));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Analyze_WithoutEntryPoints_MarksUnknown()
|
||||
{
|
||||
var graph = new DependencyGraph
|
||||
{
|
||||
Nodes = ["lib-a", "lib-b"]
|
||||
};
|
||||
|
||||
var analyzer = new StaticReachabilityAnalyzer();
|
||||
|
||||
var report = analyzer.Analyze(graph, [], null);
|
||||
|
||||
report.ComponentReachability["lib-a"].Should().Be(ReachabilityStatus.Unknown);
|
||||
report.ComponentReachability["lib-b"].Should().Be(ReachabilityStatus.Unknown);
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260119_022 TASK-022-011 - Circular dependency traversal
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Analyze_CircularDependency_MarksAllReachable()
|
||||
{
|
||||
// Circular: A -> B -> C -> A
|
||||
var graph = new DependencyGraph
|
||||
{
|
||||
Nodes = ["a", "b", "c"],
|
||||
Edges = new Dictionary<string, ImmutableArray<DependencyEdge>>
|
||||
{
|
||||
["a"] = [new DependencyEdge { From = "a", To = "b", Scope = DependencyScope.Runtime }],
|
||||
["b"] = [new DependencyEdge { From = "b", To = "c", Scope = DependencyScope.Runtime }],
|
||||
["c"] = [new DependencyEdge { From = "c", To = "a", Scope = DependencyScope.Runtime }]
|
||||
}.ToImmutableDictionary(StringComparer.Ordinal),
|
||||
Roots = ["a"]
|
||||
};
|
||||
|
||||
var analyzer = new StaticReachabilityAnalyzer();
|
||||
|
||||
var report = analyzer.Analyze(graph, ["a"], null);
|
||||
|
||||
report.ComponentReachability["a"].Should().Be(ReachabilityStatus.Reachable);
|
||||
report.ComponentReachability["b"].Should().Be(ReachabilityStatus.Reachable);
|
||||
report.ComponentReachability["c"].Should().Be(ReachabilityStatus.Reachable);
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260119_022 TASK-022-011 - Multiple entry points
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Analyze_MultipleEntryPoints_MarksAllReachablePaths()
|
||||
{
|
||||
// Entry1 -> A, Entry2 -> B, A and B are independent
|
||||
var graph = new DependencyGraph
|
||||
{
|
||||
Nodes = ["entry1", "entry2", "a", "b", "orphan"],
|
||||
Edges = new Dictionary<string, ImmutableArray<DependencyEdge>>
|
||||
{
|
||||
["entry1"] = [new DependencyEdge { From = "entry1", To = "a", Scope = DependencyScope.Runtime }],
|
||||
["entry2"] = [new DependencyEdge { From = "entry2", To = "b", Scope = DependencyScope.Runtime }]
|
||||
}.ToImmutableDictionary(StringComparer.Ordinal)
|
||||
};
|
||||
|
||||
var analyzer = new StaticReachabilityAnalyzer();
|
||||
|
||||
var report = analyzer.Analyze(graph, ["entry1", "entry2"], null);
|
||||
|
||||
report.ComponentReachability["entry1"].Should().Be(ReachabilityStatus.Reachable);
|
||||
report.ComponentReachability["entry2"].Should().Be(ReachabilityStatus.Reachable);
|
||||
report.ComponentReachability["a"].Should().Be(ReachabilityStatus.Reachable);
|
||||
report.ComponentReachability["b"].Should().Be(ReachabilityStatus.Reachable);
|
||||
report.ComponentReachability["orphan"].Should().Be(ReachabilityStatus.Unreachable);
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260119_022 TASK-022-011 - Test scope handling
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Analyze_TestScopeExcluded_MarksUnreachable()
|
||||
{
|
||||
var graph = new DependencyGraph
|
||||
{
|
||||
Nodes = ["app", "runtime-lib", "test-lib"],
|
||||
Edges = new Dictionary<string, ImmutableArray<DependencyEdge>>
|
||||
{
|
||||
["app"] =
|
||||
[
|
||||
new DependencyEdge { From = "app", To = "runtime-lib", Scope = DependencyScope.Runtime },
|
||||
new DependencyEdge { From = "app", To = "test-lib", Scope = DependencyScope.Test }
|
||||
]
|
||||
}.ToImmutableDictionary(StringComparer.Ordinal)
|
||||
};
|
||||
|
||||
var policy = new ReachabilityPolicy
|
||||
{
|
||||
ScopeHandling = new ReachabilityScopePolicy
|
||||
{
|
||||
IncludeRuntime = true,
|
||||
IncludeTest = false
|
||||
}
|
||||
};
|
||||
|
||||
var analyzer = new StaticReachabilityAnalyzer();
|
||||
|
||||
var report = analyzer.Analyze(graph, ["app"], policy);
|
||||
|
||||
report.ComponentReachability["app"].Should().Be(ReachabilityStatus.Reachable);
|
||||
report.ComponentReachability["runtime-lib"].Should().Be(ReachabilityStatus.Reachable);
|
||||
report.ComponentReachability["test-lib"].Should().Be(ReachabilityStatus.Unreachable);
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260119_022 TASK-022-011 - Deep transitive dependencies
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Analyze_DeepTransitiveDependencies_MarksAllReachable()
|
||||
{
|
||||
// 5-level deep: app -> a -> b -> c -> d -> e
|
||||
var graph = new DependencyGraph
|
||||
{
|
||||
Nodes = ["app", "a", "b", "c", "d", "e"],
|
||||
Edges = new Dictionary<string, ImmutableArray<DependencyEdge>>
|
||||
{
|
||||
["app"] = [new DependencyEdge { From = "app", To = "a", Scope = DependencyScope.Runtime }],
|
||||
["a"] = [new DependencyEdge { From = "a", To = "b", Scope = DependencyScope.Runtime }],
|
||||
["b"] = [new DependencyEdge { From = "b", To = "c", Scope = DependencyScope.Runtime }],
|
||||
["c"] = [new DependencyEdge { From = "c", To = "d", Scope = DependencyScope.Runtime }],
|
||||
["d"] = [new DependencyEdge { From = "d", To = "e", Scope = DependencyScope.Runtime }]
|
||||
}.ToImmutableDictionary(StringComparer.Ordinal)
|
||||
};
|
||||
|
||||
var analyzer = new StaticReachabilityAnalyzer();
|
||||
|
||||
var report = analyzer.Analyze(graph, ["app"], null);
|
||||
|
||||
foreach (var node in graph.Nodes)
|
||||
{
|
||||
report.ComponentReachability[node].Should().Be(ReachabilityStatus.Reachable,
|
||||
because: $"node {node} should be reachable from app");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ConditionalReachabilityAnalyzerTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Analyze_MarksConditionalDependenciesAndConditions()
|
||||
{
|
||||
var properties = ImmutableDictionary<string, string>.Empty
|
||||
.Add("stellaops.reachability.condition", "feature:beta");
|
||||
|
||||
var sbom = BuildSbom(
|
||||
components:
|
||||
[
|
||||
Component("app", type: "application"),
|
||||
Component("optional-lib", scope: ComponentScope.Optional),
|
||||
Component("flagged-lib", properties: properties)
|
||||
],
|
||||
dependencies:
|
||||
[
|
||||
Dependency("app", ["optional-lib"], DependencyScope.Optional),
|
||||
Dependency("optional-lib", ["flagged-lib"], DependencyScope.Runtime)
|
||||
],
|
||||
rootRef: "app");
|
||||
|
||||
var graph = new DependencyGraphBuilder().Build(sbom);
|
||||
var entryPoints = new EntryPointDetector().DetectEntryPoints(sbom);
|
||||
|
||||
var analyzer = new ConditionalReachabilityAnalyzer();
|
||||
|
||||
var report = analyzer.Analyze(graph, sbom, entryPoints);
|
||||
|
||||
report.ComponentReachability["app"].Should().Be(ReachabilityStatus.Reachable);
|
||||
report.ComponentReachability["optional-lib"].Should()
|
||||
.Be(ReachabilityStatus.PotentiallyReachable);
|
||||
report.ComponentReachability["flagged-lib"].Should()
|
||||
.Be(ReachabilityStatus.PotentiallyReachable);
|
||||
|
||||
report.Findings.Single(finding => finding.ComponentRef == "flagged-lib")
|
||||
.Conditions.Should().Equal(
|
||||
"component.scope.optional",
|
||||
"dependency.scope.optional",
|
||||
"feature:beta");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Analyze_PromotesToReachableWhenUnconditionalPathExists()
|
||||
{
|
||||
var sbom = BuildSbom(
|
||||
components:
|
||||
[
|
||||
Component("app", type: "application"),
|
||||
Component("lib-a")
|
||||
],
|
||||
dependencies:
|
||||
[
|
||||
Dependency("app", ["lib-a"], DependencyScope.Optional),
|
||||
Dependency("app", ["lib-a"], DependencyScope.Runtime)
|
||||
],
|
||||
rootRef: "app");
|
||||
|
||||
var graph = new DependencyGraphBuilder().Build(sbom);
|
||||
var entryPoints = new EntryPointDetector().DetectEntryPoints(sbom);
|
||||
|
||||
var analyzer = new ConditionalReachabilityAnalyzer();
|
||||
|
||||
var report = analyzer.Analyze(graph, sbom, entryPoints);
|
||||
|
||||
report.ComponentReachability["lib-a"].Should().Be(ReachabilityStatus.Reachable);
|
||||
report.Findings.Single(finding => finding.ComponentRef == "lib-a")
|
||||
.Conditions.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
internal static class DependencyTestData
|
||||
{
|
||||
public static ParsedSbom BuildSbom(
|
||||
ImmutableArray<ParsedComponent> components,
|
||||
ImmutableArray<ParsedDependency> dependencies,
|
||||
string? rootRef = null)
|
||||
{
|
||||
return new ParsedSbom
|
||||
{
|
||||
Format = "cyclonedx",
|
||||
SpecVersion = "1.7",
|
||||
SerialNumber = "urn:uuid:reachability-test",
|
||||
Components = components,
|
||||
Dependencies = dependencies,
|
||||
Metadata = new ParsedSbomMetadata
|
||||
{
|
||||
RootComponentRef = rootRef
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static ParsedComponent Component(
|
||||
string bomRef,
|
||||
string? type = null,
|
||||
ComponentScope scope = ComponentScope.Required,
|
||||
ImmutableDictionary<string, string>? properties = null,
|
||||
string? purl = null)
|
||||
{
|
||||
return new ParsedComponent
|
||||
{
|
||||
BomRef = bomRef,
|
||||
Name = bomRef,
|
||||
Type = type,
|
||||
Scope = scope,
|
||||
Properties = properties ?? ImmutableDictionary<string, string>.Empty,
|
||||
Purl = purl
|
||||
};
|
||||
}
|
||||
|
||||
public static ParsedDependency Dependency(
|
||||
string source,
|
||||
ImmutableArray<string> dependsOn,
|
||||
DependencyScope scope)
|
||||
{
|
||||
return new ParsedDependency
|
||||
{
|
||||
SourceRef = source,
|
||||
DependsOn = dependsOn,
|
||||
Scope = scope
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
using StellaOps.Scanner.Reachability.Dependencies;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
using static StellaOps.Scanner.Reachability.Tests.DependencyTestData;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests;
|
||||
|
||||
public sealed class ReachGraphReachabilityCombinerTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Combine_DowngradesReachableWhenCallGraphUnreachable()
|
||||
{
|
||||
var sbom = BuildSbom(
|
||||
components:
|
||||
[
|
||||
Component("app", type: "application", purl: "pkg:npm/app@1.0.0"),
|
||||
Component("lib", purl: "pkg:npm/lib@1.0.0")
|
||||
],
|
||||
dependencies:
|
||||
[
|
||||
Dependency("app", ["lib"], DependencyScope.Runtime)
|
||||
],
|
||||
rootRef: "app");
|
||||
|
||||
var callGraph = BuildGraph(
|
||||
nodes:
|
||||
[
|
||||
Node("sym:app.entry", "pkg:npm/app@1.0.0", "entrypoint"),
|
||||
Node("sym:lib.func", "pkg:npm/lib@1.0.0", "function")
|
||||
],
|
||||
edges: [],
|
||||
roots: [new RichGraphRoot("sym:app.entry", "runtime", "test")]);
|
||||
|
||||
var policy = new ReachabilityPolicy
|
||||
{
|
||||
AnalysisMode = ReachabilityAnalysisMode.Combined
|
||||
};
|
||||
|
||||
var combiner = new ReachGraphReachabilityCombiner();
|
||||
|
||||
var report = combiner.Analyze(sbom, callGraph, policy);
|
||||
|
||||
report.ComponentReachability["lib"].Should().Be(ReachabilityStatus.Unreachable);
|
||||
report.Findings.Single(finding => finding.ComponentRef == "lib")
|
||||
.Reason.Should().Contain("call-graph-unreachable");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Combine_PreservesSbomWhenCallGraphMissingPurl()
|
||||
{
|
||||
var sbom = BuildSbom(
|
||||
components:
|
||||
[
|
||||
Component("app", type: "application", purl: "pkg:npm/app@1.0.0"),
|
||||
Component("lib", purl: "pkg:npm/lib@1.0.0")
|
||||
],
|
||||
dependencies:
|
||||
[
|
||||
Dependency("app", ["lib"], DependencyScope.Runtime)
|
||||
],
|
||||
rootRef: "app");
|
||||
|
||||
var callGraph = BuildGraph(
|
||||
nodes:
|
||||
[
|
||||
Node("sym:app.entry", "pkg:npm/app@1.0.0", "entrypoint")
|
||||
],
|
||||
edges: [],
|
||||
roots: [new RichGraphRoot("sym:app.entry", "runtime", "test")]);
|
||||
|
||||
var policy = new ReachabilityPolicy
|
||||
{
|
||||
AnalysisMode = ReachabilityAnalysisMode.Combined
|
||||
};
|
||||
|
||||
var combiner = new ReachGraphReachabilityCombiner();
|
||||
|
||||
var report = combiner.Analyze(sbom, callGraph, policy);
|
||||
|
||||
report.ComponentReachability["lib"].Should().Be(ReachabilityStatus.Reachable);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CallGraphMode_OverridesSbomWhenReachable()
|
||||
{
|
||||
var sbom = BuildSbom(
|
||||
components:
|
||||
[
|
||||
Component("app", type: "application", purl: "pkg:npm/app@1.0.0"),
|
||||
Component("lib", purl: "pkg:npm/lib@1.0.0")
|
||||
],
|
||||
dependencies: [],
|
||||
rootRef: "app");
|
||||
|
||||
var callGraph = BuildGraph(
|
||||
nodes:
|
||||
[
|
||||
Node("sym:app.entry", "pkg:npm/app@1.0.0", "entrypoint"),
|
||||
Node("sym:lib.func", "pkg:npm/lib@1.0.0", "function")
|
||||
],
|
||||
edges:
|
||||
[
|
||||
Edge("sym:app.entry", "sym:lib.func")
|
||||
],
|
||||
roots: [new RichGraphRoot("sym:app.entry", "runtime", "test")]);
|
||||
|
||||
var policy = new ReachabilityPolicy
|
||||
{
|
||||
AnalysisMode = ReachabilityAnalysisMode.CallGraph
|
||||
};
|
||||
|
||||
var combiner = new ReachGraphReachabilityCombiner();
|
||||
|
||||
var report = combiner.Analyze(sbom, callGraph, policy);
|
||||
|
||||
report.ComponentReachability["lib"].Should().Be(ReachabilityStatus.Reachable);
|
||||
report.Findings.Single(finding => finding.ComponentRef == "lib")
|
||||
.Reason.Should().Contain("call-graph-reachable");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CallGraphMode_FallsBackToSbomWhenNoEntrypoints()
|
||||
{
|
||||
var sbom = BuildSbom(
|
||||
components:
|
||||
[
|
||||
Component("app", type: "application", purl: "pkg:npm/app@1.0.0"),
|
||||
Component("lib", purl: "pkg:npm/lib@1.0.0")
|
||||
],
|
||||
dependencies:
|
||||
[
|
||||
Dependency("app", ["lib"], DependencyScope.Runtime)
|
||||
],
|
||||
rootRef: "app");
|
||||
|
||||
var callGraph = BuildGraph(
|
||||
nodes:
|
||||
[
|
||||
Node("sym:app.entry", "pkg:npm/app@1.0.0", "function"),
|
||||
Node("sym:lib.func", "pkg:npm/lib@1.0.0", "function")
|
||||
],
|
||||
edges: [],
|
||||
roots: []);
|
||||
|
||||
var policy = new ReachabilityPolicy
|
||||
{
|
||||
AnalysisMode = ReachabilityAnalysisMode.CallGraph
|
||||
};
|
||||
|
||||
var combiner = new ReachGraphReachabilityCombiner();
|
||||
|
||||
var report = combiner.Analyze(sbom, callGraph, policy);
|
||||
|
||||
report.ComponentReachability["lib"].Should().Be(ReachabilityStatus.Reachable);
|
||||
}
|
||||
|
||||
private static RichGraph BuildGraph(
|
||||
ImmutableArray<RichGraphNode> nodes,
|
||||
ImmutableArray<RichGraphEdge> edges,
|
||||
ImmutableArray<RichGraphRoot> roots)
|
||||
{
|
||||
return new RichGraph(
|
||||
Nodes: nodes,
|
||||
Edges: edges,
|
||||
Roots: roots,
|
||||
Analyzer: new RichGraphAnalyzer("test", "1.0.0", null));
|
||||
}
|
||||
|
||||
private static RichGraphNode Node(string id, string? purl, string kind)
|
||||
{
|
||||
return new RichGraphNode(
|
||||
Id: id,
|
||||
SymbolId: id,
|
||||
CodeId: null,
|
||||
Purl: purl,
|
||||
Lang: "node",
|
||||
Kind: kind,
|
||||
Display: id,
|
||||
BuildId: null,
|
||||
Evidence: null,
|
||||
Attributes: null,
|
||||
SymbolDigest: null,
|
||||
Symbol: null,
|
||||
CodeBlockHash: null,
|
||||
NodeHash: null);
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260119_022 TASK-022-011 - SbomOnly mode ignores call graph
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SbomOnlyMode_IgnoresCallGraph()
|
||||
{
|
||||
var sbom = BuildSbom(
|
||||
components:
|
||||
[
|
||||
Component("app", type: "application", purl: "pkg:npm/app@1.0.0"),
|
||||
Component("lib", purl: "pkg:npm/lib@1.0.0")
|
||||
],
|
||||
dependencies:
|
||||
[
|
||||
Dependency("app", ["lib"], DependencyScope.Runtime)
|
||||
],
|
||||
rootRef: "app");
|
||||
|
||||
// Call graph marks lib as unreachable
|
||||
var callGraph = BuildGraph(
|
||||
nodes:
|
||||
[
|
||||
Node("sym:app.entry", "pkg:npm/app@1.0.0", "entrypoint"),
|
||||
Node("sym:lib.func", "pkg:npm/lib@1.0.0", "function")
|
||||
],
|
||||
edges: [],
|
||||
roots: [new RichGraphRoot("sym:app.entry", "runtime", "test")]);
|
||||
|
||||
var policy = new ReachabilityPolicy
|
||||
{
|
||||
AnalysisMode = ReachabilityAnalysisMode.SbomOnly
|
||||
};
|
||||
|
||||
var combiner = new ReachGraphReachabilityCombiner();
|
||||
|
||||
var report = combiner.Analyze(sbom, callGraph, policy);
|
||||
|
||||
// In SbomOnly mode, lib should be reachable via SBOM dependency
|
||||
report.ComponentReachability["lib"].Should().Be(ReachabilityStatus.Reachable);
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260119_022 TASK-022-011 - Null call graph fallback
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Analyze_NullCallGraph_UsesSbomOnly()
|
||||
{
|
||||
var sbom = BuildSbom(
|
||||
components:
|
||||
[
|
||||
Component("app", type: "application", purl: "pkg:npm/app@1.0.0"),
|
||||
Component("lib", purl: "pkg:npm/lib@1.0.0")
|
||||
],
|
||||
dependencies:
|
||||
[
|
||||
Dependency("app", ["lib"], DependencyScope.Runtime)
|
||||
],
|
||||
rootRef: "app");
|
||||
|
||||
var policy = new ReachabilityPolicy
|
||||
{
|
||||
AnalysisMode = ReachabilityAnalysisMode.Combined
|
||||
};
|
||||
|
||||
var combiner = new ReachGraphReachabilityCombiner();
|
||||
|
||||
var report = combiner.Analyze(sbom, callGraph: null, policy);
|
||||
|
||||
report.ComponentReachability["lib"].Should().Be(ReachabilityStatus.Reachable);
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260119_022 TASK-022-011 - Statistics calculation
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Analyze_CalculatesStatistics()
|
||||
{
|
||||
var sbom = BuildSbom(
|
||||
components:
|
||||
[
|
||||
Component("app", type: "application", purl: "pkg:npm/app@1.0.0"),
|
||||
Component("lib-a", purl: "pkg:npm/lib-a@1.0.0"),
|
||||
Component("lib-b", purl: "pkg:npm/lib-b@1.0.0"),
|
||||
Component("orphan", purl: "pkg:npm/orphan@1.0.0")
|
||||
],
|
||||
dependencies:
|
||||
[
|
||||
Dependency("app", ["lib-a"], DependencyScope.Runtime),
|
||||
Dependency("app", ["lib-b"], DependencyScope.Optional)
|
||||
],
|
||||
rootRef: "app");
|
||||
|
||||
var policy = new ReachabilityPolicy
|
||||
{
|
||||
ScopeHandling = new ReachabilityScopePolicy
|
||||
{
|
||||
IncludeOptional = OptionalDependencyHandling.AsPotentiallyReachable
|
||||
}
|
||||
};
|
||||
|
||||
var combiner = new ReachGraphReachabilityCombiner();
|
||||
|
||||
var report = combiner.Analyze(sbom, callGraph: null, policy);
|
||||
|
||||
report.Statistics.TotalComponents.Should().Be(4);
|
||||
report.Statistics.ReachableComponents.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260119_022 TASK-022-011 - Empty SBOM handling
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Analyze_EmptySbom_ReturnsEmptyReport()
|
||||
{
|
||||
var sbom = BuildSbom(
|
||||
components: [],
|
||||
dependencies: []);
|
||||
|
||||
var combiner = new ReachGraphReachabilityCombiner();
|
||||
|
||||
var report = combiner.Analyze(sbom, callGraph: null, policy: null);
|
||||
|
||||
report.ComponentReachability.Should().BeEmpty();
|
||||
report.Statistics.TotalComponents.Should().Be(0);
|
||||
}
|
||||
|
||||
private static RichGraphEdge Edge(string from, string to)
|
||||
{
|
||||
return new RichGraphEdge(
|
||||
From: from,
|
||||
To: to,
|
||||
Kind: "call",
|
||||
Purl: null,
|
||||
SymbolDigest: null,
|
||||
Evidence: null,
|
||||
Confidence: 1.0,
|
||||
Candidates: null,
|
||||
Gates: null,
|
||||
GateMultiplierBps: 10000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Reachability.Dependencies;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests;
|
||||
|
||||
public sealed class ReachabilityPolicyLoaderTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task LoadAsync_ReadsJsonPolicy()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"reachability-policy-{Guid.NewGuid():N}.json");
|
||||
var json = """
|
||||
{
|
||||
"reachabilityPolicy": {
|
||||
"analysisMode": "combined",
|
||||
"scopeHandling": {
|
||||
"includeRuntime": true,
|
||||
"includeOptional": "reachable",
|
||||
"includeDevelopment": true,
|
||||
"includeTest": false
|
||||
},
|
||||
"entryPoints": {
|
||||
"detectFromSbom": false,
|
||||
"additional": ["pkg:npm/app@1.0.0"]
|
||||
},
|
||||
"vulnerabilityFiltering": {
|
||||
"filterUnreachable": false,
|
||||
"severityAdjustment": {
|
||||
"potentiallyReachable": "reduceByPercentage",
|
||||
"unreachable": "informationalOnly",
|
||||
"reduceByPercentage": 0.25
|
||||
}
|
||||
},
|
||||
"reporting": {
|
||||
"showFilteredVulnerabilities": false,
|
||||
"includeReachabilityPaths": false
|
||||
},
|
||||
"confidence": {
|
||||
"minimumConfidence": 0.5,
|
||||
"markUnknownAs": "reachable"
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
await File.WriteAllTextAsync(path, json);
|
||||
try
|
||||
{
|
||||
var loader = new ReachabilityPolicyLoader();
|
||||
var policy = await loader.LoadAsync(path);
|
||||
|
||||
policy.AnalysisMode.Should().Be(ReachabilityAnalysisMode.Combined);
|
||||
policy.ScopeHandling.IncludeOptional.Should().Be(OptionalDependencyHandling.Reachable);
|
||||
policy.ScopeHandling.IncludeDevelopment.Should().BeTrue();
|
||||
policy.EntryPoints.DetectFromSbom.Should().BeFalse();
|
||||
policy.EntryPoints.Additional.Should().ContainSingle("pkg:npm/app@1.0.0");
|
||||
policy.VulnerabilityFiltering.FilterUnreachable.Should().BeFalse();
|
||||
policy.VulnerabilityFiltering.SeverityAdjustment.ReduceByPercentage.Should().Be(0.25);
|
||||
policy.Reporting.ShowFilteredVulnerabilities.Should().BeFalse();
|
||||
policy.Reporting.IncludeReachabilityPaths.Should().BeFalse();
|
||||
policy.Confidence.MinimumConfidence.Should().Be(0.5);
|
||||
policy.Confidence.MarkUnknownAs.Should().Be(ReachabilityStatus.Reachable);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task LoadAsync_ReadsYamlPolicy()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"reachability-policy-{Guid.NewGuid():N}.yaml");
|
||||
var yaml = """
|
||||
reachabilityPolicy:
|
||||
analysisMode: callGraph
|
||||
scopeHandling:
|
||||
includeRuntime: true
|
||||
includeOptional: asPotentiallyReachable
|
||||
includeDevelopment: false
|
||||
includeTest: true
|
||||
entryPoints:
|
||||
detectFromSbom: true
|
||||
additional:
|
||||
- "pkg:maven/app@1.0.0"
|
||||
vulnerabilityFiltering:
|
||||
filterUnreachable: true
|
||||
severityAdjustment:
|
||||
potentiallyReachable: reduceBySeverityLevel
|
||||
unreachable: informationalOnly
|
||||
reduceByPercentage: 0.5
|
||||
reporting:
|
||||
showFilteredVulnerabilities: true
|
||||
includeReachabilityPaths: true
|
||||
confidence:
|
||||
minimumConfidence: 0.8
|
||||
markUnknownAs: potentiallyReachable
|
||||
""";
|
||||
|
||||
await File.WriteAllTextAsync(path, yaml);
|
||||
try
|
||||
{
|
||||
var loader = new ReachabilityPolicyLoader();
|
||||
var policy = await loader.LoadAsync(path);
|
||||
|
||||
policy.AnalysisMode.Should().Be(ReachabilityAnalysisMode.CallGraph);
|
||||
policy.ScopeHandling.IncludeOptional.Should().Be(OptionalDependencyHandling.AsPotentiallyReachable);
|
||||
policy.ScopeHandling.IncludeTest.Should().BeTrue();
|
||||
policy.EntryPoints.Additional.Should().ContainSingle("pkg:maven/app@1.0.0");
|
||||
policy.VulnerabilityFiltering.FilterUnreachable.Should().BeTrue();
|
||||
policy.Reporting.ShowFilteredVulnerabilities.Should().BeTrue();
|
||||
policy.Confidence.MarkUnknownAs.Should().Be(ReachabilityStatus.PotentiallyReachable);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Scanner.Reachability.Dependencies;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests;
|
||||
|
||||
public sealed class VulnerabilityReachabilityFilterTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Apply_FiltersUnreachableAndAdjustsSeverity()
|
||||
{
|
||||
var reachableId = Guid.NewGuid();
|
||||
var unreachableId = Guid.NewGuid();
|
||||
var matches = new[]
|
||||
{
|
||||
Match("pkg:npm/a@1.0.0", reachableId),
|
||||
Match("pkg:npm/b@1.0.0", unreachableId)
|
||||
};
|
||||
|
||||
var reachability = new Dictionary<string, ReachabilityStatus>
|
||||
{
|
||||
["pkg:npm/a@1.0.0"] = ReachabilityStatus.Reachable,
|
||||
["pkg:npm/b@1.0.0"] = ReachabilityStatus.Unreachable
|
||||
};
|
||||
|
||||
var severity = new Dictionary<Guid, string?>
|
||||
{
|
||||
[reachableId] = "high",
|
||||
[unreachableId] = "critical"
|
||||
};
|
||||
|
||||
var filter = new VulnerabilityReachabilityFilter();
|
||||
|
||||
var result = filter.Apply(matches, reachability, null, severity);
|
||||
|
||||
result.Matches.Should().ContainSingle(match => match.Purl == "pkg:npm/a@1.0.0");
|
||||
result.Filtered.Should().ContainSingle(adjustment =>
|
||||
adjustment.Match.Purl == "pkg:npm/b@1.0.0" &&
|
||||
adjustment.AdjustedSeverity == "informational");
|
||||
result.Statistics.FilteredVulnerabilities.Should().Be(1);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Apply_ReducesSeverityForPotentiallyReachable()
|
||||
{
|
||||
var canonicalId = Guid.NewGuid();
|
||||
var matches = new[] { Match("pkg:npm/a@1.0.0", canonicalId) };
|
||||
|
||||
var reachability = new Dictionary<string, ReachabilityStatus>
|
||||
{
|
||||
["pkg:npm/a@1.0.0"] = ReachabilityStatus.PotentiallyReachable
|
||||
};
|
||||
|
||||
var severity = new Dictionary<Guid, string?>
|
||||
{
|
||||
[canonicalId] = "critical"
|
||||
};
|
||||
|
||||
var policy = new ReachabilityPolicy
|
||||
{
|
||||
VulnerabilityFiltering = new ReachabilityVulnerabilityFilteringPolicy
|
||||
{
|
||||
FilterUnreachable = false,
|
||||
SeverityAdjustment = new ReachabilitySeverityAdjustmentPolicy
|
||||
{
|
||||
PotentiallyReachable = ReachabilitySeverityAdjustment.ReduceBySeverityLevel
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var filter = new VulnerabilityReachabilityFilter();
|
||||
|
||||
var result = filter.Apply(matches, reachability, policy, severity);
|
||||
|
||||
result.Adjustments.Should().ContainSingle(adjustment =>
|
||||
adjustment.AdjustedSeverity == "high" &&
|
||||
adjustment.Match.IsReachable);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Apply_UsesUnknownPolicyForMissingReachability()
|
||||
{
|
||||
var matches = new[] { Match("pkg:npm/a@1.0.0", Guid.NewGuid()) };
|
||||
|
||||
var policy = new ReachabilityPolicy
|
||||
{
|
||||
Confidence = new ReachabilityConfidencePolicy
|
||||
{
|
||||
MarkUnknownAs = ReachabilityStatus.Unreachable
|
||||
}
|
||||
};
|
||||
|
||||
var filter = new VulnerabilityReachabilityFilter();
|
||||
|
||||
var result = filter.Apply(matches, (IReadOnlyDictionary<string, ReachabilityStatus>?)null, policy, null);
|
||||
|
||||
result.Matches.Should().BeEmpty();
|
||||
result.Filtered.Should().ContainSingle();
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260119_022 TASK-022-011 - No filtering when policy disabled
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Apply_FilteringDisabled_ReturnsAllMatches()
|
||||
{
|
||||
var id1 = Guid.NewGuid();
|
||||
var id2 = Guid.NewGuid();
|
||||
var matches = new[]
|
||||
{
|
||||
Match("pkg:npm/a@1.0.0", id1),
|
||||
Match("pkg:npm/b@1.0.0", id2)
|
||||
};
|
||||
|
||||
var reachability = new Dictionary<string, ReachabilityStatus>
|
||||
{
|
||||
["pkg:npm/a@1.0.0"] = ReachabilityStatus.Reachable,
|
||||
["pkg:npm/b@1.0.0"] = ReachabilityStatus.Unreachable
|
||||
};
|
||||
|
||||
var policy = new ReachabilityPolicy
|
||||
{
|
||||
VulnerabilityFiltering = new ReachabilityVulnerabilityFilteringPolicy
|
||||
{
|
||||
FilterUnreachable = false
|
||||
}
|
||||
};
|
||||
|
||||
var filter = new VulnerabilityReachabilityFilter();
|
||||
|
||||
var result = filter.Apply(matches, reachability, policy, null);
|
||||
|
||||
result.Matches.Should().HaveCount(2);
|
||||
result.Filtered.Should().BeEmpty();
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260119_022 TASK-022-011 - Empty input
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Apply_EmptyMatches_ReturnsEmptyResult()
|
||||
{
|
||||
var filter = new VulnerabilityReachabilityFilter();
|
||||
var emptyReachability = (IReadOnlyDictionary<string, ReachabilityStatus>?)null;
|
||||
|
||||
var result = filter.Apply([], emptyReachability, null, null);
|
||||
|
||||
result.Matches.Should().BeEmpty();
|
||||
result.Filtered.Should().BeEmpty();
|
||||
result.Adjustments.Should().BeEmpty();
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260119_022 TASK-022-011 - Severity reduction percentage
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Apply_ReduceByPercentage_AppliesCorrectReduction()
|
||||
{
|
||||
var canonicalId = Guid.NewGuid();
|
||||
var matches = new[] { Match("pkg:npm/a@1.0.0", canonicalId) };
|
||||
|
||||
var reachability = new Dictionary<string, ReachabilityStatus>
|
||||
{
|
||||
["pkg:npm/a@1.0.0"] = ReachabilityStatus.PotentiallyReachable
|
||||
};
|
||||
|
||||
var severity = new Dictionary<Guid, string?>
|
||||
{
|
||||
[canonicalId] = "critical"
|
||||
};
|
||||
|
||||
var policy = new ReachabilityPolicy
|
||||
{
|
||||
VulnerabilityFiltering = new ReachabilityVulnerabilityFilteringPolicy
|
||||
{
|
||||
FilterUnreachable = false,
|
||||
SeverityAdjustment = new ReachabilitySeverityAdjustmentPolicy
|
||||
{
|
||||
PotentiallyReachable = ReachabilitySeverityAdjustment.ReduceByPercentage,
|
||||
ReduceByPercentage = 0.5
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var filter = new VulnerabilityReachabilityFilter();
|
||||
|
||||
var result = filter.Apply(matches, reachability, policy, severity);
|
||||
|
||||
// Verify adjustment was made
|
||||
result.Adjustments.Should().ContainSingle();
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260119_022 TASK-022-011 - All components reachable
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Apply_AllReachable_NoFiltering()
|
||||
{
|
||||
var id1 = Guid.NewGuid();
|
||||
var id2 = Guid.NewGuid();
|
||||
var matches = new[]
|
||||
{
|
||||
Match("pkg:npm/a@1.0.0", id1),
|
||||
Match("pkg:npm/b@1.0.0", id2)
|
||||
};
|
||||
|
||||
var reachability = new Dictionary<string, ReachabilityStatus>
|
||||
{
|
||||
["pkg:npm/a@1.0.0"] = ReachabilityStatus.Reachable,
|
||||
["pkg:npm/b@1.0.0"] = ReachabilityStatus.Reachable
|
||||
};
|
||||
|
||||
var filter = new VulnerabilityReachabilityFilter();
|
||||
|
||||
var result = filter.Apply(matches, reachability, null, null);
|
||||
|
||||
result.Matches.Should().HaveCount(2);
|
||||
result.Filtered.Should().BeEmpty();
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260119_022 TASK-022-011 - Case-insensitive PURL matching
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Apply_CaseInsensitivePurl_MatchesCorrectly()
|
||||
{
|
||||
var canonicalId = Guid.NewGuid();
|
||||
var matches = new[] { Match("pkg:NPM/MyPackage@1.0.0", canonicalId) };
|
||||
|
||||
var reachability = new Dictionary<string, ReachabilityStatus>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["pkg:npm/mypackage@1.0.0"] = ReachabilityStatus.Unreachable
|
||||
};
|
||||
|
||||
var filter = new VulnerabilityReachabilityFilter();
|
||||
|
||||
var result = filter.Apply(matches, reachability, null, null);
|
||||
|
||||
result.Matches.Should().BeEmpty();
|
||||
result.Filtered.Should().ContainSingle();
|
||||
}
|
||||
|
||||
private static SbomAdvisoryMatch Match(string purl, Guid canonicalId)
|
||||
{
|
||||
return new SbomAdvisoryMatch
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SbomId = Guid.NewGuid(),
|
||||
SbomDigest = "sha256:test",
|
||||
CanonicalId = canonicalId,
|
||||
Purl = purl,
|
||||
Method = MatchMethod.ExactPurl,
|
||||
IsReachable = false,
|
||||
IsDeployed = false,
|
||||
MatchedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user