release orchestrator v1 draft and build fixes

This commit is contained in:
master
2026-01-12 12:24:17 +02:00
parent f3de858c59
commit 9873f80830
1598 changed files with 240385 additions and 5944 deletions

View File

@@ -0,0 +1,388 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
// Sprint: EVID-001-005 - Binary Patch Verifier Tests
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.BinaryIndex.Decompiler;
using StellaOps.BinaryIndex.Ghidra;
using StellaOps.Scanner.Explainability.Assumptions;
using StellaOps.Scanner.Reachability.Binary;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests.Evidence;
/// <summary>
/// Unit tests for <see cref="BinaryPatchVerifier"/>.
/// </summary>
public sealed class BinaryPatchVerifierTests
{
private readonly MockGhidraService _ghidraService;
private readonly MockDecompilerService _decompilerService;
private readonly BinaryPatchVerifier _sut;
public BinaryPatchVerifierTests()
{
_ghidraService = new MockGhidraService();
_decompilerService = new MockDecompilerService();
_sut = new BinaryPatchVerifier(
_ghidraService,
_decompilerService,
NullLogger<BinaryPatchVerifier>.Instance);
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData(".so", true)]
[InlineData(".dll", true)]
[InlineData(".exe", true)]
[InlineData(".dylib", true)]
[InlineData(".elf", true)]
[InlineData(".txt", false)]
[InlineData(".js", false)]
public void IsSupported_ChecksFileExtension(string extension, bool expected)
{
var result = _sut.IsSupported($"/path/to/binary{extension}");
Assert.Equal(expected, result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void IsSupported_EmptyPath_ReturnsFalse()
{
Assert.False(_sut.IsSupported(""));
Assert.False(_sut.IsSupported(null!));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyPatchAsync_IdenticalFunctions_ReturnsVulnerable()
{
// Setup identical P-Code hashes
var pCodeHash = new byte[] { 1, 2, 3, 4 };
_ghidraService.SetupAnalysis("/vulnerable.so", CreateAnalysis("vuln_func", pCodeHash));
_ghidraService.SetupAnalysis("/target.so", CreateAnalysis("vuln_func", pCodeHash));
var request = new PatchVerificationRequest
{
VulnerableBinaryReference = "/vulnerable.so",
TargetBinaryPath = "/target.so",
CveId = "CVE-2024-1234",
TargetSymbols = [new VulnerableSymbol { Name = "vuln_func" }]
};
var result = await _sut.VerifyPatchAsync(request, CancellationToken.None);
Assert.True(result.Success);
Assert.Equal(PatchStatus.Vulnerable, result.Status);
Assert.Single(result.FunctionResults);
Assert.False(result.FunctionResults[0].IsPatched);
Assert.Equal(1.0m, result.FunctionResults[0].Similarity);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyPatchAsync_DifferentFunctions_ReturnsPatched()
{
// Setup different P-Code hashes to force decompilation comparison
_ghidraService.SetupAnalysis("/vulnerable.so", CreateAnalysis("vuln_func", new byte[] { 1, 2, 3 }));
_ghidraService.SetupAnalysis("/target.so", CreateAnalysis("vuln_func", new byte[] { 4, 5, 6 }));
// Setup low similarity comparison result
_decompilerService.SetupComparison(0.3m, ComparisonConfidence.High);
var request = new PatchVerificationRequest
{
VulnerableBinaryReference = "/vulnerable.so",
TargetBinaryPath = "/target.so",
CveId = "CVE-2024-1234",
TargetSymbols = [new VulnerableSymbol { Name = "vuln_func" }]
};
var result = await _sut.VerifyPatchAsync(request, CancellationToken.None);
Assert.True(result.Success);
Assert.Equal(PatchStatus.Patched, result.Status);
Assert.Single(result.FunctionResults);
Assert.True(result.FunctionResults[0].IsPatched);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyPatchAsync_FunctionRemoved_ReturnsPatched()
{
// Function exists in vulnerable but not in target
_ghidraService.SetupAnalysis("/vulnerable.so", CreateAnalysis("vuln_func", new byte[] { 1, 2, 3 }));
_ghidraService.SetupAnalysis("/target.so", CreateAnalysis("other_func", new byte[] { 4, 5, 6 }));
var request = new PatchVerificationRequest
{
VulnerableBinaryReference = "/vulnerable.so",
TargetBinaryPath = "/target.so",
CveId = "CVE-2024-1234",
TargetSymbols = [new VulnerableSymbol { Name = "vuln_func" }]
};
var result = await _sut.VerifyPatchAsync(request, CancellationToken.None);
Assert.True(result.Success);
Assert.Equal(PatchStatus.Patched, result.Status);
Assert.Contains("removed", result.FunctionResults[0].Differences[0]);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyPatchAsync_MixedResults_ReturnsPartiallyPatched()
{
// Two functions: one patched, one not
var analysis = CreateAnalysisWithMultipleFunctions(
("func1", new byte[] { 1, 2, 3 }),
("func2", new byte[] { 4, 5, 6 }));
_ghidraService.SetupAnalysis("/vulnerable.so", analysis);
_ghidraService.SetupAnalysis("/target.so", CreateAnalysisWithMultipleFunctions(
("func1", new byte[] { 1, 2, 3 }), // Same - not patched
("func2", new byte[] { 7, 8, 9 }))); // Different - patched
_decompilerService.SetupComparison(0.3m, ComparisonConfidence.High);
var request = new PatchVerificationRequest
{
VulnerableBinaryReference = "/vulnerable.so",
TargetBinaryPath = "/target.so",
CveId = "CVE-2024-1234",
TargetSymbols =
[
new VulnerableSymbol { Name = "func1" },
new VulnerableSymbol { Name = "func2" }
]
};
var result = await _sut.VerifyPatchAsync(request, CancellationToken.None);
Assert.True(result.Success);
Assert.Equal(PatchStatus.PartiallyPatched, result.Status);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyPatchAsync_AnalysisFails_ReturnsUnknown()
{
_ghidraService.SetupAnalysisFailure("/vulnerable.so");
var request = new PatchVerificationRequest
{
VulnerableBinaryReference = "/vulnerable.so",
TargetBinaryPath = "/target.so",
CveId = "CVE-2024-1234",
TargetSymbols = [new VulnerableSymbol { Name = "vuln_func" }]
};
var result = await _sut.VerifyPatchAsync(request, CancellationToken.None);
Assert.False(result.Success);
Assert.Equal(PatchStatus.Unknown, result.Status);
Assert.NotNull(result.Error);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyPatchAsync_BuildsCorrectLayer2()
{
var pCodeHash = new byte[] { 1, 2, 3, 4 };
_ghidraService.SetupAnalysis("/vulnerable.so", CreateAnalysis("vuln_func", pCodeHash));
_ghidraService.SetupAnalysis("/target.so", CreateAnalysis("vuln_func", pCodeHash));
var request = new PatchVerificationRequest
{
VulnerableBinaryReference = "/vulnerable.so",
TargetBinaryPath = "/target.so",
CveId = "CVE-2024-1234",
TargetSymbols = [new VulnerableSymbol { Name = "vuln_func" }]
};
var result = await _sut.VerifyPatchAsync(request, CancellationToken.None);
Assert.True(result.Layer2.IsResolved);
Assert.Equal(ConfidenceLevel.High, result.Layer2.Confidence);
Assert.Contains("identical", result.Layer2.Reason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CompareFunctionAsync_ComparesSpecificFunction()
{
_ghidraService.SetupAnalysis("/vuln.so", CreateAnalysis("target_func", new byte[] { 1, 2, 3 }));
_ghidraService.SetupAnalysis("/new.so", CreateAnalysis("target_func", new byte[] { 4, 5, 6 }));
_decompilerService.SetupComparison(0.5m, ComparisonConfidence.Medium);
var result = await _sut.CompareFunctionAsync(
"/vuln.so", "/new.so", "target_func", CancellationToken.None);
Assert.True(result.Success);
Assert.Equal("target_func", result.SymbolName);
Assert.Equal(0.5m, result.Similarity);
}
private static GhidraAnalysisResult CreateAnalysis(string funcName, byte[] pCodeHash)
{
return new GhidraAnalysisResult(
BinaryHash: "abc123",
Functions: ImmutableArray.Create(new GhidraFunction(
Name: funcName,
Address: 0x1000,
Size: 100,
Signature: $"void {funcName}(void)",
DecompiledCode: "void func() { }",
PCodeHash: pCodeHash,
CalledFunctions: [],
CallingFunctions: [])),
Imports: [],
Exports: [],
Strings: [],
MemoryBlocks: [],
Metadata: CreateMetadata());
}
private static GhidraAnalysisResult CreateAnalysisWithMultipleFunctions(
params (string name, byte[] hash)[] functions)
{
var ghidraFunctions = functions.Select(f => new GhidraFunction(
Name: f.name,
Address: 0x1000,
Size: 100,
Signature: $"void {f.name}(void)",
DecompiledCode: "void func() { }",
PCodeHash: f.hash,
CalledFunctions: [],
CallingFunctions: [])).ToImmutableArray();
return new GhidraAnalysisResult(
BinaryHash: "abc123",
Functions: ghidraFunctions,
Imports: [],
Exports: [],
Strings: [],
MemoryBlocks: [],
Metadata: CreateMetadata());
}
private static GhidraMetadata CreateMetadata()
{
return new GhidraMetadata(
FileName: "test.so",
Format: "ELF",
Architecture: "x86_64",
Processor: "x86:LE:64:default",
Compiler: null,
Endianness: "LE",
AddressSize: 64,
ImageBase: 0x400000,
EntryPoint: 0x401000,
AnalysisDate: DateTimeOffset.UtcNow,
GhidraVersion: "11.0",
AnalysisDuration: TimeSpan.FromSeconds(5));
}
}
internal sealed class MockGhidraService : IGhidraService
{
private readonly Dictionary<string, GhidraAnalysisResult> _analyses = new();
private readonly HashSet<string> _failures = new();
public void SetupAnalysis(string path, GhidraAnalysisResult result)
=> _analyses[path] = result;
public void SetupAnalysisFailure(string path)
=> _failures.Add(path);
public Task<GhidraAnalysisResult> AnalyzeAsync(
Stream binaryStream,
GhidraAnalysisOptions? options = null,
CancellationToken ct = default)
=> throw new NotImplementedException();
public Task<GhidraAnalysisResult> AnalyzeAsync(
string binaryPath,
GhidraAnalysisOptions? options = null,
CancellationToken ct = default)
{
if (_failures.Contains(binaryPath))
throw new InvalidOperationException("Analysis failed");
if (_analyses.TryGetValue(binaryPath, out var result))
return Task.FromResult(result);
throw new InvalidOperationException($"No analysis configured for {binaryPath}");
}
public Task<bool> IsAvailableAsync(CancellationToken ct = default)
=> Task.FromResult(true);
public Task<GhidraInfo> GetInfoAsync(CancellationToken ct = default)
=> Task.FromResult(new GhidraInfo("11.0", "21", [], "/opt/ghidra"));
}
internal sealed class MockDecompilerService : IDecompilerService
{
private decimal _similarity = 0.9m;
private ComparisonConfidence _confidence = ComparisonConfidence.High;
public void SetupComparison(decimal similarity, ComparisonConfidence confidence)
{
_similarity = similarity;
_confidence = confidence;
}
public Task<DecompiledFunction> DecompileAsync(
GhidraFunction function,
DecompileOptions? options = null,
CancellationToken ct = default)
{
var ast = new DecompiledAst(
Root: new BlockNode(ImmutableArray<AstNode>.Empty),
NodeCount: 10,
Depth: 3,
Patterns: []);
return Task.FromResult(new DecompiledFunction(
FunctionName: function.Name,
Signature: function.Signature ?? "void func()",
Code: function.DecompiledCode ?? "void func() { }",
Ast: ast,
Locals: [],
CalledFunctions: [],
Address: function.Address,
SizeBytes: function.Size));
}
public Task<DecompiledFunction> DecompileAtAddressAsync(
string binaryPath,
ulong address,
DecompileOptions? options = null,
CancellationToken ct = default)
=> throw new NotImplementedException();
public Task<DecompiledAst> ParseToAstAsync(
string decompiledCode,
CancellationToken ct = default)
=> throw new NotImplementedException();
public Task<DecompiledComparisonResult> CompareAsync(
DecompiledFunction a,
DecompiledFunction b,
ComparisonOptions? options = null,
CancellationToken ct = default)
{
return Task.FromResult(new DecompiledComparisonResult(
Similarity: _similarity,
StructuralSimilarity: _similarity,
SemanticSimilarity: _similarity,
EditDistance: new AstEditDistance(0, 0, 0, 0, 1 - _similarity),
Equivalences: [],
Differences: [],
Confidence: _confidence));
}
}

View File

@@ -0,0 +1,204 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
// Sprint: EVID-001-005 - CVE Symbol Mapping Service Tests
using StellaOps.Scanner.Reachability.Services;
using StellaOps.Scanner.Reachability.Stack;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests.Evidence;
/// <summary>
/// Unit tests for <see cref="ICveSymbolMappingService"/> implementations.
/// Uses in-memory implementation for unit testing.
/// </summary>
public sealed class CveSymbolMappingServiceTests
{
private readonly InMemoryCveSymbolMappingService _sut;
public CveSymbolMappingServiceTests()
{
_sut = new InMemoryCveSymbolMappingService();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task HasMappingAsync_NoMappings_ReturnsFalse()
{
var result = await _sut.HasMappingAsync("CVE-2024-1234", CancellationToken.None);
Assert.False(result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task HasMappingAsync_WithMapping_ReturnsTrue()
{
await _sut.UpsertMappingAsync(CreateMapping("CVE-2024-1234"), CancellationToken.None);
var result = await _sut.HasMappingAsync("CVE-2024-1234", CancellationToken.None);
Assert.True(result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetSinksForCveAsync_ReturnsMatchingMappings()
{
var mapping1 = CreateMapping("CVE-2024-1234", "pkg:npm/test@1.0", "sink1");
var mapping2 = CreateMapping("CVE-2024-1234", "pkg:npm/test@1.0", "sink2");
var mapping3 = CreateMapping("CVE-2024-1234", "pkg:npm/other@1.0", "sink3");
await _sut.UpsertMappingAsync(mapping1, CancellationToken.None);
await _sut.UpsertMappingAsync(mapping2, CancellationToken.None);
await _sut.UpsertMappingAsync(mapping3, CancellationToken.None);
var result = await _sut.GetSinksForCveAsync(
"CVE-2024-1234", "pkg:npm/test@1.0", CancellationToken.None);
Assert.Equal(2, result.Count);
Assert.All(result, m => Assert.Equal("pkg:npm/test@1.0", m.Purl));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetAllMappingsForCveAsync_ReturnsAllMappingsForCve()
{
var mapping1 = CreateMapping("CVE-2024-1234", "pkg:npm/test@1.0", "sink1");
var mapping2 = CreateMapping("CVE-2024-1234", "pkg:npm/other@1.0", "sink2");
var mapping3 = CreateMapping("CVE-2024-5678", "pkg:npm/test@1.0", "sink3");
await _sut.UpsertMappingAsync(mapping1, CancellationToken.None);
await _sut.UpsertMappingAsync(mapping2, CancellationToken.None);
await _sut.UpsertMappingAsync(mapping3, CancellationToken.None);
var result = await _sut.GetAllMappingsForCveAsync("CVE-2024-1234", CancellationToken.None);
Assert.Equal(2, result.Count);
Assert.All(result, m => Assert.Equal("CVE-2024-1234", m.CveId));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task UpsertMappingAsync_UpdatesExisting()
{
var original = CreateMapping("CVE-2024-1234", "pkg:npm/test@1.0", "sink1", 0.5m);
var updated = CreateMapping("CVE-2024-1234", "pkg:npm/test@1.0", "sink1", 0.9m);
await _sut.UpsertMappingAsync(original, CancellationToken.None);
await _sut.UpsertMappingAsync(updated, CancellationToken.None);
var result = await _sut.GetSinksForCveAsync(
"CVE-2024-1234", "pkg:npm/test@1.0", CancellationToken.None);
Assert.Single(result);
Assert.Equal(0.9m, result[0].Confidence);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetMappingCountAsync_ReturnsCorrectCount()
{
await _sut.UpsertMappingAsync(CreateMapping("CVE-1"), CancellationToken.None);
await _sut.UpsertMappingAsync(CreateMapping("CVE-2"), CancellationToken.None);
await _sut.UpsertMappingAsync(CreateMapping("CVE-3"), CancellationToken.None);
var count = await _sut.GetMappingCountAsync(CancellationToken.None);
Assert.Equal(3, count);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CveSinkMapping_ToVulnerableSymbol_ConvertsCorrectly()
{
var mapping = new CveSinkMapping
{
CveId = "CVE-2024-1234",
SymbolName = "vulnerable_function",
CanonicalId = "org.test.Vuln#vulnerable_function()",
Purl = "pkg:maven/org.test/vuln@1.0.0",
FilePath = "src/main/java/org/test/Vuln.java",
VulnType = VulnerabilityType.Sink,
Confidence = 0.95m,
Source = MappingSource.NvdCpe
};
var symbol = mapping.ToVulnerableSymbol();
Assert.Equal("vulnerable_function", symbol.Name);
Assert.Equal("CVE-2024-1234", symbol.VulnerabilityId);
Assert.Equal(SymbolType.Function, symbol.Type);
}
private static CveSinkMapping CreateMapping(
string cveId,
string purl = "pkg:npm/test@1.0.0",
string symbolName = "vulnerable_func",
decimal confidence = 0.9m)
{
return new CveSinkMapping
{
CveId = cveId,
SymbolName = symbolName,
CanonicalId = $"{purl}#{symbolName}",
Purl = purl,
FilePath = null,
VulnType = VulnerabilityType.Sink,
Confidence = confidence,
Source = MappingSource.ManualCuration
};
}
}
/// <summary>
/// In-memory implementation for testing.
/// </summary>
internal sealed class InMemoryCveSymbolMappingService : ICveSymbolMappingService
{
private readonly List<CveSinkMapping> _mappings = [];
public Task<IReadOnlyList<CveSinkMapping>> GetSinksForCveAsync(
string cveId, string purl, CancellationToken ct = default)
{
var result = _mappings
.Where(m => m.CveId == cveId && m.Purl == purl)
.ToList();
return Task.FromResult<IReadOnlyList<CveSinkMapping>>(result);
}
public Task<bool> HasMappingAsync(string cveId, CancellationToken ct = default)
{
return Task.FromResult(_mappings.Any(m => m.CveId == cveId));
}
public Task<IReadOnlyList<CveSinkMapping>> GetAllMappingsForCveAsync(
string cveId, CancellationToken ct = default)
{
var result = _mappings.Where(m => m.CveId == cveId).ToList();
return Task.FromResult<IReadOnlyList<CveSinkMapping>>(result);
}
public Task<CveSinkMapping> UpsertMappingAsync(
CveSinkMapping mapping, CancellationToken ct = default)
{
var existing = _mappings.FirstOrDefault(m =>
m.CveId == mapping.CveId &&
m.SymbolName == mapping.SymbolName &&
m.Purl == mapping.Purl);
if (existing is not null)
{
_mappings.Remove(existing);
}
_mappings.Add(mapping);
return Task.FromResult(mapping);
}
public Task<int> GetMappingCountAsync(CancellationToken ct = default)
{
return Task.FromResult(_mappings.Count);
}
}

View File

@@ -0,0 +1,311 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
// Sprint: EVID-001-005 - Runtime Reachability Collector Tests
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.Explainability.Assumptions;
using StellaOps.Scanner.Reachability.Runtime;
using StellaOps.Scanner.Reachability.Stack;
using StellaOps.Signals.Ebpf.Schema;
using StellaOps.Signals.Ebpf.Services;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests.Evidence;
/// <summary>
/// Unit tests for <see cref="EbpfRuntimeReachabilityCollector"/>.
/// </summary>
public sealed class RuntimeReachabilityCollectorTests
{
private readonly MockSignalCollector _signalCollector;
private readonly MockObservationStore _observationStore;
private readonly FakeTimeProvider _timeProvider;
private readonly EbpfRuntimeReachabilityCollector _sut;
public RuntimeReachabilityCollectorTests()
{
_signalCollector = new MockSignalCollector();
_observationStore = new MockObservationStore();
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
_sut = new EbpfRuntimeReachabilityCollector(
_signalCollector,
_observationStore,
NullLogger<EbpfRuntimeReachabilityCollector>.Instance,
_timeProvider);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void IsAvailable_ReturnsSignalCollectorAvailability()
{
_signalCollector.SetSupported(true);
// Note: Also requires Linux, so this will be false on Windows
var result = _sut.IsAvailable;
// On non-Linux, this should be false even if signal collector is supported
Assert.False(result); // Running on Windows
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Platform_ReturnsUnsupportedOnNonLinux()
{
Assert.Equal("unsupported", _sut.Platform);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ObserveAsync_WithHistoricalData_ReturnsHistoricalResult()
{
var observations = new List<SymbolObservation>
{
new()
{
Symbol = "vulnerable_func",
WasObserved = true,
ObservationCount = 5,
FirstObservedAt = DateTimeOffset.UtcNow.AddHours(-1),
LastObservedAt = DateTimeOffset.UtcNow.AddMinutes(-5)
}
};
_observationStore.SetObservations("container-123", observations);
var request = new RuntimeObservationRequest
{
ContainerId = "container-123",
ImageDigest = "sha256:abc123",
TargetSymbols = ["vulnerable_func"],
UseHistoricalData = true
};
var result = await _sut.ObserveAsync(request, CancellationToken.None);
Assert.True(result.Success);
Assert.Equal(ObservationSource.Historical, result.Source);
Assert.Single(result.Observations);
Assert.True(result.Observations[0].WasObserved);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ObserveAsync_NoHistoricalData_ReturnsUnavailableOnNonLinux()
{
_observationStore.SetObservations("container-123", []);
var request = new RuntimeObservationRequest
{
ContainerId = "container-123",
ImageDigest = "sha256:abc123",
TargetSymbols = ["vulnerable_func"],
UseHistoricalData = true
};
var result = await _sut.ObserveAsync(request, CancellationToken.None);
// On non-Linux, live observation is not available
Assert.False(result.Success);
Assert.Equal(ObservationSource.None, result.Source);
Assert.Contains("not available", result.Error);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ObserveAsync_SymbolsObserved_ReturnsNotGatedLayer3()
{
var observations = new List<SymbolObservation>
{
new()
{
Symbol = "target_sink",
WasObserved = true,
ObservationCount = 10
}
};
_observationStore.SetObservations("container-abc", observations);
var request = new RuntimeObservationRequest
{
ContainerId = "container-abc",
ImageDigest = "sha256:def456",
TargetSymbols = ["target_sink"],
UseHistoricalData = true
};
var result = await _sut.ObserveAsync(request, CancellationToken.None);
Assert.True(result.Success);
Assert.False(result.Layer3.IsGated);
Assert.Equal(GatingOutcome.NotGated, result.Layer3.Outcome);
Assert.Equal(ConfidenceLevel.High, result.Layer3.Confidence);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ObserveAsync_NoSymbolsObserved_ReturnsUnknownOutcome()
{
var observations = new List<SymbolObservation>
{
new()
{
Symbol = "target_sink",
WasObserved = false,
ObservationCount = 0
}
};
_observationStore.SetObservations("container-xyz", observations);
var request = new RuntimeObservationRequest
{
ContainerId = "container-xyz",
ImageDigest = "sha256:ghi789",
TargetSymbols = ["target_sink"],
UseHistoricalData = true
};
var result = await _sut.ObserveAsync(request, CancellationToken.None);
Assert.True(result.Success);
Assert.Equal(GatingOutcome.Unknown, result.Layer3.Outcome);
Assert.Equal(ConfidenceLevel.Medium, result.Layer3.Confidence);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CheckObservationsAsync_DelegatesToStore()
{
var observations = new List<SymbolObservation>
{
new() { Symbol = "func1", WasObserved = true, ObservationCount = 3 },
new() { Symbol = "func2", WasObserved = false, ObservationCount = 0 }
};
_observationStore.SetObservations("container-check", observations);
var result = await _sut.CheckObservationsAsync(
"container-check",
["func1", "func2"],
CancellationToken.None);
Assert.Equal(2, result.Count);
Assert.True(result[0].WasObserved);
Assert.False(result[1].WasObserved);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ObserveAsync_Exception_ReturnsFailedResult()
{
_observationStore.ThrowOnGet = true;
var request = new RuntimeObservationRequest
{
ContainerId = "container-fail",
ImageDigest = "sha256:fail",
TargetSymbols = ["func"],
UseHistoricalData = true
};
var result = await _sut.ObserveAsync(request, CancellationToken.None);
Assert.False(result.Success);
Assert.NotNull(result.Error);
Assert.Equal(GatingOutcome.Unknown, result.Layer3.Outcome);
}
}
internal sealed class MockSignalCollector : IRuntimeSignalCollector
{
private bool _isSupported = true;
private readonly RuntimeSignalOptions _defaultOptions = new()
{
TargetSymbols = [],
MaxEventsPerSecond = 10000
};
public void SetSupported(bool supported) => _isSupported = supported;
public bool IsSupported() => _isSupported;
public IReadOnlyList<ProbeType> GetSupportedProbeTypes() => [ProbeType.Uprobe, ProbeType.Uretprobe];
public Task<SignalCollectionHandle> StartCollectionAsync(
string containerId,
RuntimeSignalOptions options,
CancellationToken ct = default)
{
return Task.FromResult(new SignalCollectionHandle
{
SessionId = Guid.NewGuid(),
ContainerId = containerId,
StartedAt = DateTimeOffset.UtcNow,
Options = options ?? _defaultOptions
});
}
public Task<RuntimeSignalSummary> StopCollectionAsync(
SignalCollectionHandle handle,
CancellationToken ct = default)
{
return Task.FromResult(new RuntimeSignalSummary
{
ContainerId = handle.ContainerId,
StartedAt = handle.StartedAt,
StoppedAt = DateTimeOffset.UtcNow,
TotalEvents = 100,
ObservedSymbols = ["func1", "func2"],
CallPaths = []
});
}
public Task<SignalStatistics> GetStatisticsAsync(
SignalCollectionHandle handle,
CancellationToken ct = default)
{
return Task.FromResult(new SignalStatistics
{
TotalEvents = 100,
DroppedEvents = 0,
EventsPerSecond = 50,
UniqueCallPaths = 5,
BufferUtilization = 0.25
});
}
}
internal sealed class MockObservationStore : IRuntimeObservationStore
{
private readonly Dictionary<string, IReadOnlyList<SymbolObservation>> _observations = new();
public bool ThrowOnGet { get; set; }
public void SetObservations(string containerId, IReadOnlyList<SymbolObservation> observations)
=> _observations[containerId] = observations;
public Task<IReadOnlyList<SymbolObservation>> GetObservationsAsync(
string containerId,
IReadOnlyList<string> symbols,
CancellationToken ct = default)
{
if (ThrowOnGet)
throw new InvalidOperationException("Store error");
if (_observations.TryGetValue(containerId, out var obs))
return Task.FromResult(obs);
return Task.FromResult<IReadOnlyList<SymbolObservation>>([]);
}
public Task StoreObservationAsync(
string containerId,
string imageDigest,
SymbolObservation observation,
CancellationToken ct = default)
{
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,235 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
// Sprint: EVID-001-005 - VEX Status Determiner Tests
using System.Collections.Immutable;
using StellaOps.Scanner.Explainability.Assumptions;
using StellaOps.Scanner.Reachability.Stack;
using StellaOps.Scanner.Reachability.Vex;
using StellaOps.TestKit;
using Xunit;
using StackCallPath = StellaOps.Scanner.Reachability.Stack.CallPath;
namespace StellaOps.Scanner.Reachability.Tests.Evidence;
/// <summary>
/// Unit tests for <see cref="VexStatusDeterminer"/>.
/// </summary>
public sealed class VexStatusDeterminerTests
{
private readonly VexStatusDeterminer _sut;
private readonly FakeTimeProvider _timeProvider;
public VexStatusDeterminerTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero));
_sut = new VexStatusDeterminer(_timeProvider);
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData(ReachabilityVerdict.Exploitable, VexStatus.Affected)]
[InlineData(ReachabilityVerdict.LikelyExploitable, VexStatus.Affected)]
[InlineData(ReachabilityVerdict.PossiblyExploitable, VexStatus.UnderInvestigation)]
[InlineData(ReachabilityVerdict.Unreachable, VexStatus.NotAffected)]
[InlineData(ReachabilityVerdict.Unknown, VexStatus.UnderInvestigation)]
public void DetermineStatus_MapsVerdictToVexStatus(
ReachabilityVerdict verdict,
VexStatus expectedStatus)
{
var result = _sut.DetermineStatus(verdict);
Assert.Equal(expectedStatus, result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void BuildJustification_Unreachable_ReturnsVulnerableCodeNotReachable()
{
var stack = CreateStack(ReachabilityVerdict.Unreachable, isL1Reachable: false);
var justification = _sut.BuildJustification(stack, ["evidence://bundle/123"]);
Assert.Equal(VexJustificationCategory.VulnerableCodeNotReachable, justification.Category);
Assert.Contains("not reachable", justification.Detail);
Assert.Single(justification.EvidenceReferences);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void BuildJustification_Exploitable_ReturnsRequiresDependency()
{
var stack = CreateStack(ReachabilityVerdict.Exploitable, isL1Reachable: true);
var justification = _sut.BuildJustification(stack, []);
Assert.Equal(VexJustificationCategory.RequiresDependency, justification.Category);
Assert.Contains("reachable", justification.Detail);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void BuildJustification_LikelyExploitable_ReturnsRequiresConfiguration()
{
var stack = CreateStack(ReachabilityVerdict.LikelyExploitable, isL1Reachable: true);
var justification = _sut.BuildJustification(stack, []);
Assert.Equal(VexJustificationCategory.RequiresConfiguration, justification.Category);
Assert.Contains("likely exploitable", justification.Detail);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void BuildJustification_CalculatesConfidence_FromLayerConfidences()
{
var stack = CreateStack(
ReachabilityVerdict.Exploitable,
isL1Reachable: true,
l1Confidence: ConfidenceLevel.High,
l2Confidence: ConfidenceLevel.Medium,
l3Confidence: ConfidenceLevel.Low);
var justification = _sut.BuildJustification(stack, []);
// Weighted: 0.5 * 1.0 + 0.25 * 0.7 + 0.25 * 0.4 = 0.775
Assert.True(justification.Confidence >= 0.7m);
Assert.True(justification.Confidence <= 0.8m);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CreateStatement_NotAffected_IncludesJustification()
{
var stack = CreateStack(ReachabilityVerdict.Unreachable, isL1Reachable: false);
var statement = _sut.CreateStatement(stack, "product/1.0", []);
Assert.Equal(VexStatus.NotAffected, statement.Status);
Assert.NotNull(statement.Justification);
Assert.Null(statement.ActionStatement);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CreateStatement_Affected_IncludesActionStatement()
{
var stack = CreateStack(ReachabilityVerdict.Exploitable, isL1Reachable: true);
var statement = _sut.CreateStatement(stack, "product/1.0", []);
Assert.Equal(VexStatus.Affected, statement.Status);
Assert.Null(statement.Justification); // Justification only for NotAffected
Assert.NotNull(statement.ActionStatement);
Assert.Contains("affects", statement.ActionStatement);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CreateStatement_GeneratesDeterministicId()
{
var stack = CreateStack(ReachabilityVerdict.Exploitable, isL1Reachable: true);
var statement1 = _sut.CreateStatement(stack, "product/1.0", []);
var statement2 = _sut.CreateStatement(stack, "product/1.0", []);
Assert.Equal(statement1.StatementId, statement2.StatementId);
Assert.StartsWith("stmt-", statement1.StatementId);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CreateStatement_SetsTimestampFromTimeProvider()
{
var stack = CreateStack(ReachabilityVerdict.Exploitable, isL1Reachable: true);
var statement = _sut.CreateStatement(stack, "product/1.0", []);
Assert.Equal(_timeProvider.GetUtcNow(), statement.Timestamp);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CreateStatement_IncludesImpactStatement()
{
var stack = CreateStack(ReachabilityVerdict.Exploitable, isL1Reachable: true);
var statement = _sut.CreateStatement(stack, "product/1.0", []);
Assert.NotNull(statement.ImpactStatement);
Assert.Contains("vulnerable_func", statement.ImpactStatement);
}
private static ReachabilityStack CreateStack(
ReachabilityVerdict verdict,
bool isL1Reachable,
ConfidenceLevel l1Confidence = ConfidenceLevel.High,
ConfidenceLevel l2Confidence = ConfidenceLevel.High,
ConfidenceLevel l3Confidence = ConfidenceLevel.High)
{
var paths = isL1Reachable
? ImmutableArray.Create(new StackCallPath
{
Sites = ImmutableArray.Create(new CallSite("main", null, null, null, CallSiteType.Direct)),
Entrypoint = new Entrypoint("main", EntrypointType.HttpEndpoint, null, null),
Confidence = 1.0,
HasConditionals = false
})
: ImmutableArray<StackCallPath>.Empty;
var entrypoints = isL1Reachable
? ImmutableArray.Create(new Entrypoint("main", EntrypointType.HttpEndpoint, null, null))
: ImmutableArray<Entrypoint>.Empty;
return new ReachabilityStack
{
Id = "test-stack-1",
FindingId = "CVE-2024-1234:pkg:npm/test@1.0.0",
Symbol = new VulnerableSymbol(
Name: "vulnerable_func",
Library: "test-lib",
Version: "1.0.0",
VulnerabilityId: "CVE-2024-1234",
Type: SymbolType.Function),
StaticCallGraph = new ReachabilityLayer1
{
IsReachable = isL1Reachable,
Confidence = l1Confidence,
Paths = paths,
ReachingEntrypoints = entrypoints,
AnalysisMethod = "BFS",
Limitations = []
},
BinaryResolution = new ReachabilityLayer2
{
IsResolved = true,
Confidence = l2Confidence,
Reason = "Symbol linked"
},
RuntimeGating = new ReachabilityLayer3
{
IsGated = false,
Outcome = GatingOutcome.NotGated,
Confidence = l3Confidence,
Description = "No gating detected"
},
Verdict = verdict,
AnalyzedAt = DateTimeOffset.UtcNow,
Explanation = "Test stack"
};
}
}
internal sealed class FakeTimeProvider : TimeProvider
{
private DateTimeOffset _utcNow;
public FakeTimeProvider(DateTimeOffset utcNow)
{
_utcNow = utcNow;
}
public override DateTimeOffset GetUtcNow() => _utcNow;
public void Advance(TimeSpan duration) => _utcNow = _utcNow.Add(duration);
}

View File

@@ -1,3 +1,4 @@
using StellaOps.Scanner.Contracts;
using StellaOps.Scanner.Reachability;
using Xunit;

View File

@@ -18,5 +18,7 @@
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.Reachability\StellaOps.Scanner.Reachability.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.Cache\StellaOps.Scanner.Cache.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
<ProjectReference Include="../../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Decompiler/StellaOps.BinaryIndex.Decompiler.csproj" />
<ProjectReference Include="../../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Ghidra/StellaOps.BinaryIndex.Ghidra.csproj" />
</ItemGroup>
</Project>