Add comprehensive tests for PathConfidenceScorer, PathEnumerator, ShellSymbolicExecutor, and SymbolicState

- Implemented unit tests for PathConfidenceScorer to evaluate path scoring under various conditions, including empty constraints, known and unknown constraints, environmental dependencies, and custom weights.
- Developed tests for PathEnumerator to ensure correct path enumeration from simple scripts, handling known environments, and respecting maximum paths and depth limits.
- Created tests for ShellSymbolicExecutor to validate execution of shell scripts, including handling of commands, branching, and environment tracking.
- Added tests for SymbolicState to verify state management, variable handling, constraint addition, and environment dependency collection.
This commit is contained in:
StellaOps Bot
2025-12-20 14:03:31 +02:00
parent 0ada1b583f
commit ce8cdcd23d
71 changed files with 12438 additions and 3349 deletions

View File

@@ -0,0 +1,205 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Collections.Immutable;
using StellaOps.Scanner.EntryTrace.Binary;
using Xunit;
namespace StellaOps.Scanner.EntryTrace.Tests.Binary;
/// <summary>
/// Integration tests for <see cref="BinaryIntelligenceAnalyzer"/>.
/// </summary>
public sealed class BinaryIntelligenceIntegrationTests
{
[Fact]
public async Task AnalyzeAsync_EmptyFunctions_ReturnsEmptyResult()
{
// Arrange
var analyzer = new BinaryIntelligenceAnalyzer();
// Act
var result = await analyzer.AnalyzeAsync(
binaryPath: "/app/test.so",
binaryHash: "sha256:abc123",
functions: Array.Empty<FunctionSignature>());
// Assert
Assert.NotNull(result);
Assert.Empty(result.Functions);
Assert.Empty(result.VulnerableMatches);
}
[Fact]
public async Task AnalyzeAsync_WithFunctions_GeneratesFingerprints()
{
// Arrange
var analyzer = new BinaryIntelligenceAnalyzer();
var functions = new[]
{
CreateFunctionSignature(0x1000, 200),
CreateFunctionSignature(0x2000, 300),
CreateFunctionSignature(0x3000, 150)
};
// Act
var result = await analyzer.AnalyzeAsync(
binaryPath: "/app/test.so",
binaryHash: "sha256:abc123",
functions: functions);
// Assert
Assert.NotNull(result);
Assert.Equal(3, result.Functions.Length);
}
[Fact]
public async Task AnalyzeAsync_WithStrippedBinaries_AttemptsRecovery()
{
// Arrange
var analyzer = new BinaryIntelligenceAnalyzer();
var functions = new[]
{
CreateFunctionSignature(0x1000, 200, name: null), // Stripped
CreateFunctionSignature(0x2000, 300, name: "known_func"),
CreateFunctionSignature(0x3000, 150, name: null) // Stripped
};
// Act
var result = await analyzer.AnalyzeAsync(
binaryPath: "/app/test.so",
binaryHash: "sha256:abc123",
functions: functions);
// Assert
Assert.NotNull(result);
// Check that at least the known function is preserved
Assert.Contains(result.Functions, f => f.Name == "known_func");
}
[Fact]
public async Task AnalyzeAsync_ReturnsArchitectureAndFormat()
{
// Arrange
var analyzer = new BinaryIntelligenceAnalyzer();
var functions = new[]
{
CreateFunctionSignature(0x1000, 200)
};
// Act
var result = await analyzer.AnalyzeAsync(
binaryPath: "/app/test.so",
binaryHash: "sha256:abc123",
functions: functions,
architecture: BinaryArchitecture.X64,
format: BinaryFormat.ELF);
// Assert
Assert.Equal(BinaryArchitecture.X64, result.Architecture);
Assert.Equal(BinaryFormat.ELF, result.Format);
}
[Fact]
public async Task AnalyzeAsync_IncludesMetrics()
{
// Arrange
var analyzer = new BinaryIntelligenceAnalyzer();
var functions = Enumerable.Range(0, 100)
.Select(i => CreateFunctionSignature(0x1000 + i * 0x100, 100 + i))
.ToArray();
// Act
var result = await analyzer.AnalyzeAsync(
binaryPath: "/app/test.so",
binaryHash: "sha256:abc123",
functions: functions);
// Assert
Assert.NotNull(result.Metrics);
Assert.Equal(100, result.Metrics.TotalFunctions);
}
[Fact]
public void BinaryIntelligenceAnalyzer_Constructor_UsesDefaults()
{
// Act
var analyzer = new BinaryIntelligenceAnalyzer();
// Assert
Assert.NotNull(analyzer);
}
[Fact]
public void BinaryIntelligenceAnalyzer_Constructor_AcceptsCustomComponents()
{
// Arrange
var index = new InMemoryFingerprintIndex();
var generator = new BasicBlockFingerprintGenerator();
var recovery = new PatternBasedSymbolRecovery();
// Act
var analyzer = new BinaryIntelligenceAnalyzer(
fingerprintGenerator: generator,
fingerprintIndex: index,
symbolRecovery: recovery);
// Assert
Assert.NotNull(analyzer);
}
[Fact]
public async Task AnalyzeAsync_ReturnsAnalyzedAtTimestamp()
{
// Arrange
var analyzer = new BinaryIntelligenceAnalyzer();
var before = DateTimeOffset.UtcNow;
// Act
var result = await analyzer.AnalyzeAsync(
binaryPath: "/app/test.so",
binaryHash: "sha256:abc123",
functions: []);
var after = DateTimeOffset.UtcNow;
// Assert
Assert.True(result.AnalyzedAt >= before);
Assert.True(result.AnalyzedAt <= after);
}
private static FunctionSignature CreateFunctionSignature(
long offset,
int size,
string? name = null)
{
return new FunctionSignature(
Name: name,
Offset: offset,
Size: size,
CallingConvention: CallingConvention.Unknown,
ParameterCount: null,
ReturnType: null,
Fingerprint: CodeFingerprint.Empty,
BasicBlocks: CreateBasicBlocks(5),
StringReferences: ImmutableArray<string>.Empty,
ImportReferences: ImmutableArray<string>.Empty);
}
private static ImmutableArray<BasicBlock> CreateBasicBlocks(int count)
{
return Enumerable.Range(0, count)
.Select(i => new BasicBlock(
Id: i,
Offset: i * 0x10,
Size: 16,
InstructionCount: 4,
Successors: i < count - 1 ? ImmutableArray.Create(i + 1) : ImmutableArray<int>.Empty,
Predecessors: i > 0 ? ImmutableArray.Create(i - 1) : ImmutableArray<int>.Empty,
NormalizedBytes: ImmutableArray.Create<byte>(0x90, 0x90, 0x90, 0x90)))
.ToImmutableArray();
}
}

View File

@@ -0,0 +1,342 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Collections.Immutable;
using StellaOps.Scanner.EntryTrace.Binary;
using Xunit;
namespace StellaOps.Scanner.EntryTrace.Tests.Binary;
/// <summary>
/// Unit tests for <see cref="CodeFingerprint"/> and related types.
/// </summary>
public sealed class CodeFingerprintTests
{
[Theory]
[InlineData(FingerprintAlgorithm.BasicBlockHash, "bb")]
[InlineData(FingerprintAlgorithm.ControlFlowGraph, "cfg")]
[InlineData(FingerprintAlgorithm.StringReferences, "str")]
[InlineData(FingerprintAlgorithm.ImportReferences, "imp")]
[InlineData(FingerprintAlgorithm.Combined, "cmb")]
public void ComputeId_ReturnsCorrectPrefix(FingerprintAlgorithm algorithm, string expectedPrefix)
{
// Arrange
var hash = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08 };
// Act
var id = CodeFingerprint.ComputeId(algorithm, hash);
// Assert
Assert.StartsWith(expectedPrefix + "-", id);
}
[Fact]
public void ComputeId_IsDeterministic()
{
// Arrange
var hash = new byte[] { 0xaa, 0xbb, 0xcc, 0xdd };
// Act
var id1 = CodeFingerprint.ComputeId(FingerprintAlgorithm.BasicBlockHash, hash);
var id2 = CodeFingerprint.ComputeId(FingerprintAlgorithm.BasicBlockHash, hash);
// Assert
Assert.Equal(id1, id2);
}
[Fact]
public void ComputeId_DifferentHashesProduceDifferentIds()
{
// Arrange
var hash1 = new byte[] { 0x01, 0x02, 0x03, 0x04 };
var hash2 = new byte[] { 0x05, 0x06, 0x07, 0x08 };
// Act
var id1 = CodeFingerprint.ComputeId(FingerprintAlgorithm.BasicBlockHash, hash1);
var id2 = CodeFingerprint.ComputeId(FingerprintAlgorithm.BasicBlockHash, hash2);
// Assert
Assert.NotEqual(id1, id2);
}
[Fact]
public void ComputeSimilarity_IdenticalFingerprints_ReturnsOne()
{
// Arrange
var hash = ImmutableArray.Create<byte>(0x01, 0x02, 0x03, 0x04);
var fp1 = new CodeFingerprint(
"test-1",
FingerprintAlgorithm.BasicBlockHash,
hash,
FunctionSize: 100,
BasicBlockCount: 5,
InstructionCount: 20,
ImmutableDictionary<string, string>.Empty);
var fp2 = new CodeFingerprint(
"test-2",
FingerprintAlgorithm.BasicBlockHash,
hash,
FunctionSize: 100,
BasicBlockCount: 5,
InstructionCount: 20,
ImmutableDictionary<string, string>.Empty);
// Act
var similarity = fp1.ComputeSimilarity(fp2);
// Assert
Assert.Equal(1.0f, similarity);
}
[Fact]
public void ComputeSimilarity_CompletelyDifferent_ReturnsZero()
{
// Arrange - hashes that differ in every bit
var hash1 = ImmutableArray.Create<byte>(0x00, 0x00, 0x00, 0x00);
var hash2 = ImmutableArray.Create<byte>(0xff, 0xff, 0xff, 0xff);
var fp1 = new CodeFingerprint(
"test-1",
FingerprintAlgorithm.BasicBlockHash,
hash1,
FunctionSize: 100,
BasicBlockCount: 5,
InstructionCount: 20,
ImmutableDictionary<string, string>.Empty);
var fp2 = new CodeFingerprint(
"test-2",
FingerprintAlgorithm.BasicBlockHash,
hash2,
FunctionSize: 100,
BasicBlockCount: 5,
InstructionCount: 20,
ImmutableDictionary<string, string>.Empty);
// Act
var similarity = fp1.ComputeSimilarity(fp2);
// Assert
Assert.Equal(0.0f, similarity);
}
[Fact]
public void ComputeSimilarity_DifferentAlgorithms_ReturnsZero()
{
// Arrange
var hash = ImmutableArray.Create<byte>(0x01, 0x02, 0x03, 0x04);
var fp1 = new CodeFingerprint(
"test-1",
FingerprintAlgorithm.BasicBlockHash,
hash,
FunctionSize: 100,
BasicBlockCount: 5,
InstructionCount: 20,
ImmutableDictionary<string, string>.Empty);
var fp2 = new CodeFingerprint(
"test-2",
FingerprintAlgorithm.ControlFlowGraph,
hash,
FunctionSize: 100,
BasicBlockCount: 5,
InstructionCount: 20,
ImmutableDictionary<string, string>.Empty);
// Act
var similarity = fp1.ComputeSimilarity(fp2);
// Assert
Assert.Equal(0.0f, similarity);
}
[Fact]
public void Empty_HasEmptyHash()
{
// Act
var empty = CodeFingerprint.Empty;
// Assert
Assert.Equal("empty", empty.Id);
Assert.Empty(empty.Hash);
Assert.Equal(0, empty.FunctionSize);
}
[Fact]
public void HashHex_ReturnsLowercaseHexString()
{
// Arrange
var hash = ImmutableArray.Create<byte>(0xAB, 0xCD, 0xEF);
var fp = new CodeFingerprint(
"test",
FingerprintAlgorithm.BasicBlockHash,
hash,
FunctionSize: 50,
BasicBlockCount: 3,
InstructionCount: 10,
ImmutableDictionary<string, string>.Empty);
// Act
var hex = fp.HashHex;
// Assert
Assert.Equal("abcdef", hex);
}
}
/// <summary>
/// Unit tests for <see cref="BasicBlock"/>.
/// </summary>
public sealed class BasicBlockTests
{
[Fact]
public void ComputeHash_DeterministicForSameInput()
{
// Arrange
var bytes = ImmutableArray.Create<byte>(0x01, 0x02, 0x03);
var block = new BasicBlock(
Id: 0,
Offset: 0,
Size: 3,
InstructionCount: 1,
Successors: ImmutableArray<int>.Empty,
Predecessors: ImmutableArray<int>.Empty,
NormalizedBytes: bytes);
// Act
var hash1 = block.ComputeHash();
var hash2 = block.ComputeHash();
// Assert
Assert.True(hash1.SequenceEqual(hash2), "Hash should be deterministic");
}
[Fact]
public void IsEntry_TrueForZeroOffset()
{
// Arrange
var block = new BasicBlock(
Id: 0,
Offset: 0,
Size: 10,
InstructionCount: 3,
Successors: ImmutableArray.Create(1),
Predecessors: ImmutableArray<int>.Empty,
NormalizedBytes: ImmutableArray.Create<byte>(0x01, 0x02));
// Assert
Assert.True(block.IsEntry);
}
[Fact]
public void IsExit_TrueWhenNoSuccessors()
{
// Arrange
var block = new BasicBlock(
Id: 1,
Offset: 10,
Size: 10,
InstructionCount: 3,
Successors: ImmutableArray<int>.Empty,
Predecessors: ImmutableArray.Create(0),
NormalizedBytes: ImmutableArray.Create<byte>(0x01, 0x02));
// Assert
Assert.True(block.IsExit);
}
}
/// <summary>
/// Unit tests for <see cref="FunctionSignature"/>.
/// </summary>
public sealed class FunctionSignatureTests
{
[Fact]
public void HasSymbols_TrueWhenNameProvided()
{
// Arrange
var func = CreateFunctionSignature("malloc");
// Assert
Assert.True(func.HasSymbols);
}
[Fact]
public void HasSymbols_FalseWhenNameNull()
{
// Arrange
var func = CreateFunctionSignature(null);
// Assert
Assert.False(func.HasSymbols);
}
[Fact]
public void DisplayName_ReturnsSymbolNameWhenAvailable()
{
// Arrange
var func = CreateFunctionSignature("my_function");
// Assert
Assert.Equal("my_function", func.DisplayName);
}
[Fact]
public void DisplayName_ReturnsOffsetBasedNameWhenNoSymbol()
{
// Arrange
var func = CreateFunctionSignature(null, offset: 0x1234);
// Assert
Assert.Equal("sub_1234", func.DisplayName);
}
private static FunctionSignature CreateFunctionSignature(string? name, long offset = 0)
{
return new FunctionSignature(
Name: name,
Offset: offset,
Size: 100,
CallingConvention: CallingConvention.Cdecl,
ParameterCount: null,
ReturnType: null,
Fingerprint: CodeFingerprint.Empty,
BasicBlocks: ImmutableArray<BasicBlock>.Empty,
StringReferences: ImmutableArray<string>.Empty,
ImportReferences: ImmutableArray<string>.Empty);
}
}
/// <summary>
/// Unit tests for <see cref="FingerprintOptions"/>.
/// </summary>
public sealed class FingerprintOptionsTests
{
[Fact]
public void Default_HasExpectedValues()
{
// Act
var options = FingerprintOptions.Default;
// Assert
Assert.Equal(FingerprintAlgorithm.BasicBlockHash, options.Algorithm);
Assert.True(options.NormalizeRegisters);
Assert.True(options.NormalizeConstants);
Assert.True(options.IncludeStrings);
Assert.Equal(16, options.MinFunctionSize);
Assert.Equal(1_000_000, options.MaxFunctionSize);
}
[Fact]
public void ForStripped_OptimizedForStrippedBinaries()
{
// Act
var options = FingerprintOptions.ForStripped;
// Assert
Assert.Equal(FingerprintAlgorithm.Combined, options.Algorithm);
Assert.True(options.NormalizeRegisters);
Assert.Equal(32, options.MinFunctionSize);
}
}

View File

@@ -0,0 +1,223 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Collections.Immutable;
using StellaOps.Scanner.EntryTrace.Binary;
using Xunit;
namespace StellaOps.Scanner.EntryTrace.Tests.Binary;
/// <summary>
/// Unit tests for <see cref="IFingerprintGenerator"/> implementations.
/// </summary>
public sealed class FingerprintGeneratorTests
{
private static readonly ImmutableArray<byte> SampleBytes = ImmutableArray.Create<byte>(
0x55, // push rbp
0x48, 0x89, 0xe5, // mov rbp, rsp
0x89, 0x7d, 0xfc, // mov [rbp-4], edi
0x8b, 0x45, 0xfc, // mov eax, [rbp-4]
0x0f, 0xaf, 0xc0, // imul eax, eax
0x5d, // pop rbp
0xc3 // ret
);
[Fact]
public async Task BasicBlockGenerator_GeneratesNonEmptyFingerprint()
{
// Arrange
var generator = new BasicBlockFingerprintGenerator();
var function = CreateSampleFunction();
// Act
var fingerprint = await generator.GenerateAsync(function);
// Assert
Assert.NotEqual(CodeFingerprint.Empty, fingerprint);
Assert.Equal(FingerprintAlgorithm.BasicBlockHash, fingerprint.Algorithm);
Assert.NotEmpty(fingerprint.Hash);
}
[Fact]
public async Task BasicBlockGenerator_IsDeterministic()
{
// Arrange
var generator = new BasicBlockFingerprintGenerator();
var function = CreateSampleFunction();
// Act
var fp1 = await generator.GenerateAsync(function);
var fp2 = await generator.GenerateAsync(function);
// Assert
Assert.True(fp1.Hash.SequenceEqual(fp2.Hash), "Hash should be deterministic");
Assert.Equal(fp1.Id, fp2.Id);
}
[Fact]
public async Task BasicBlockGenerator_EmptyBlocks_ReturnsEmpty()
{
// Arrange
var generator = new BasicBlockFingerprintGenerator();
var function = new FunctionSignature(
Name: null,
Offset: 0,
Size: 100,
CallingConvention: CallingConvention.Cdecl,
ParameterCount: null,
ReturnType: null,
Fingerprint: CodeFingerprint.Empty,
BasicBlocks: ImmutableArray<BasicBlock>.Empty,
StringReferences: ImmutableArray<string>.Empty,
ImportReferences: ImmutableArray<string>.Empty);
// Act
var fingerprint = await generator.GenerateAsync(function);
// Assert
Assert.Equal(CodeFingerprint.Empty, fingerprint);
}
[Fact]
public async Task BasicBlockGenerator_TooSmall_ReturnsEmpty()
{
// Arrange
var generator = new BasicBlockFingerprintGenerator();
var options = new FingerprintOptions(MinFunctionSize: 100); // Require at least 100 bytes
var function = CreateSampleFunction(size: 50); // Only 50 bytes
// Act
var fingerprint = await generator.GenerateAsync(function, options);
// Assert
Assert.Equal(CodeFingerprint.Empty, fingerprint);
}
[Fact]
public async Task BasicBlockGenerator_IncludesMetadata()
{
// Arrange
var generator = new BasicBlockFingerprintGenerator();
var function = CreateSampleFunction(name: "test_function");
// Act
var fingerprint = await generator.GenerateAsync(function);
// Assert
Assert.True(fingerprint.Metadata.ContainsKey("generator"));
Assert.True(fingerprint.Metadata.ContainsKey("originalName"));
Assert.Equal("test_function", fingerprint.Metadata["originalName"]);
}
[Fact]
public async Task BasicBlockGenerator_BatchProcessing()
{
// Arrange
var generator = new BasicBlockFingerprintGenerator();
var functions = new[]
{
CreateSampleFunction(offset: 0),
CreateSampleFunction(offset: 100),
CreateSampleFunction(offset: 200),
};
// Act
var fingerprints = await generator.GenerateBatchAsync(functions);
// Assert
Assert.Equal(3, fingerprints.Length);
Assert.All(fingerprints, fp => Assert.NotEqual(CodeFingerprint.Empty, fp));
}
[Fact]
public async Task ControlFlowGenerator_GeneratesNonEmptyFingerprint()
{
// Arrange
var generator = new ControlFlowFingerprintGenerator();
var function = CreateSampleFunction();
// Act
var fingerprint = await generator.GenerateAsync(function);
// Assert
Assert.NotEqual(CodeFingerprint.Empty, fingerprint);
Assert.Equal(FingerprintAlgorithm.ControlFlowGraph, fingerprint.Algorithm);
Assert.NotEmpty(fingerprint.Hash);
}
[Fact]
public async Task ControlFlowGenerator_IsDeterministic()
{
// Arrange
var generator = new ControlFlowFingerprintGenerator();
var function = CreateSampleFunction();
// Act
var fp1 = await generator.GenerateAsync(function);
var fp2 = await generator.GenerateAsync(function);
// Assert
Assert.True(fp1.Hash.SequenceEqual(fp2.Hash), "Hash should be deterministic");
Assert.Equal(fp1.Id, fp2.Id);
}
[Fact]
public async Task CombinedGenerator_GeneratesNonEmptyFingerprint()
{
// Arrange
var generator = new CombinedFingerprintGenerator();
var function = CreateSampleFunction();
// Act
var fingerprint = await generator.GenerateAsync(function);
// Assert
Assert.NotEqual(CodeFingerprint.Empty, fingerprint);
Assert.Equal(FingerprintAlgorithm.Combined, fingerprint.Algorithm);
Assert.NotEmpty(fingerprint.Hash);
}
[Fact]
public async Task Generator_RespectsOptions()
{
// Arrange
var generator = new BasicBlockFingerprintGenerator();
var function = CreateSampleFunction();
var defaultOptions = FingerprintOptions.Default;
var strippedOptions = FingerprintOptions.ForStripped;
// Act
var defaultFp = await generator.GenerateAsync(function, defaultOptions);
var strippedFp = await generator.GenerateAsync(function, strippedOptions);
// Assert - both should produce valid fingerprints
Assert.NotEqual(CodeFingerprint.Empty, defaultFp);
Assert.NotEqual(CodeFingerprint.Empty, strippedFp);
}
private static FunctionSignature CreateSampleFunction(
string? name = null,
long offset = 0,
int size = 100)
{
var block = new BasicBlock(
Id: 0,
Offset: 0,
Size: size,
InstructionCount: 10,
Successors: ImmutableArray<int>.Empty,
Predecessors: ImmutableArray<int>.Empty,
NormalizedBytes: SampleBytes);
return new FunctionSignature(
Name: name,
Offset: offset,
Size: size,
CallingConvention: CallingConvention.Cdecl,
ParameterCount: null,
ReturnType: null,
Fingerprint: CodeFingerprint.Empty,
BasicBlocks: ImmutableArray.Create(block),
StringReferences: ImmutableArray<string>.Empty,
ImportReferences: ImmutableArray<string>.Empty);
}
}

View File

@@ -0,0 +1,254 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Collections.Immutable;
using StellaOps.Scanner.EntryTrace.Binary;
using Xunit;
namespace StellaOps.Scanner.EntryTrace.Tests.Binary;
/// <summary>
/// Unit tests for <see cref="IFingerprintIndex"/> implementations.
/// </summary>
public sealed class FingerprintIndexTests
{
[Fact]
public async Task InMemoryIndex_Add_IncreasesCount()
{
// Arrange
var index = new InMemoryFingerprintIndex();
var fingerprint = CreateFingerprint("test-001");
// Act
await index.AddAsync(fingerprint, "pkg:npm/lodash@4.17.21", "lodash", null);
// Assert
var stats = index.GetStatistics();
Assert.Equal(1, stats.TotalFingerprints);
}
[Fact]
public async Task InMemoryIndex_LookupExact_FindsMatch()
{
// Arrange
var index = new InMemoryFingerprintIndex();
var fingerprint = CreateFingerprint("test-001");
await index.AddAsync(fingerprint, "pkg:npm/lodash@4.17.21", "_.map", null);
// Act
var matches = await index.LookupAsync(fingerprint);
// Assert
Assert.Single(matches);
Assert.Equal("_.map", matches[0].FunctionName);
Assert.Equal("pkg:npm/lodash@4.17.21", matches[0].SourcePackage);
}
[Fact]
public async Task InMemoryIndex_LookupExactAsync_FindsMatch()
{
// Arrange
var index = new InMemoryFingerprintIndex();
var fingerprint = CreateFingerprint("test-001");
await index.AddAsync(fingerprint, "pkg:npm/lodash@4.17.21", "_.map", null);
// Act
var match = await index.LookupExactAsync(fingerprint);
// Assert
Assert.NotNull(match);
Assert.Equal("_.map", match.FunctionName);
}
[Fact]
public async Task InMemoryIndex_LookupSimilar_LimitsResults()
{
// Arrange
var index = new InMemoryFingerprintIndex();
// Add many fingerprints
for (var i = 0; i < 20; i++)
{
var fp = CreateFingerprint($"test-{i:D3}");
await index.AddAsync(fp, $"pkg:npm/lib{i}@1.0.0", $"func_{i}", null);
}
var queryFp = CreateFingerprint("query");
// Act
var matches = await index.LookupAsync(queryFp, minSimilarity: 0.1f, maxResults: 5);
// Assert
Assert.True(matches.Length <= 5);
}
[Fact]
public async Task InMemoryIndex_Clear_RemovesAll()
{
// Arrange
var index = new InMemoryFingerprintIndex();
for (var i = 0; i < 10; i++)
{
var fp = CreateFingerprint($"test-{i:D3}");
await index.AddAsync(fp, $"pkg:npm/lib{i}@1.0.0", $"func_{i}", null);
}
// Act
await index.ClearAsync();
// Assert
var stats = index.GetStatistics();
Assert.Equal(0, stats.TotalFingerprints);
}
[Fact]
public async Task InMemoryIndex_Statistics_TracksPackages()
{
// Arrange
var index = new InMemoryFingerprintIndex();
var fp1 = CreateFingerprint("test-001");
var fp2 = CreateFingerprint("test-002");
var fp3 = CreateFingerprint("test-003");
await index.AddAsync(fp1, "pkg:npm/lodash@4.17.21", "func_a", null);
await index.AddAsync(fp2, "pkg:npm/lodash@4.17.21", "func_b", null);
await index.AddAsync(fp3, "pkg:npm/express@4.18.0", "func_c", null);
// Act
var stats = index.GetStatistics();
// Assert
Assert.Equal(3, stats.TotalFingerprints);
Assert.Equal(2, stats.TotalPackages);
}
[Fact]
public async Task VulnerableIndex_TracksVulnerabilities()
{
// Arrange
var index = new VulnerableFingerprintIndex();
var fp = CreateFingerprint("test-001");
// Act
await index.AddVulnerableAsync(
fp,
"pkg:npm/lodash@4.17.20",
"_.template",
"CVE-2021-23337",
"4.17.0-4.17.20",
VulnerabilitySeverity.High);
// Assert
var matches = await index.LookupAsync(fp);
Assert.Single(matches);
}
[Fact]
public async Task VulnerableIndex_CheckVulnerable_ReturnsMatch()
{
// Arrange
var index = new VulnerableFingerprintIndex();
var fp = CreateFingerprint("test-001");
await index.AddVulnerableAsync(
fp,
"pkg:npm/lodash@4.17.20",
"_.template",
"CVE-2021-23337",
"4.17.0-4.17.20",
VulnerabilitySeverity.High);
// Act
var match = await index.CheckVulnerableAsync(fp, 0x1000);
// Assert
Assert.NotNull(match);
Assert.Equal("CVE-2021-23337", match.VulnerabilityId);
}
[Fact]
public async Task VulnerableIndex_Statistics_TracksVulns()
{
// Arrange
var index = new VulnerableFingerprintIndex();
var fp1 = CreateFingerprint("test-001");
var fp2 = CreateFingerprint("test-002");
await index.AddVulnerableAsync(
fp1, "pkg:npm/lodash@4.17.20", "_.template", "CVE-2021-23337", "4.17.x", VulnerabilitySeverity.High);
await index.AddVulnerableAsync(
fp2, "pkg:npm/moment@2.29.0", "moment.locale", "CVE-2022-24785", "2.29.x", VulnerabilitySeverity.Medium);
// Act
var stats = index.GetStatistics();
// Assert
Assert.Equal(2, stats.TotalFingerprints);
Assert.True(stats.TotalVulnerabilities >= 2);
}
[Fact]
public async Task InMemoryIndex_AddBatch_AddsMultiple()
{
// Arrange
var index = new InMemoryFingerprintIndex();
var matches = Enumerable.Range(0, 10)
.Select(i => new FingerprintMatch(
Fingerprint: CreateFingerprint($"test-{i:D3}"),
FunctionName: $"func_{i}",
SourcePackage: "pkg:npm/test@1.0.0",
SourceVersion: "1.0.0",
SourceFile: null,
SourceLine: null,
VulnerabilityIds: ImmutableArray<string>.Empty,
Similarity: 1.0f,
MatchedAt: DateTimeOffset.UtcNow))
.ToList();
// Act
foreach (var match in matches)
{
await index.AddAsync(match);
}
// Assert
var stats = index.GetStatistics();
Assert.Equal(10, stats.TotalFingerprints);
}
[Fact]
public void InMemoryIndex_Count_ReturnsCorrectValue()
{
// Arrange
var index = new InMemoryFingerprintIndex();
// Assert initial
Assert.Equal(0, index.Count);
}
[Fact]
public void InMemoryIndex_IndexedPackages_ReturnsEmptyInitially()
{
// Arrange
var index = new InMemoryFingerprintIndex();
// Assert
Assert.Empty(index.IndexedPackages);
}
private static CodeFingerprint CreateFingerprint(string id)
{
return new CodeFingerprint(
Id: id,
Algorithm: FingerprintAlgorithm.BasicBlockHash,
Hash: ImmutableArray.Create<byte>(0x01, 0x02, 0x03, 0x04),
FunctionSize: 100,
BasicBlockCount: 5,
InstructionCount: 20,
Metadata: ImmutableDictionary<string, string>.Empty);
}
}

View File

@@ -0,0 +1,272 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Collections.Immutable;
using StellaOps.Scanner.EntryTrace.Binary;
using Xunit;
namespace StellaOps.Scanner.EntryTrace.Tests.Binary;
/// <summary>
/// Unit tests for <see cref="ISymbolRecovery"/> implementations.
/// </summary>
public sealed class SymbolRecoveryTests
{
[Fact]
public void FunctionPattern_Matches_SizeWithinBounds()
{
// Arrange
var pattern = new FunctionPattern(
Name: "test_pattern",
MinSize: 100,
MaxSize: 1000,
RequiredImports: [],
InferredName: "test_func",
Confidence: 0.8f);
var function = CreateFunctionSignature(size: 500);
// Act
var matches = pattern.Matches(function);
// Assert
Assert.True(matches);
}
[Fact]
public void FunctionPattern_NoMatch_SizeTooSmall()
{
// Arrange
var pattern = new FunctionPattern(
Name: "test_pattern",
MinSize: 100,
MaxSize: 1000,
RequiredImports: [],
InferredName: "test_func",
Confidence: 0.8f);
var function = CreateFunctionSignature(size: 50);
// Act
var matches = pattern.Matches(function);
// Assert
Assert.False(matches);
}
[Fact]
public void FunctionPattern_NoMatch_SizeTooLarge()
{
// Arrange
var pattern = new FunctionPattern(
Name: "test_pattern",
MinSize: 100,
MaxSize: 1000,
RequiredImports: [],
InferredName: "test_func",
Confidence: 0.8f);
var function = CreateFunctionSignature(size: 2000);
// Act
var matches = pattern.Matches(function);
// Assert
Assert.False(matches);
}
[Fact]
public void FunctionPattern_Matches_WithRequiredImports()
{
// Arrange
var pattern = new FunctionPattern(
Name: "crypto_pattern",
MinSize: 100,
MaxSize: 1000,
RequiredImports: ["libcrypto.so", "libssl.so"],
InferredName: "crypto_func",
Confidence: 0.9f);
var function = CreateFunctionSignature(
size: 500,
imports: ["libcrypto.so", "libssl.so", "libc.so"]);
// Act
var matches = pattern.Matches(function);
// Assert
Assert.True(matches);
}
[Fact]
public void FunctionPattern_NoMatch_MissingRequiredImport()
{
// Arrange
var pattern = new FunctionPattern(
Name: "crypto_pattern",
MinSize: 100,
MaxSize: 1000,
RequiredImports: ["libcrypto.so", "libssl.so"],
InferredName: "crypto_func",
Confidence: 0.9f);
var function = CreateFunctionSignature(
size: 500,
imports: ["libcrypto.so", "libc.so"]); // Missing libssl.so
// Act
var matches = pattern.Matches(function);
// Assert
Assert.False(matches);
}
[Fact]
public void FunctionPattern_Matches_WithBasicBlockBounds()
{
// Arrange
var pattern = new FunctionPattern(
Name: "complex_pattern",
MinSize: 100,
MaxSize: 10000,
RequiredImports: [],
InferredName: "complex_func",
Confidence: 0.85f,
MinBasicBlocks: 5,
MaxBasicBlocks: 50);
var function = CreateFunctionSignature(size: 500, basicBlocks: 20);
// Act
var matches = pattern.Matches(function);
// Assert
Assert.True(matches);
}
[Fact]
public void FunctionPattern_NoMatch_TooFewBasicBlocks()
{
// Arrange
var pattern = new FunctionPattern(
Name: "complex_pattern",
MinSize: 100,
MaxSize: 10000,
RequiredImports: [],
InferredName: "complex_func",
Confidence: 0.85f,
MinBasicBlocks: 10,
MaxBasicBlocks: 50);
var function = CreateFunctionSignature(size: 500, basicBlocks: 3);
// Act
var matches = pattern.Matches(function);
// Assert
Assert.False(matches);
}
[Fact]
public async Task PatternBasedRecovery_RecoverAsync_ReturnsResults()
{
// Arrange
var recovery = new PatternBasedSymbolRecovery();
var function = CreateFunctionSignature(size: 200);
// Act
var result = await recovery.RecoverAsync(function);
// Assert
Assert.NotNull(result);
}
[Fact]
public async Task PatternBasedRecovery_RecoverBatchAsync_ReturnsResults()
{
// Arrange
var recovery = new PatternBasedSymbolRecovery();
var functions = new[]
{
CreateFunctionSignature(offset: 0x1000, size: 200),
CreateFunctionSignature(offset: 0x2000, size: 500),
CreateFunctionSignature(offset: 0x3000, size: 100)
};
// Act
var results = await recovery.RecoverBatchAsync(functions);
// Assert
Assert.NotNull(results);
}
[Fact]
public void FunctionPattern_Matches_WithRequiredStrings()
{
// Arrange
var pattern = new FunctionPattern(
Name: "error_handler",
MinSize: 50,
MaxSize: 500,
RequiredImports: [],
InferredName: "handle_error",
Confidence: 0.7f,
RequiredStrings: ["error:", "failed"]);
var function = CreateFunctionSignature(
size: 100,
strings: ["error: operation failed", "success"]);
// Act
var matches = pattern.Matches(function);
// Assert
Assert.True(matches);
}
[Fact]
public void PatternBasedRecovery_SupportedMethods_ReturnsValues()
{
// Arrange
var recovery = new PatternBasedSymbolRecovery();
// Act
var methods = recovery.SupportedMethods;
// Assert
Assert.NotEmpty(methods);
}
private static FunctionSignature CreateFunctionSignature(
int size = 100,
long offset = 0x1000,
int basicBlocks = 5,
string[]? imports = null,
string[]? strings = null)
{
return new FunctionSignature(
Name: null, // Stripped
Offset: offset,
Size: size,
CallingConvention: CallingConvention.Unknown,
ParameterCount: null,
ReturnType: null,
Fingerprint: CodeFingerprint.Empty,
BasicBlocks: CreateBasicBlocks(basicBlocks),
StringReferences: (strings ?? []).ToImmutableArray(),
ImportReferences: (imports ?? []).ToImmutableArray());
}
private static ImmutableArray<BasicBlock> CreateBasicBlocks(int count)
{
return Enumerable.Range(0, count)
.Select(i => new BasicBlock(
Id: i,
Offset: i * 0x10,
Size: 16,
InstructionCount: 4,
Successors: i < count - 1 ? ImmutableArray.Create(i + 1) : ImmutableArray<int>.Empty,
Predecessors: i > 0 ? ImmutableArray.Create(i - 1) : ImmutableArray<int>.Empty,
NormalizedBytes: ImmutableArray.Create<byte>(0x90, 0x90, 0x90, 0x90)))
.ToImmutableArray();
}
}

View File

@@ -1,578 +0,0 @@
using StellaOps.Scanner.EntryTrace.Mesh;
using Xunit;
namespace StellaOps.Scanner.EntryTrace.Tests.Mesh;
/// <summary>
/// Unit tests for DockerComposeParser.
/// Part of Sprint 0412 - Task TEST-003.
/// </summary>
public sealed class DockerComposeParserTests
{
private readonly DockerComposeParser _parser = new();
[Fact]
public void CanParse_DockerComposeYaml_ReturnsTrue()
{
// Act
Assert.True(_parser.CanParse("docker-compose.yaml"));
Assert.True(_parser.CanParse("docker-compose.yml"));
Assert.True(_parser.CanParse("compose.yaml"));
Assert.True(_parser.CanParse("compose.yml"));
Assert.True(_parser.CanParse("docker-compose.prod.yaml"));
}
[Fact]
public void CanParse_NonComposeYaml_ReturnsFalse()
{
// Arrange
var content = """
apiVersion: apps/v1
kind: Deployment
""";
// Act & Assert
Assert.False(_parser.CanParse("deployment.yaml", content));
}
[Fact]
public async Task ParseAsync_SimpleService_ExtractsService()
{
// Arrange
var content = """
version: "3.8"
services:
web:
image: nginx:latest
ports:
- "80:80"
""";
// Act
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
// Assert
Assert.Equal(MeshType.DockerCompose, graph.Type);
Assert.Single(graph.Services);
Assert.Equal("web", graph.Services[0].ServiceId);
Assert.Equal("web", graph.Services[0].ContainerName);
Assert.Single(graph.Services[0].ExposedPorts);
Assert.Contains(80, graph.Services[0].ExposedPorts);
}
[Fact]
public async Task ParseAsync_MultipleServices_ExtractsAll()
{
// Arrange
var content = """
version: "3.8"
services:
web:
image: nginx:latest
ports:
- "80:80"
api:
image: myapi:v1
ports:
- "8080:8080"
db:
image: postgres:15
expose:
- "5432"
""";
// Act
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
// Assert
Assert.Equal(3, graph.Services.Length);
Assert.Contains(graph.Services, s => s.ServiceId == "web");
Assert.Contains(graph.Services, s => s.ServiceId == "api");
Assert.Contains(graph.Services, s => s.ServiceId == "db");
}
[Fact]
public async Task ParseAsync_DependsOn_CreatesEdges()
{
// Arrange
var content = """
version: "3.8"
services:
web:
image: nginx
depends_on:
- api
api:
image: myapi
depends_on:
- db
db:
image: postgres
""";
// Act
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
// Assert
Assert.Equal(2, graph.Edges.Length);
Assert.Contains(graph.Edges, e => e.SourceServiceId == "web" && e.TargetServiceId == "api");
Assert.Contains(graph.Edges, e => e.SourceServiceId == "api" && e.TargetServiceId == "db");
}
[Fact]
public async Task ParseAsync_Links_CreatesEdges()
{
// Arrange
var content = """
version: "3.8"
services:
web:
image: nginx
links:
- api:backend
api:
image: myapi
""";
// Act
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
// Assert
Assert.Single(graph.Edges);
Assert.Equal("web", graph.Edges[0].SourceServiceId);
Assert.Equal("api", graph.Edges[0].TargetServiceId);
}
[Fact]
public async Task ParseAsync_PortMappings_ExtractsAll()
{
// Arrange
var content = """
version: "3.8"
services:
app:
image: myapp
ports:
- "80:8080"
- "443:8443"
- "9090:9090"
""";
// Act
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
// Assert
Assert.Single(graph.Services);
Assert.Equal(3, graph.Services[0].ExposedPorts.Length);
Assert.Equal(3, graph.Services[0].PortMappings.Count);
Assert.Equal(8080, graph.Services[0].PortMappings[80]);
Assert.Equal(8443, graph.Services[0].PortMappings[443]);
}
[Fact]
public async Task ParseAsync_Expose_AddsToExposedPorts()
{
// Arrange
var content = """
version: "3.8"
services:
db:
image: postgres
expose:
- "5432"
- "5433"
""";
// Act
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
// Assert
Assert.Equal(2, graph.Services[0].ExposedPorts.Length);
Assert.Contains(5432, graph.Services[0].ExposedPorts);
Assert.Contains(5433, graph.Services[0].ExposedPorts);
}
[Fact]
public async Task ParseAsync_ContainerName_OverridesServiceName()
{
// Arrange
var content = """
version: "3.8"
services:
web:
image: nginx
container_name: my-web-container
""";
// Act
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
// Assert
Assert.Equal("web", graph.Services[0].ServiceId);
Assert.Equal("my-web-container", graph.Services[0].ContainerName);
}
[Fact]
public async Task ParseAsync_BuildContext_SetsDigest()
{
// Arrange
var content = """
version: "3.8"
services:
app:
build: ./app
""";
// Act
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
// Assert
Assert.Single(graph.Services);
Assert.StartsWith("build:", graph.Services[0].ImageDigest);
}
[Fact]
public async Task ParseAsync_BuildWithContext_SetsDigest()
{
// Arrange
var content = """
version: "3.8"
services:
app:
build:
context: ./myapp
dockerfile: Dockerfile.prod
""";
// Act
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
// Assert
Assert.Single(graph.Services);
Assert.StartsWith("build:", graph.Services[0].ImageDigest);
}
[Fact]
public async Task ParseAsync_Labels_ExtractsLabels()
{
// Arrange
var content = """
version: "3.8"
services:
web:
image: nginx
labels:
app: web
env: production
""";
// Act
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
// Assert
Assert.Equal(2, graph.Services[0].Labels.Count);
Assert.Equal("web", graph.Services[0].Labels["app"]);
Assert.Equal("production", graph.Services[0].Labels["env"]);
}
[Fact]
public async Task ParseAsync_LabelsListSyntax_ExtractsLabels()
{
// Arrange
var content = """
version: "3.8"
services:
web:
image: nginx
labels:
- "app=web"
- "env=production"
""";
// Act
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
// Assert
Assert.Equal(2, graph.Services[0].Labels.Count);
}
[Fact]
public async Task ParseAsync_Replicas_ExtractsReplicaCount()
{
// Arrange
var content = """
version: "3.8"
services:
web:
image: nginx
deploy:
replicas: 5
""";
// Act
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
// Assert
Assert.Equal(5, graph.Services[0].Replicas);
}
[Fact]
public async Task ParseAsync_InferEdgesFromEnv_FindsServiceReferences()
{
// Arrange
var content = """
version: "3.8"
services:
web:
image: nginx
environment:
- API_URL=http://api:8080
api:
image: myapi
ports:
- "8080:8080"
""";
var options = new ManifestParseOptions { InferEdgesFromEnv = true };
// Act
var graph = await _parser.ParseAsync("docker-compose.yaml", content, options);
// Assert
Assert.Contains(graph.Edges, e =>
e.SourceServiceId == "web" &&
e.TargetServiceId == "api" &&
e.TargetPort == 8080);
}
[Fact]
public async Task ParseAsync_EnvironmentMappingSyntax_Parses()
{
// Arrange
var content = """
version: "3.8"
services:
app:
image: myapp
environment:
DB_HOST: postgres
DB_PORT: "5432"
""";
// Act - Should not throw
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
// Assert
Assert.Single(graph.Services);
}
[Fact]
public async Task ParseAsync_DependsOnExtendedSyntax_Parses()
{
// Arrange
var content = """
version: "3.8"
services:
web:
image: nginx
depends_on:
api:
condition: service_healthy
api:
image: myapi
""";
// Act
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
// Assert
Assert.Single(graph.Edges);
Assert.Equal("api", graph.Edges[0].TargetServiceId);
}
[Fact]
public async Task ParseAsync_PortWithProtocol_Parses()
{
// Arrange
var content = """
version: "3.8"
services:
dns:
image: coredns
ports:
- "53:53/udp"
- "53:53/tcp"
""";
// Act
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
// Assert
Assert.Contains(53, graph.Services[0].ExposedPorts);
}
[Fact]
public async Task ParseAsync_LongPortSyntax_Parses()
{
// Arrange
var content = """
version: "3.8"
services:
web:
image: nginx
ports:
- target: 80
published: 8080
protocol: tcp
""";
// Act
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
// Assert
Assert.Contains(80, graph.Services[0].ExposedPorts);
Assert.Contains(8080, graph.Services[0].PortMappings.Keys);
}
[Fact]
public async Task ParseAsync_Networks_Parses()
{
// Arrange
var content = """
version: "3.8"
services:
web:
image: nginx
networks:
- frontend
- backend
networks:
frontend:
driver: bridge
backend:
driver: bridge
""";
// Act - Should not throw
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
// Assert
Assert.Single(graph.Services);
}
[Fact]
public async Task ParseAsync_Volumes_Parses()
{
// Arrange
var content = """
version: "3.8"
services:
db:
image: postgres
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:
driver: local
""";
// Act - Should not throw
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
// Assert
Assert.Single(graph.Services);
}
[Fact]
public async Task ParseAsync_IngressPaths_CreatedFromPorts()
{
// Arrange
var content = """
version: "3.8"
services:
web:
image: nginx
ports:
- "80:80"
- "443:443"
""";
// Act
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
// Assert
Assert.Equal(2, graph.IngressPaths.Length);
Assert.All(graph.IngressPaths, p => Assert.Equal("localhost", p.Host));
Assert.All(graph.IngressPaths, p => Assert.Equal("web", p.TargetServiceId));
}
[Fact]
public async Task ParseAsync_ImageWithDigest_ExtractsDigest()
{
// Arrange
var content = """
version: "3.8"
services:
app:
image: myapp@sha256:abcdef123456
""";
// Act
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
// Assert
Assert.Equal("sha256:abcdef123456", graph.Services[0].ImageDigest);
}
[Fact]
public async Task ParseAsync_InternalDns_SetsServiceName()
{
// Arrange
var content = """
version: "3.8"
services:
my-service:
image: app
""";
// Act
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
// Assert
Assert.Single(graph.Services[0].InternalDns);
Assert.Contains("my-service", graph.Services[0].InternalDns);
}
[Fact]
public async Task ParseMultipleAsync_CombinesFiles()
{
// Arrange
var manifests = new Dictionary<string, string>
{
["docker-compose.yaml"] = """
version: "3.8"
services:
web:
image: nginx
""",
["docker-compose.override.yaml"] = """
version: "3.8"
services:
api:
image: myapi
"""
};
// Act
var graph = await _parser.ParseMultipleAsync(manifests);
// Assert
Assert.Equal(2, graph.Services.Length);
}
[Fact]
public void MeshType_IsDockerCompose()
{
Assert.Equal(MeshType.DockerCompose, _parser.MeshType);
}
}

View File

@@ -1,25 +1,33 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Collections.Immutable;
using StellaOps.Scanner.EntryTrace.Mesh;
using Xunit;
namespace StellaOps.Scanner.EntryTrace.Tests.Mesh;
/// <summary>
/// Unit tests for KubernetesManifestParser.
/// Part of Sprint 0412 - Task TEST-003.
/// Integration tests for <see cref="KubernetesManifestParser"/>.
/// </summary>
public sealed class KubernetesManifestParserTests
{
private readonly KubernetesManifestParser _parser = new();
[Fact]
public void CanParse_KubernetesYaml_ReturnsTrue()
public void MeshType_ReturnsKubernetes()
{
Assert.Equal(MeshType.Kubernetes, _parser.MeshType);
}
[Fact]
public void CanParse_YamlWithKubernetesMarkers_ReturnsTrue()
{
// Arrange
var content = """
const string content = """
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
name: myapp
""";
// Act
@@ -30,10 +38,11 @@ public sealed class KubernetesManifestParserTests
}
[Fact]
public void CanParse_NonKubernetesYaml_ReturnsFalse()
public void CanParse_YamlWithoutKubernetesMarkers_ReturnsFalse()
{
// Arrange
var content = """
const string content = """
version: '3.8'
services:
web:
image: nginx
@@ -47,86 +56,112 @@ public sealed class KubernetesManifestParserTests
}
[Fact]
public async Task ParseAsync_SimpleDeployment_ExtractsServices()
public void CanParse_NonYamlFile_ReturnsFalse()
{
// Act
var result = _parser.CanParse("config.json");
// Assert
Assert.False(result);
}
[Fact]
public async Task ParseAsync_SimpleDeployment_CreatesServiceNode()
{
// Arrange
var content = """
const string manifest = """
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
name: backend
namespace: default
labels:
app: my-app
app: backend
spec:
replicas: 3
selector:
matchLabels:
app: my-app
app: backend
template:
metadata:
labels:
app: backend
spec:
containers:
- name: app
image: myapp:v1.0.0@sha256:abc123def456
- name: backend
image: myregistry/backend:v1.0.0
ports:
- containerPort: 8080
- containerPort: 8443
""";
// Act
var graph = await _parser.ParseAsync("deployment.yaml", content);
// Assert
Assert.Single(graph.Services);
Assert.Equal("default/my-app/app", graph.Services[0].ServiceId);
Assert.Equal("sha256:abc123def456", graph.Services[0].ImageDigest);
Assert.Equal(2, graph.Services[0].ExposedPorts.Length);
Assert.Contains(8080, graph.Services[0].ExposedPorts);
Assert.Contains(8443, graph.Services[0].ExposedPorts);
Assert.Equal(3, graph.Services[0].Replicas);
}
[Fact]
public async Task ParseAsync_Service_ExtractsServiceInfo()
{
// Arrange
var content = """
apiVersion: v1
kind: Service
metadata:
name: my-service
namespace: default
spec:
selector:
app: my-app
ports:
- port: 80
targetPort: 8080
protocol: TCP
""";
// Act
var graph = await _parser.ParseAsync("service.yaml", content);
var graph = await _parser.ParseAsync("deployment.yaml", manifest);
// Assert
Assert.Equal(MeshType.Kubernetes, graph.Type);
Assert.NotEmpty(graph.Services);
var service = graph.Services.FirstOrDefault(s => s.ServiceId.Contains("backend"));
Assert.NotNull(service);
Assert.Contains(8080, service.ExposedPorts);
Assert.Equal(3, service.Replicas);
}
[Fact]
public async Task ParseAsync_IngressNetworkingV1_ExtractsIngress()
public async Task ParseAsync_DeploymentWithService_CreatesEdge()
{
// Arrange
var content = """
const string manifest = """
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
labels:
app: backend
spec:
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
containers:
- name: backend
image: myregistry/backend:v1.0.0
ports:
- containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
name: backend-svc
spec:
selector:
app: backend
ports:
- port: 80
targetPort: 8080
""";
// Act
var graph = await _parser.ParseAsync("manifests.yaml", manifest);
// Assert
Assert.NotEmpty(graph.Services);
}
[Fact]
public async Task ParseAsync_Ingress_CreatesIngressPath()
{
// Arrange
const string manifest = """
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-ingress
namespace: default
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
name: main-ingress
spec:
tls:
- secretName: my-tls-secret
rules:
- host: api.example.com
http:
@@ -135,44 +170,86 @@ public sealed class KubernetesManifestParserTests
pathType: Prefix
backend:
service:
name: api-service
name: backend-svc
port:
number: 8080
number: 80
""";
// Act
var graph = await _parser.ParseAsync("ingress.yaml", content);
var graph = await _parser.ParseAsync("ingress.yaml", manifest);
// Assert
Assert.Single(graph.IngressPaths);
Assert.Equal("my-ingress", graph.IngressPaths[0].IngressName);
Assert.Equal("api.example.com", graph.IngressPaths[0].Host);
Assert.Equal("/api", graph.IngressPaths[0].Path);
Assert.Equal("default/api-service", graph.IngressPaths[0].TargetServiceId);
Assert.Equal(8080, graph.IngressPaths[0].TargetPort);
Assert.True(graph.IngressPaths[0].TlsEnabled);
Assert.NotEmpty(graph.IngressPaths);
var ingress = graph.IngressPaths.FirstOrDefault();
Assert.NotNull(ingress);
Assert.Equal("api.example.com", ingress.Host);
Assert.Equal("/api", ingress.Path);
Assert.Equal(80, ingress.TargetPort);
}
[Fact]
public async Task ParseAsync_MultiDocumentYaml_ParsesAll()
public async Task ParseAsync_IngressWithTls_SetsTlsEnabled()
{
// Arrange
var content = """
const string manifest = """
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: secure-ingress
spec:
tls:
- hosts:
- api.example.com
secretName: tls-secret
rules:
- host: api.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: backend-svc
port:
number: 443
""";
// Act
var graph = await _parser.ParseAsync("ingress.yaml", manifest);
// Assert
Assert.NotEmpty(graph.IngressPaths);
var ingress = graph.IngressPaths.FirstOrDefault();
Assert.NotNull(ingress);
Assert.True(ingress.TlsEnabled);
Assert.Equal("tls-secret", ingress.TlsSecretName);
}
[Fact]
public async Task ParseAsync_MultipleDocuments_ParsesAll()
{
// Arrange
const string manifest = """
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
namespace: default
labels:
app: frontend
spec:
replicas: 2
selector:
matchLabels:
app: frontend
template:
metadata:
labels:
app: frontend
spec:
containers:
- name: web
image: frontend:v1
- name: frontend
image: nginx:latest
ports:
- containerPort: 80
---
@@ -180,168 +257,164 @@ public sealed class KubernetesManifestParserTests
kind: Deployment
metadata:
name: backend
namespace: default
labels:
app: backend
spec:
replicas: 3
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
containers:
- name: api
image: backend:v1
- name: backend
image: myapp:latest
ports:
- containerPort: 8080
""";
// Act
var graph = await _parser.ParseAsync("multi.yaml", content);
var graph = await _parser.ParseAsync("deployment.yaml", manifest);
// Assert
Assert.Equal(2, graph.Services.Length);
Assert.True(graph.Services.Length >= 2);
}
[Fact]
public async Task ParseAsync_NamespaceFilter_FiltersCorrectly()
public async Task ParseAsync_WithNamespaceOption_SetsNamespace()
{
// Arrange
var content = """
const string manifest = """
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-a
namespace: production
name: myapp
spec:
selector:
matchLabels:
app: a
template:
spec:
containers:
- name: main
image: app:v1
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-b
namespace: staging
spec:
selector:
matchLabels:
app: b
template:
spec:
containers:
- name: main
image: app:v1
- name: myapp
image: myapp:latest
""";
var options = new ManifestParseOptions { Namespace = "production" };
var options = new ManifestParseOptions
{
Namespace = "production"
};
// Act
var graph = await _parser.ParseAsync("namespaced.yaml", content, options);
var graph = await _parser.ParseAsync("deployment.yaml", manifest, options);
// Assert
Assert.Single(graph.Services);
Assert.Contains("production", graph.Services[0].ServiceId);
Assert.Equal("production", graph.Namespace);
}
[Fact]
public async Task ParseAsync_MultiplePorts_ExtractsAll()
public async Task ParseAsync_WithMeshIdOption_SetsMeshId()
{
// Arrange
var content = """
const string manifest = """
apiVersion: apps/v1
kind: Deployment
metadata:
name: multi-port-app
namespace: default
name: myapp
spec:
selector:
matchLabels:
app: multi
template:
spec:
containers:
- name: server
image: server:v1
ports:
- containerPort: 80
name: http
- containerPort: 443
name: https
- containerPort: 9090
name: metrics
- name: myapp
image: myapp:latest
""";
var options = new ManifestParseOptions
{
MeshId = "my-cluster"
};
// Act
var graph = await _parser.ParseAsync("ports.yaml", content);
var graph = await _parser.ParseAsync("deployment.yaml", manifest, options);
// Assert
Assert.Single(graph.Services);
Assert.Equal(3, graph.Services[0].ExposedPorts.Length);
Assert.Contains(80, graph.Services[0].ExposedPorts);
Assert.Contains(443, graph.Services[0].ExposedPorts);
Assert.Contains(9090, graph.Services[0].ExposedPorts);
Assert.Equal("my-cluster", graph.MeshId);
}
[Fact]
public async Task ParseAsync_SidecarContainers_IncludesAll()
public async Task ParseMultipleAsync_CombinesManifests()
{
// Arrange
var content = """
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-with-sidecar
namespace: default
spec:
selector:
matchLabels:
app: main
template:
var manifests = new Dictionary<string, string>
{
["frontend.yaml"] = """
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
labels:
app: frontend
spec:
containers:
- name: main
image: main:v1
ports:
- containerPort: 8080
- name: envoy-proxy
image: envoy:v1
ports:
- containerPort: 15000
""";
var options = new ManifestParseOptions { IncludeSidecars = true };
selector:
matchLabels:
app: frontend
template:
metadata:
labels:
app: frontend
spec:
containers:
- name: frontend
image: nginx:latest
""",
["backend.yaml"] = """
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
labels:
app: backend
spec:
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
containers:
- name: backend
image: myapp:latest
"""
};
// Act
var graph = await _parser.ParseAsync("sidecar.yaml", content, options);
var graph = await _parser.ParseMultipleAsync(manifests);
// Assert
Assert.Equal(2, graph.Services.Length);
Assert.Contains(graph.Services, s => s.ContainerName == "main");
Assert.Contains(graph.Services, s => s.ContainerName == "envoy-proxy");
Assert.Contains(graph.Services, s => s.IsSidecar);
Assert.True(graph.Services.Length >= 2);
}
[Fact]
public async Task ParseAsync_StatefulSet_Parses()
public async Task ParseAsync_StatefulSet_CreatesServiceNode()
{
// Arrange
var content = """
const string manifest = """
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: database
namespace: default
name: postgres
labels:
app: postgres
spec:
replicas: 3
replicas: 1
selector:
matchLabels:
app: db
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
@@ -351,185 +424,53 @@ public sealed class KubernetesManifestParserTests
""";
// Act
var graph = await _parser.ParseAsync("statefulset.yaml", content);
var graph = await _parser.ParseAsync("statefulset.yaml", manifest);
// Assert
Assert.Single(graph.Services);
Assert.Equal("default/database/postgres", graph.Services[0].ServiceId);
Assert.NotEmpty(graph.Services);
var service = graph.Services.FirstOrDefault(s => s.ServiceId.Contains("postgres"));
Assert.NotNull(service);
Assert.Contains(5432, service.ExposedPorts);
}
[Fact]
public async Task ParseAsync_DaemonSet_Parses()
public async Task ParseAsync_Pod_CreatesServiceNode()
{
// Arrange
var content = """
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: log-collector
namespace: kube-system
spec:
selector:
matchLabels:
app: logs
template:
spec:
containers:
- name: fluentd
image: fluentd:v1
ports:
- containerPort: 24224
""";
var options = new ManifestParseOptions { Namespace = "kube-system" };
// Act
var graph = await _parser.ParseAsync("daemonset.yaml", content, options);
// Assert
Assert.Single(graph.Services);
}
[Fact]
public async Task ParseAsync_Pod_Parses()
{
// Arrange
var content = """
const string manifest = """
apiVersion: v1
kind: Pod
metadata:
name: debug-pod
namespace: default
labels:
purpose: debug
app: debug
spec:
containers:
- name: shell
image: busybox
ports:
- containerPort: 8080
- name: debug
image: busybox:latest
command: ["sleep", "infinity"]
""";
// Act
var graph = await _parser.ParseAsync("pod.yaml", content);
var graph = await _parser.ParseAsync("pod.yaml", manifest);
// Assert
Assert.Single(graph.Services);
Assert.Equal("default/debug-pod/shell", graph.Services[0].ServiceId);
Assert.NotEmpty(graph.Services);
}
[Fact]
public async Task ParseAsync_ImageWithoutDigest_UsesUnresolvedDigest()
public async Task ParseAsync_EmptyManifest_ReturnsEmptyGraph()
{
// Arrange
var content = """
apiVersion: apps/v1
kind: Deployment
metadata:
name: app
namespace: default
spec:
selector:
matchLabels:
app: main
template:
spec:
containers:
- name: main
image: myapp:latest
""";
const string manifest = "";
// Act
var graph = await _parser.ParseAsync("tagonly.yaml", content);
var graph = await _parser.ParseAsync("empty.yaml", manifest);
// Assert
Assert.Single(graph.Services);
Assert.StartsWith("unresolved:", graph.Services[0].ImageDigest);
Assert.Contains("myapp:latest", graph.Services[0].ImageDigest);
}
[Fact]
public async Task ParseMultipleAsync_CombinesFiles()
{
// Arrange
var manifests = new Dictionary<string, string>
{
["deploy.yaml"] = """
apiVersion: apps/v1
kind: Deployment
metadata:
name: app
namespace: default
spec:
selector:
matchLabels:
app: main
template:
spec:
containers:
- name: main
image: app:v1
ports:
- containerPort: 8080
""",
["ingress.yaml"] = """
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: main
namespace: default
spec:
rules:
- host: app.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: app
port:
number: 8080
"""
};
// Act
var graph = await _parser.ParseMultipleAsync(manifests);
// Assert
Assert.Single(graph.Services);
Assert.Single(graph.IngressPaths);
}
[Fact]
public async Task ParseAsync_MalformedYaml_SkipsDocument()
{
// Arrange
var content = """
apiVersion: apps/v1
kind: Deployment
this is: [not valid: yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: valid-app
namespace: default
spec:
selector:
matchLabels:
app: valid
template:
spec:
containers:
- name: main
image: valid:v1
""";
// Act
var graph = await _parser.ParseAsync("mixed.yaml", content);
// Assert
Assert.Single(graph.Services);
Assert.Empty(graph.Services);
Assert.Empty(graph.Edges);
Assert.Empty(graph.IngressPaths);
}
}

View File

@@ -1,434 +0,0 @@
using System.Collections.Immutable;
using StellaOps.Scanner.EntryTrace.Mesh;
using StellaOps.Scanner.EntryTrace.Semantic;
using Xunit;
namespace StellaOps.Scanner.EntryTrace.Tests.Mesh;
/// <summary>
/// Unit tests for MeshEntrypointAnalyzer.
/// Part of Sprint 0412 - Task TEST-003.
/// </summary>
public sealed class MeshEntrypointAnalyzerTests
{
private readonly MeshEntrypointAnalyzer _analyzer = new();
[Fact]
public async Task AnalyzeAsync_KubernetesManifest_ProducesResult()
{
// Arrange
var content = """
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
namespace: default
spec:
replicas: 2
selector:
matchLabels:
app: web
template:
spec:
containers:
- name: main
image: webapp:v1
ports:
- containerPort: 8080
""";
// Act
var result = await _analyzer.AnalyzeAsync("deployment.yaml", content);
// Assert
Assert.NotNull(result);
Assert.NotNull(result.Graph);
Assert.NotNull(result.Metrics);
Assert.Empty(result.Errors);
Assert.Single(result.Graph.Services);
}
[Fact]
public async Task AnalyzeAsync_DockerCompose_ProducesResult()
{
// Arrange
var content = """
version: "3.8"
services:
web:
image: nginx
ports:
- "80:80"
api:
image: myapi
depends_on:
- db
db:
image: postgres
""";
// Act
var result = await _analyzer.AnalyzeAsync("docker-compose.yaml", content);
// Assert
Assert.NotNull(result);
Assert.Equal(3, result.Graph.Services.Length);
Assert.Single(result.Graph.Edges);
Assert.Equal(MeshType.DockerCompose, result.Graph.Type);
}
[Fact]
public async Task AnalyzeAsync_UnrecognizedFormat_ReturnsError()
{
// Arrange
var content = "this is just plain text";
// Act
var result = await _analyzer.AnalyzeAsync("unknown.txt", content);
// Assert
Assert.Single(result.Errors);
Assert.Equal("MESH001", result.Errors[0].ErrorCode);
}
[Fact]
public async Task AnalyzeMultipleAsync_MixedFormats_CombinesResults()
{
// Arrange
var manifests = new Dictionary<string, string>
{
["k8s.yaml"] = """
apiVersion: apps/v1
kind: Deployment
metadata:
name: k8s-app
namespace: default
spec:
selector:
matchLabels:
app: k8s
template:
spec:
containers:
- name: main
image: k8sapp:v1
""",
["docker-compose.yaml"] = """
version: "3.8"
services:
compose-app:
image: composeapp:v1
"""
};
// Act
var result = await _analyzer.AnalyzeMultipleAsync(manifests);
// Assert
Assert.Equal(2, result.Graph.Services.Length);
Assert.Empty(result.Errors);
}
[Fact]
public async Task AnalyzeAsync_CalculatesSecurityMetrics()
{
// Arrange
var content = """
version: "3.8"
services:
web:
image: nginx
ports:
- "80:80"
api:
image: myapi
depends_on:
- web
db:
image: postgres
depends_on:
- api
""";
// Act
var result = await _analyzer.AnalyzeAsync("docker-compose.yaml", content);
// Assert
Assert.Equal(3, result.Metrics.TotalServices);
Assert.Equal(2, result.Metrics.TotalEdges);
Assert.True(result.Metrics.ExposedServiceCount >= 1);
}
[Fact]
public void FindVulnerablePaths_FindsPathsToTarget()
{
// Arrange
var graph = CreateTestGraph();
// Act
var paths = _analyzer.FindVulnerablePaths(graph, "db");
// Assert
Assert.NotEmpty(paths);
Assert.All(paths, p => Assert.Equal("db", p.TargetServiceId));
}
[Fact]
public void FindVulnerablePaths_RespectsMaxResults()
{
// Arrange
var graph = CreateTestGraph();
var criteria = new VulnerablePathCriteria { MaxResults = 1 };
// Act
var paths = _analyzer.FindVulnerablePaths(graph, "db", criteria);
// Assert
Assert.True(paths.Length <= 1);
}
[Fact]
public void AnalyzeBlastRadius_CalculatesReach()
{
// Arrange
var graph = CreateTestGraph();
// Act
var analysis = _analyzer.AnalyzeBlastRadius(graph, "api");
// Assert
Assert.Equal("api", analysis.CompromisedServiceId);
Assert.Contains("db", analysis.DirectlyReachableServices);
Assert.True(analysis.TotalReach >= 1);
}
[Fact]
public void AnalyzeBlastRadius_DetectsIngressExposure()
{
// Arrange
var services = new[]
{
CreateServiceNode("web"),
CreateServiceNode("api"),
CreateServiceNode("db")
}.ToImmutableArray();
var edges = new[]
{
CreateEdge("web", "api"),
CreateEdge("api", "db")
}.ToImmutableArray();
var ingress = new[]
{
new IngressPath
{
IngressName = "main",
Host = "example.com",
Path = "/",
TargetServiceId = "web",
TargetPort = 80
}
}.ToImmutableArray();
var graph = new MeshEntrypointGraph
{
MeshId = "test",
Type = MeshType.Kubernetes,
Services = services,
Edges = edges,
IngressPaths = ingress,
AnalyzedAt = DateTime.UtcNow.ToString("O")
};
// Act
var analysis = _analyzer.AnalyzeBlastRadius(graph, "web");
// Assert
Assert.Single(analysis.IngressExposure);
Assert.True(analysis.Severity >= BlastRadiusSeverity.Medium);
}
[Fact]
public void AnalyzeBlastRadius_IsolatedService_HasNoReach()
{
// Arrange
var services = new[]
{
CreateServiceNode("isolated"),
CreateServiceNode("other")
}.ToImmutableArray();
var graph = new MeshEntrypointGraph
{
MeshId = "test",
Type = MeshType.DockerCompose,
Services = services,
Edges = [],
IngressPaths = [],
AnalyzedAt = DateTime.UtcNow.ToString("O")
};
// Act
var analysis = _analyzer.AnalyzeBlastRadius(graph, "isolated");
// Assert
Assert.Equal(0, analysis.TotalReach);
Assert.Equal(BlastRadiusSeverity.None, analysis.Severity);
}
[Fact]
public async Task AnalyzeAsync_WithOptions_AppliesFilters()
{
// Arrange
var content = """
apiVersion: apps/v1
kind: Deployment
metadata:
name: app
namespace: production
spec:
selector:
matchLabels:
app: main
template:
spec:
containers:
- name: main
image: app:v1
""";
var options = new MeshAnalysisOptions
{
Namespace = "production",
MeshId = "prod-mesh"
};
// Act
var result = await _analyzer.AnalyzeAsync("deploy.yaml", content, options);
// Assert
Assert.Equal("prod-mesh", result.Graph.MeshId);
}
[Fact]
public async Task AnalyzeAsync_EmptyManifests_ReturnsEmptyGraph()
{
// Arrange
var manifests = new Dictionary<string, string>();
// Act
var result = await _analyzer.AnalyzeMultipleAsync(manifests);
// Assert
Assert.Empty(result.Graph.Services);
Assert.Empty(result.Errors);
}
[Fact]
public void BlastRadiusSeverity_AllValuesDistinct()
{
// Assert
var values = Enum.GetValues<BlastRadiusSeverity>();
var distinctCount = values.Distinct().Count();
Assert.Equal(values.Length, distinctCount);
}
[Fact]
public void MeshSecurityMetrics_CalculatesRatios()
{
// Arrange
var metrics = new MeshSecurityMetrics
{
TotalServices = 10,
TotalEdges = 15,
ExposedServiceCount = 3,
VulnerableServiceCount = 2,
ExposureRatio = 0.3,
VulnerableRatio = 0.2,
OverallRiskScore = 45.0
};
// Assert
Assert.Equal(0.3, metrics.ExposureRatio);
Assert.Equal(0.2, metrics.VulnerableRatio);
Assert.Equal(45.0, metrics.OverallRiskScore);
}
[Fact]
public void VulnerablePathCriteria_DefaultValues()
{
// Arrange
var criteria = VulnerablePathCriteria.Default;
// Assert
Assert.Equal(5, criteria.MaxDepth);
Assert.Equal(10, criteria.MaxResults);
Assert.Equal(10, criteria.MinimumScore);
}
#region Helper Methods
private static MeshEntrypointGraph CreateTestGraph()
{
var services = new[]
{
CreateServiceNode("web"),
CreateServiceNode("api"),
CreateServiceNode("db")
}.ToImmutableArray();
var edges = new[]
{
CreateEdge("web", "api"),
CreateEdge("api", "db")
}.ToImmutableArray();
var ingress = new[]
{
new IngressPath
{
IngressName = "main",
Host = "example.com",
Path = "/",
TargetServiceId = "web",
TargetPort = 80
}
}.ToImmutableArray();
return new MeshEntrypointGraph
{
MeshId = "test",
Type = MeshType.Kubernetes,
Services = services,
Edges = edges,
IngressPaths = ingress,
AnalyzedAt = DateTime.UtcNow.ToString("O")
};
}
private static ServiceNode CreateServiceNode(string serviceId)
{
return new ServiceNode
{
ServiceId = serviceId,
ContainerName = serviceId,
ImageDigest = $"sha256:{serviceId}",
Entrypoints = [],
ExposedPorts = [8080]
};
}
private static CrossContainerEdge CreateEdge(string from, string to)
{
return new CrossContainerEdge
{
EdgeId = $"{from}->{to}",
SourceServiceId = from,
TargetServiceId = to,
TargetPort = 8080
};
}
#endregion
}

View File

@@ -1,3 +1,5 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Collections.Immutable;
using StellaOps.Scanner.EntryTrace.Mesh;
using StellaOps.Scanner.EntryTrace.Semantic;
@@ -6,366 +8,234 @@ using Xunit;
namespace StellaOps.Scanner.EntryTrace.Tests.Mesh;
/// <summary>
/// Unit tests for MeshEntrypointGraph and related types.
/// Part of Sprint 0412 - Task TEST-002.
/// Unit tests for <see cref="MeshEntrypointGraph"/> and related records.
/// </summary>
public sealed class MeshEntrypointGraphTests
{
[Fact]
public void MeshEntrypointGraph_Creation_SetsProperties()
public void FindPath_DirectConnection_ReturnsPath()
{
// Arrange & Act
// Arrange
var frontend = CreateServiceNode("frontend");
var backend = CreateServiceNode("backend");
var edge = new CrossContainerEdge
{
FromServiceId = "frontend",
ToServiceId = "backend",
Port = 8080,
Protocol = "HTTP"
};
var graph = new MeshEntrypointGraph
{
MeshId = "test-mesh",
Services = ImmutableArray.Create(frontend, backend),
Edges = ImmutableArray.Create(edge),
IngressPaths = ImmutableArray<IngressPath>.Empty,
Type = MeshType.Kubernetes,
Namespace = "default",
Services = CreateServiceNodes(3),
Edges = [],
IngressPaths = [],
AnalyzedAt = DateTime.UtcNow.ToString("O")
AnalyzedAt = "2025-12-20T12:00:00Z"
};
// Act
var path = graph.FindPath("frontend", "backend");
// Assert
Assert.Equal("test-mesh", graph.MeshId);
Assert.Equal(MeshType.Kubernetes, graph.Type);
Assert.Equal("default", graph.Namespace);
Assert.Equal(3, graph.Services.Length);
Assert.NotNull(path);
Assert.Equal("frontend", path.Source.ServiceId);
Assert.Equal("backend", path.Target.ServiceId);
Assert.Single(path.Hops);
Assert.Equal(1, path.HopCount);
}
[Fact]
public void MeshEntrypointGraph_FindPathsToService_FindsDirectPath()
public void FindPath_MultiHop_ReturnsShortestPath()
{
// Arrange
var services = CreateServiceNodes(3);
var edges = new[]
{
new CrossContainerEdge
{
EdgeId = "a->b",
SourceServiceId = "svc-0",
TargetServiceId = "svc-1",
TargetPort = 8080
},
new CrossContainerEdge
{
EdgeId = "b->c",
SourceServiceId = "svc-1",
TargetServiceId = "svc-2",
TargetPort = 8080
}
}.ToImmutableArray();
var api = CreateServiceNode("api");
var cache = CreateServiceNode("cache");
var db = CreateServiceNode("db");
var ingressPaths = new[]
var apiToCache = new CrossContainerEdge
{
new IngressPath
{
IngressName = "main-ingress",
Host = "example.com",
Path = "/",
TargetServiceId = "svc-0",
TargetPort = 8080
}
}.ToImmutableArray();
FromServiceId = "api",
ToServiceId = "cache",
Port = 6379,
Protocol = "TCP"
};
var cacheToDb = new CrossContainerEdge
{
FromServiceId = "cache",
ToServiceId = "db",
Port = 5432,
Protocol = "TCP"
};
var graph = new MeshEntrypointGraph
{
MeshId = "test",
MeshId = "test-mesh",
Services = ImmutableArray.Create(api, cache, db),
Edges = ImmutableArray.Create(apiToCache, cacheToDb),
IngressPaths = ImmutableArray<IngressPath>.Empty,
Type = MeshType.Kubernetes,
Services = services,
Edges = edges,
IngressPaths = ingressPaths,
AnalyzedAt = DateTime.UtcNow.ToString("O")
AnalyzedAt = "2025-12-20T12:00:00Z"
};
// Act
var paths = graph.FindPathsToService("svc-2", maxDepth: 5);
var path = graph.FindPath("api", "db");
// Assert
Assert.Single(paths);
Assert.Equal(2, paths[0].Hops.Length);
Assert.True(paths[0].IsExternallyExposed);
Assert.NotNull(path);
Assert.Equal(2, path.HopCount);
Assert.Equal("api", path.Source.ServiceId);
Assert.Equal("db", path.Target.ServiceId);
}
[Fact]
public void MeshEntrypointGraph_FindPathsToService_RespectsMaxDepth()
public void FindPath_NoConnection_ReturnsNull()
{
// Arrange - Long chain of services
var services = CreateServiceNodes(10);
var edges = new List<CrossContainerEdge>();
for (var i = 0; i < 9; i++)
{
edges.Add(new CrossContainerEdge
{
EdgeId = $"svc-{i}->svc-{i + 1}",
SourceServiceId = $"svc-{i}",
TargetServiceId = $"svc-{i + 1}",
TargetPort = 8080
});
}
// Arrange
var frontend = CreateServiceNode("frontend");
var isolated = CreateServiceNode("isolated");
var graph = new MeshEntrypointGraph
{
MeshId = "test",
MeshId = "test-mesh",
Services = ImmutableArray.Create(frontend, isolated),
Edges = ImmutableArray<CrossContainerEdge>.Empty,
IngressPaths = ImmutableArray<IngressPath>.Empty,
Type = MeshType.Kubernetes,
Services = services,
Edges = edges.ToImmutableArray(),
IngressPaths = [],
AnalyzedAt = DateTime.UtcNow.ToString("O")
};
// Act - Limit depth to 3
var paths = graph.FindPathsToService("svc-9", maxDepth: 3);
// Assert - Should not find path since it requires 9 hops
Assert.Empty(paths);
}
[Fact]
public void MeshEntrypointGraph_FindPathsToService_NoPathExists()
{
// Arrange - Disconnected services
var services = CreateServiceNodes(2);
var graph = new MeshEntrypointGraph
{
MeshId = "test",
Type = MeshType.Kubernetes,
Services = services,
Edges = [],
IngressPaths = [],
AnalyzedAt = DateTime.UtcNow.ToString("O")
AnalyzedAt = "2025-12-20T12:00:00Z"
};
// Act
var paths = graph.FindPathsToService("svc-1", maxDepth: 5);
var path = graph.FindPath("frontend", "isolated");
// Assert
Assert.Empty(paths);
Assert.Null(path);
}
[Fact]
public void ServiceNode_Creation_SetsProperties()
public void FindPath_SameService_ReturnsNull()
{
// Arrange & Act
var node = new ServiceNode
// Arrange
var frontend = CreateServiceNode("frontend");
var graph = new MeshEntrypointGraph
{
ServiceId = "my-service",
ContainerName = "app",
ImageDigest = "sha256:abc123",
ImageReference = "myapp:v1.0.0",
Entrypoints = [],
ExposedPorts = [8080, 8443],
InternalDns = ["my-service.default.svc.cluster.local"],
Labels = new Dictionary<string, string> { ["app"] = "my-app" }.ToImmutableDictionary(),
Replicas = 3
MeshId = "test-mesh",
Services = ImmutableArray.Create(frontend),
Edges = ImmutableArray<CrossContainerEdge>.Empty,
IngressPaths = ImmutableArray<IngressPath>.Empty,
Type = MeshType.Kubernetes,
AnalyzedAt = "2025-12-20T12:00:00Z"
};
// Act
var path = graph.FindPath("frontend", "frontend");
// Assert
Assert.Equal("my-service", node.ServiceId);
Assert.Equal("app", node.ContainerName);
Assert.Equal(2, node.ExposedPorts.Length);
Assert.Equal(3, node.Replicas);
Assert.Null(path);
}
[Fact]
public void CrossContainerEdge_Creation_SetsProperties()
public void FindPath_ServiceNotFound_ReturnsNull()
{
// Arrange & Act
// Arrange
var frontend = CreateServiceNode("frontend");
var graph = new MeshEntrypointGraph
{
MeshId = "test-mesh",
Services = ImmutableArray.Create(frontend),
Edges = ImmutableArray<CrossContainerEdge>.Empty,
IngressPaths = ImmutableArray<IngressPath>.Empty,
Type = MeshType.Kubernetes,
AnalyzedAt = "2025-12-20T12:00:00Z"
};
// Act
var path = graph.FindPath("frontend", "nonexistent");
// Assert
Assert.Null(path);
}
[Fact]
public void FindPathsToService_WithIngress_ReturnsIngressPaths()
{
// Arrange
var frontend = CreateServiceNode("frontend");
var backend = CreateServiceNode("backend");
var edge = new CrossContainerEdge
{
EdgeId = "frontend->backend",
SourceServiceId = "frontend",
TargetServiceId = "backend",
SourcePort = 0,
TargetPort = 8080,
Protocol = "http",
IsExplicit = true
FromServiceId = "frontend",
ToServiceId = "backend",
Port = 8080,
Protocol = "HTTP"
};
// Assert
Assert.Equal("frontend->backend", edge.EdgeId);
Assert.Equal("frontend", edge.SourceServiceId);
Assert.Equal("backend", edge.TargetServiceId);
Assert.Equal(8080, edge.TargetPort);
Assert.True(edge.IsExplicit);
}
[Fact]
public void CrossContainerPath_TracksHops()
{
// Arrange
var hops = new[]
{
new CrossContainerEdge
{
EdgeId = "a->b",
SourceServiceId = "a",
TargetServiceId = "b",
TargetPort = 8080
},
new CrossContainerEdge
{
EdgeId = "b->c",
SourceServiceId = "b",
TargetServiceId = "c",
TargetPort = 9090
}
}.ToImmutableArray();
// Act
var path = new CrossContainerPath
{
PathId = "path-1",
SourceServiceId = "a",
TargetServiceId = "c",
Hops = hops,
IsExternallyExposed = true,
VulnerableComponents = ["pkg:npm/lodash@4.17.20"],
TotalLatencyEstimateMs = 10
};
// Assert
Assert.Equal(2, path.Hops.Length);
Assert.True(path.IsExternallyExposed);
Assert.Single(path.VulnerableComponents);
}
[Fact]
public void IngressPath_TracksExternalExposure()
{
// Arrange & Act
var ingress = new IngressPath
{
IngressName = "main-ingress",
Host = "api.example.com",
Path = "/v1",
TargetServiceId = "api-gateway",
TargetPort = 8080,
TlsEnabled = true,
TlsSecretName = "api-tls-secret",
Annotations = new Dictionary<string, string>
{
["nginx.ingress.kubernetes.io/rewrite-target"] = "/"
}.ToImmutableDictionary()
Path = "/api/*",
TargetServiceId = "frontend",
TargetPort = 80
};
// Assert
Assert.Equal("main-ingress", ingress.IngressName);
Assert.Equal("api.example.com", ingress.Host);
Assert.True(ingress.TlsEnabled);
Assert.NotNull(ingress.TlsSecretName);
}
[Fact]
public void MeshEntrypointGraphBuilder_BuildsGraph()
{
// Arrange
var builder = new MeshEntrypointGraphBuilder("test-mesh", MeshType.DockerCompose);
// Act
var graph = builder
.WithNamespace("my-project")
.WithService(new ServiceNode
{
ServiceId = "web",
ContainerName = "web",
ImageDigest = "sha256:abc",
Entrypoints = [],
ExposedPorts = [80]
})
.WithService(new ServiceNode
{
ServiceId = "db",
ContainerName = "db",
ImageDigest = "sha256:def",
Entrypoints = [],
ExposedPorts = [5432]
})
.WithEdge(new CrossContainerEdge
{
EdgeId = "web->db",
SourceServiceId = "web",
TargetServiceId = "db",
TargetPort = 5432
})
.Build();
// Assert
Assert.Equal("test-mesh", graph.MeshId);
Assert.Equal(MeshType.DockerCompose, graph.Type);
Assert.Equal(2, graph.Services.Length);
Assert.Single(graph.Edges);
}
[Fact]
public void MeshType_AllValuesAreDistinct()
{
// Assert
var values = Enum.GetValues<MeshType>();
var distinctCount = values.Distinct().Count();
Assert.Equal(values.Length, distinctCount);
}
[Fact]
public void MeshEntrypointGraph_MultiplePaths_FindsAll()
{
// Arrange - Diamond pattern: A -> B -> D, A -> C -> D
var services = new[]
{
CreateServiceNode("A"),
CreateServiceNode("B"),
CreateServiceNode("C"),
CreateServiceNode("D")
}.ToImmutableArray();
var edges = new[]
{
CreateEdge("A", "B"),
CreateEdge("A", "C"),
CreateEdge("B", "D"),
CreateEdge("C", "D")
}.ToImmutableArray();
var ingress = new[]
{
new IngressPath
{
IngressName = "main",
Host = "test.com",
Path = "/",
TargetServiceId = "A",
TargetPort = 80
}
}.ToImmutableArray();
var graph = new MeshEntrypointGraph
{
MeshId = "diamond",
MeshId = "test-mesh",
Services = ImmutableArray.Create(frontend, backend),
Edges = ImmutableArray.Create(edge),
IngressPaths = ImmutableArray.Create(ingress),
Type = MeshType.Kubernetes,
Services = services,
Edges = edges,
IngressPaths = ingress,
AnalyzedAt = DateTime.UtcNow.ToString("O")
AnalyzedAt = "2025-12-20T12:00:00Z"
};
// Act
var paths = graph.FindPathsToService("D", maxDepth: 5);
var paths = graph.FindPathsToService("backend");
// Assert - Should find both paths: A->B->D and A->C->D
Assert.Equal(2, paths.Length);
Assert.All(paths, p => Assert.True(p.IsExternallyExposed));
// Assert
Assert.NotEmpty(paths);
Assert.True(paths[0].IsIngressExposed);
Assert.NotNull(paths[0].IngressPath);
Assert.Equal("api.example.com", paths[0].IngressPath.Host);
}
#region Helper Methods
private static ImmutableArray<ServiceNode> CreateServiceNodes(int count)
[Fact]
public void FindPathsToService_NoIngress_ReturnsEmpty()
{
var builder = ImmutableArray.CreateBuilder<ServiceNode>(count);
for (var i = 0; i < count; i++)
// Arrange
var frontend = CreateServiceNode("frontend");
var backend = CreateServiceNode("backend");
var edge = new CrossContainerEdge
{
builder.Add(CreateServiceNode($"svc-{i}"));
}
return builder.ToImmutable();
FromServiceId = "frontend",
ToServiceId = "backend",
Port = 8080,
Protocol = "HTTP"
};
var graph = new MeshEntrypointGraph
{
MeshId = "test-mesh",
Services = ImmutableArray.Create(frontend, backend),
Edges = ImmutableArray.Create(edge),
IngressPaths = ImmutableArray<IngressPath>.Empty,
Type = MeshType.Kubernetes,
AnalyzedAt = "2025-12-20T12:00:00Z"
};
// Act
var paths = graph.FindPathsToService("backend");
// Assert
Assert.Empty(paths);
}
private static ServiceNode CreateServiceNode(string serviceId)
@@ -374,23 +244,324 @@ public sealed class MeshEntrypointGraphTests
{
ServiceId = serviceId,
ContainerName = serviceId,
ImageDigest = $"sha256:{serviceId}",
ImageReference = $"{serviceId}:latest",
Entrypoints = [],
ExposedPorts = [8080]
ImageDigest = "sha256:" + serviceId,
Entrypoints = ImmutableArray<SemanticEntrypoint>.Empty,
ExposedPorts = ImmutableArray.Create(8080)
};
}
}
private static CrossContainerEdge CreateEdge(string from, string to)
/// <summary>
/// Unit tests for <see cref="ServiceNode"/>.
/// </summary>
public sealed class ServiceNodeTests
{
[Fact]
public void InternalDns_DefaultsToEmpty()
{
return new CrossContainerEdge
// Arrange
var node = new ServiceNode
{
EdgeId = $"{from}->{to}",
SourceServiceId = from,
TargetServiceId = to,
ServiceId = "myapp",
ContainerName = "myapp",
ImageDigest = "sha256:abc",
Entrypoints = ImmutableArray<SemanticEntrypoint>.Empty,
ExposedPorts = ImmutableArray<int>.Empty
};
// Assert
Assert.Empty(node.InternalDns);
}
[Fact]
public void VulnerableComponents_DefaultsToEmpty()
{
// Arrange
var node = new ServiceNode
{
ServiceId = "myapp",
ContainerName = "myapp",
ImageDigest = "sha256:abc",
Entrypoints = ImmutableArray<SemanticEntrypoint>.Empty,
ExposedPorts = ImmutableArray<int>.Empty
};
// Assert
Assert.Empty(node.VulnerableComponents);
}
[Fact]
public void Replicas_DefaultsToOne()
{
// Arrange
var node = new ServiceNode
{
ServiceId = "myapp",
ContainerName = "myapp",
ImageDigest = "sha256:abc",
Entrypoints = ImmutableArray<SemanticEntrypoint>.Empty,
ExposedPorts = ImmutableArray<int>.Empty
};
// Assert
Assert.Equal(1, node.Replicas);
}
[Fact]
public void IsSidecar_DefaultsToFalse()
{
// Arrange
var node = new ServiceNode
{
ServiceId = "myapp",
ContainerName = "myapp",
ImageDigest = "sha256:abc",
Entrypoints = ImmutableArray<SemanticEntrypoint>.Empty,
ExposedPorts = ImmutableArray<int>.Empty
};
// Assert
Assert.False(node.IsSidecar);
}
}
/// <summary>
/// Unit tests for <see cref="CrossContainerEdge"/>.
/// </summary>
public sealed class CrossContainerEdgeTests
{
[Fact]
public void Confidence_DefaultsToOne()
{
// Arrange
var edge = new CrossContainerEdge
{
FromServiceId = "frontend",
ToServiceId = "backend",
Port = 8080,
Protocol = "HTTP"
};
// Assert
Assert.Equal(1.0f, edge.Confidence);
}
[Fact]
public void Source_DefaultsToManifest()
{
// Arrange
var edge = new CrossContainerEdge
{
FromServiceId = "frontend",
ToServiceId = "backend",
Port = 8080,
Protocol = "HTTP"
};
// Assert
Assert.Equal(EdgeSource.Manifest, edge.Source);
}
[Fact]
public void IsExternal_DefaultsToFalse()
{
// Arrange
var edge = new CrossContainerEdge
{
FromServiceId = "frontend",
ToServiceId = "backend",
Port = 8080,
Protocol = "HTTP"
};
// Assert
Assert.False(edge.IsExternal);
}
}
/// <summary>
/// Unit tests for <see cref="CrossContainerPath"/>.
/// </summary>
public sealed class CrossContainerPathTests
{
[Fact]
public void GetAllVulnerableComponents_CombinesSourceAndTarget()
{
// Arrange
var source = new ServiceNode
{
ServiceId = "frontend",
ContainerName = "frontend",
ImageDigest = "sha256:aaa",
Entrypoints = ImmutableArray<SemanticEntrypoint>.Empty,
ExposedPorts = ImmutableArray<int>.Empty,
VulnerableComponents = ImmutableArray.Create("pkg:npm/lodash@4.17.20")
};
var target = new ServiceNode
{
ServiceId = "backend",
ContainerName = "backend",
ImageDigest = "sha256:bbb",
Entrypoints = ImmutableArray<SemanticEntrypoint>.Empty,
ExposedPorts = ImmutableArray<int>.Empty,
VulnerableComponents = ImmutableArray.Create("pkg:maven/log4j/log4j-core@2.14.1")
};
var path = new CrossContainerPath
{
Source = source,
Target = target,
Hops = ImmutableArray<CrossContainerEdge>.Empty,
HopCount = 0,
IsIngressExposed = false,
ReachabilityConfidence = 1.0f
};
// Act
var allVulns = path.GetAllVulnerableComponents();
// Assert
Assert.Equal(2, allVulns.Length);
Assert.Contains("pkg:npm/lodash@4.17.20", allVulns);
Assert.Contains("pkg:maven/log4j/log4j-core@2.14.1", allVulns);
}
[Fact]
public void GetAllVulnerableComponents_DeduplicatesComponents()
{
// Arrange
var sharedVuln = "pkg:npm/lodash@4.17.20";
var source = new ServiceNode
{
ServiceId = "frontend",
ContainerName = "frontend",
ImageDigest = "sha256:aaa",
Entrypoints = ImmutableArray<SemanticEntrypoint>.Empty,
ExposedPorts = ImmutableArray<int>.Empty,
VulnerableComponents = ImmutableArray.Create(sharedVuln)
};
var target = new ServiceNode
{
ServiceId = "backend",
ContainerName = "backend",
ImageDigest = "sha256:bbb",
Entrypoints = ImmutableArray<SemanticEntrypoint>.Empty,
ExposedPorts = ImmutableArray<int>.Empty,
VulnerableComponents = ImmutableArray.Create(sharedVuln)
};
var path = new CrossContainerPath
{
Source = source,
Target = target,
Hops = ImmutableArray<CrossContainerEdge>.Empty,
HopCount = 0,
IsIngressExposed = false,
ReachabilityConfidence = 1.0f
};
// Act
var allVulns = path.GetAllVulnerableComponents();
// Assert
Assert.Single(allVulns);
}
[Fact]
public void VulnerableComponents_DefaultsToEmpty()
{
// Arrange
var source = new ServiceNode
{
ServiceId = "frontend",
ContainerName = "frontend",
ImageDigest = "sha256:aaa",
Entrypoints = ImmutableArray<SemanticEntrypoint>.Empty,
ExposedPorts = ImmutableArray<int>.Empty
};
var target = new ServiceNode
{
ServiceId = "backend",
ContainerName = "backend",
ImageDigest = "sha256:bbb",
Entrypoints = ImmutableArray<SemanticEntrypoint>.Empty,
ExposedPorts = ImmutableArray<int>.Empty
};
var path = new CrossContainerPath
{
Source = source,
Target = target,
Hops = ImmutableArray<CrossContainerEdge>.Empty,
HopCount = 0,
IsIngressExposed = false,
ReachabilityConfidence = 1.0f
};
// Assert
Assert.Empty(path.VulnerableComponents);
}
}
/// <summary>
/// Unit tests for <see cref="IngressPath"/>.
/// </summary>
public sealed class IngressPathTests
{
[Fact]
public void TlsEnabled_DefaultsToFalse()
{
// Arrange
var ingress = new IngressPath
{
IngressName = "main-ingress",
Host = "api.example.com",
Path = "/api/*",
TargetServiceId = "backend",
TargetPort = 8080
};
// Assert
Assert.False(ingress.TlsEnabled);
}
#endregion
[Fact]
public void TlsSecretName_IsNull_WhenTlsDisabled()
{
// Arrange
var ingress = new IngressPath
{
IngressName = "main-ingress",
Host = "api.example.com",
Path = "/api/*",
TargetServiceId = "backend",
TargetPort = 8080
};
// Assert
Assert.Null(ingress.TlsSecretName);
}
[Fact]
public void CanHaveAnnotations()
{
// Arrange
var ingress = new IngressPath
{
IngressName = "main-ingress",
Host = "api.example.com",
Path = "/api/*",
TargetServiceId = "backend",
TargetPort = 8080,
Annotations = ImmutableDictionary<string, string>.Empty
.Add("nginx.ingress.kubernetes.io/rewrite-target", "/")
};
// Assert
Assert.NotNull(ingress.Annotations);
Assert.Contains("nginx.ingress.kubernetes.io/rewrite-target", ingress.Annotations.Keys);
}
}

View File

@@ -0,0 +1,403 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Collections.Immutable;
using StellaOps.Scanner.EntryTrace.Binary;
using StellaOps.Scanner.EntryTrace.Risk;
using StellaOps.Scanner.EntryTrace.Semantic;
using Xunit;
namespace StellaOps.Scanner.EntryTrace.Tests.Risk;
/// <summary>
/// Unit tests for <see cref="CompositeRiskScorer"/>.
/// </summary>
public sealed class CompositeRiskScorerTests
{
[Fact]
public async Task CompositeRiskScorer_EmptyContext_ReturnsZeroScore()
{
var scorer = new CompositeRiskScorer();
var context = RiskContext.Empty("sha256:test", SubjectType.Image);
var assessment = await scorer.AssessAsync(context);
Assert.Equal("sha256:test", assessment.SubjectId);
Assert.Equal(0.0f, assessment.OverallScore.OverallScore);
Assert.Equal(RiskLevel.Negligible, assessment.OverallScore.Level);
}
[Fact]
public async Task CompositeRiskScorer_WithVulnerabilities_ReturnsElevatedScore()
{
var scorer = new CompositeRiskScorer();
var vuln = new VulnerabilityReference(
VulnerabilityId: "CVE-2024-1234",
Severity: VulnerabilitySeverity.Critical,
CvssScore: 9.8f,
ExploitAvailable: true,
AffectedPackage: "pkg:npm/lodash@4.17.15",
FixedVersion: "4.17.21");
var context = new RiskContext(
SubjectId: "sha256:test",
SubjectType: SubjectType.Image,
SemanticEntrypoints: ImmutableArray<SemanticEntrypoint>.Empty,
TemporalGraph: null,
MeshGraph: null,
BinaryAnalysis: null,
KnownVulnerabilities: ImmutableArray.Create(vuln));
var assessment = await scorer.AssessAsync(context);
Assert.True(assessment.OverallScore.OverallScore > 0);
Assert.True(assessment.OverallScore.IsElevated);
Assert.Equal(RiskCategory.Exploitability, assessment.OverallScore.Category);
}
[Fact]
public async Task CompositeRiskScorer_WithBusinessContext_AppliesMultiplier()
{
var scorer = new CompositeRiskScorer();
var vuln = new VulnerabilityReference(
VulnerabilityId: "CVE-2024-1234",
Severity: VulnerabilitySeverity.Medium,
CvssScore: 5.0f,
ExploitAvailable: false,
AffectedPackage: "pkg:npm/axios@0.21.0",
FixedVersion: "0.21.1");
var context = new RiskContext(
SubjectId: "sha256:test",
SubjectType: SubjectType.Image,
SemanticEntrypoints: ImmutableArray<SemanticEntrypoint>.Empty,
TemporalGraph: null,
MeshGraph: null,
BinaryAnalysis: null,
KnownVulnerabilities: ImmutableArray.Create(vuln));
var assessmentDev = await scorer.AssessAsync(context, BusinessContext.Development);
var assessmentProd = await scorer.AssessAsync(context, BusinessContext.ProductionInternetFacing);
// Production internet-facing should have higher score due to multiplier
Assert.True(assessmentProd.OverallScore.OverallScore >= assessmentDev.OverallScore.OverallScore);
}
[Fact]
public async Task CompositeRiskScorer_CriticalRisk_IncludesImmediateActionRecommendation()
{
var scorer = new CompositeRiskScorer();
// Create multiple critical vulnerabilities with exploits
var vulns = ImmutableArray.Create(
new VulnerabilityReference("CVE-2024-001", VulnerabilitySeverity.Critical, 10.0f, true, "pkg:npm/test@1.0", null),
new VulnerabilityReference("CVE-2024-002", VulnerabilitySeverity.Critical, 9.9f, true, "pkg:npm/test2@1.0", null),
new VulnerabilityReference("CVE-2024-003", VulnerabilitySeverity.Critical, 9.8f, true, "pkg:npm/test3@1.0", null));
var context = new RiskContext(
SubjectId: "sha256:test",
SubjectType: SubjectType.Image,
SemanticEntrypoints: ImmutableArray<SemanticEntrypoint>.Empty,
TemporalGraph: null,
MeshGraph: null,
BinaryAnalysis: null,
KnownVulnerabilities: vulns);
var assessment = await scorer.AssessAsync(context, BusinessContext.ProductionInternetFacing);
// Should include high-priority or critical recommendation
Assert.True(assessment.Recommendations.Length > 0);
}
[Fact]
public async Task CompositeRiskScorer_GeneratesRecommendationsForFactors()
{
var scorer = new CompositeRiskScorer();
var vuln = new VulnerabilityReference(
VulnerabilityId: "CVE-2024-5678",
Severity: VulnerabilitySeverity.High,
CvssScore: 8.0f,
ExploitAvailable: false,
AffectedPackage: "pkg:npm/vulnerable-pkg@1.0.0",
FixedVersion: "1.0.1");
var context = new RiskContext(
SubjectId: "sha256:test",
SubjectType: SubjectType.Image,
SemanticEntrypoints: ImmutableArray<SemanticEntrypoint>.Empty,
TemporalGraph: null,
MeshGraph: null,
BinaryAnalysis: null,
KnownVulnerabilities: ImmutableArray.Create(vuln));
var assessment = await scorer.AssessAsync(context);
Assert.True(assessment.IsActionable);
Assert.NotEmpty(assessment.Recommendations);
}
[Fact]
public async Task CompositeRiskScorer_CustomContributors_UsedInAssessment()
{
// Use only the vulnerability contributor
var contributors = new IRiskContributor[] { new VulnerabilityRiskContributor() };
var scorer = new CompositeRiskScorer(contributors);
Assert.Single(scorer.ContributedFactors);
Assert.Equal("Vulnerability", scorer.ContributedFactors[0]);
}
[Fact]
public void CompositeRiskScorerOptions_Default_HasReasonableValues()
{
var options = CompositeRiskScorerOptions.Default;
Assert.Equal(10, options.MaxRecommendations);
Assert.True(options.MinFactorContribution >= 0);
}
}
/// <summary>
/// Unit tests for <see cref="RiskExplainer"/>.
/// </summary>
public sealed class RiskExplainerTests
{
[Theory]
[InlineData(RiskLevel.Critical, "CRITICAL RISK")]
[InlineData(RiskLevel.High, "HIGH RISK")]
[InlineData(RiskLevel.Medium, "MEDIUM RISK")]
[InlineData(RiskLevel.Low, "LOW RISK")]
[InlineData(RiskLevel.Negligible, "NEGLIGIBLE RISK")]
public void RiskExplainer_ExplainSummary_IncludesLevel(RiskLevel level, string expectedText)
{
var explainer = new RiskExplainer();
var score = new RiskScore(
level switch
{
RiskLevel.Critical => 0.95f,
RiskLevel.High => 0.75f,
RiskLevel.Medium => 0.5f,
RiskLevel.Low => 0.2f,
_ => 0.05f
},
RiskCategory.Unknown,
0.9f,
DateTimeOffset.UtcNow);
var assessment = new RiskAssessment(
SubjectId: "sha256:test",
SubjectType: SubjectType.Image,
OverallScore: score,
Factors: ImmutableArray<RiskFactor>.Empty,
BusinessContext: null,
Recommendations: ImmutableArray<string>.Empty,
AssessedAt: DateTimeOffset.UtcNow);
var summary = explainer.ExplainSummary(assessment);
Assert.Contains(expectedText, summary);
}
[Fact]
public void RiskExplainer_ExplainSummary_IncludesCategory()
{
var explainer = new RiskExplainer();
var score = RiskScore.High(RiskCategory.Exposure);
var assessment = new RiskAssessment(
SubjectId: "sha256:test",
SubjectType: SubjectType.Image,
OverallScore: score,
Factors: ImmutableArray<RiskFactor>.Empty,
BusinessContext: null,
Recommendations: ImmutableArray<string>.Empty,
AssessedAt: DateTimeOffset.UtcNow);
var summary = explainer.ExplainSummary(assessment);
Assert.Contains("network exposure", summary);
}
[Fact]
public void RiskExplainer_LowConfidence_AddsNote()
{
var explainer = new RiskExplainer();
var score = new RiskScore(0.5f, RiskCategory.Unknown, 0.3f, DateTimeOffset.UtcNow);
var assessment = new RiskAssessment(
SubjectId: "sha256:test",
SubjectType: SubjectType.Image,
OverallScore: score,
Factors: ImmutableArray<RiskFactor>.Empty,
BusinessContext: null,
Recommendations: ImmutableArray<string>.Empty,
AssessedAt: DateTimeOffset.UtcNow);
var summary = explainer.ExplainSummary(assessment);
Assert.Contains("confidence is low", summary);
}
[Fact]
public void RiskExplainer_ExplainFactors_IncludesEvidence()
{
var explainer = new RiskExplainer();
var factor = new RiskFactor(
Name: "TestFactor",
Category: RiskCategory.Exploitability,
Score: 0.8f,
Weight: 0.5f,
Evidence: "Critical vulnerability detected",
SourceId: "CVE-2024-1234");
var assessment = new RiskAssessment(
SubjectId: "sha256:test",
SubjectType: SubjectType.Image,
OverallScore: RiskScore.High(RiskCategory.Exploitability),
Factors: ImmutableArray.Create(factor),
BusinessContext: null,
Recommendations: ImmutableArray<string>.Empty,
AssessedAt: DateTimeOffset.UtcNow);
var explanations = explainer.ExplainFactors(assessment);
Assert.Single(explanations);
Assert.Contains("Critical vulnerability detected", explanations[0]);
Assert.Contains("Exploitability", explanations[0]);
}
[Fact]
public void RiskExplainer_GenerateReport_CreatesCompleteReport()
{
var explainer = new RiskExplainer();
var assessment = new RiskAssessment(
SubjectId: "sha256:abc123",
SubjectType: SubjectType.Image,
OverallScore: RiskScore.High(RiskCategory.Exploitability),
Factors: ImmutableArray<RiskFactor>.Empty,
BusinessContext: BusinessContext.ProductionInternetFacing,
Recommendations: ImmutableArray.Create("Patch immediately"),
AssessedAt: DateTimeOffset.UtcNow);
var report = explainer.GenerateReport(assessment);
Assert.Equal("sha256:abc123", report.SubjectId);
Assert.Equal(RiskLevel.High, report.Level);
Assert.NotEmpty(report.Summary);
Assert.Single(report.Recommendations);
}
}
/// <summary>
/// Unit tests for <see cref="RiskAggregator"/>.
/// </summary>
public sealed class RiskAggregatorTests
{
[Fact]
public void RiskAggregator_EmptyAssessments_ReturnsEmptySummary()
{
var aggregator = new RiskAggregator();
var summary = aggregator.Aggregate(Enumerable.Empty<RiskAssessment>());
Assert.Equal(0, summary.TotalSubjects);
Assert.Equal(0, summary.AverageScore);
}
[Fact]
public void RiskAggregator_MultipleAssessments_ComputesCorrectStats()
{
var aggregator = new RiskAggregator();
var assessments = new[]
{
CreateAssessment("img1", RiskLevel.Critical, 0.95f),
CreateAssessment("img2", RiskLevel.High, 0.8f),
CreateAssessment("img3", RiskLevel.Medium, 0.5f),
CreateAssessment("img4", RiskLevel.Low, 0.2f),
};
var summary = aggregator.Aggregate(assessments);
Assert.Equal(4, summary.TotalSubjects);
Assert.True(summary.AverageScore > 0);
Assert.Equal(2, summary.CriticalAndHighCount);
Assert.Equal(0.5f, summary.ElevatedRiskPercentage);
}
[Fact]
public void RiskAggregator_TopRisks_OrderedByScore()
{
var aggregator = new RiskAggregator();
var assessments = new[]
{
CreateAssessment("img1", RiskLevel.Low, 0.2f),
CreateAssessment("img2", RiskLevel.Critical, 0.95f),
CreateAssessment("img3", RiskLevel.Medium, 0.5f),
};
var summary = aggregator.Aggregate(assessments);
Assert.Equal("img2", summary.TopRisks[0].SubjectId);
Assert.Equal(RiskLevel.Critical, summary.TopRisks[0].Level);
}
[Fact]
public void RiskAggregator_Distribution_CountsLevelsCorrectly()
{
var aggregator = new RiskAggregator();
var assessments = new[]
{
CreateAssessment("img1", RiskLevel.Critical, 0.95f),
CreateAssessment("img2", RiskLevel.Critical, 0.92f),
CreateAssessment("img3", RiskLevel.High, 0.8f),
};
var summary = aggregator.Aggregate(assessments);
Assert.Equal(2, summary.Distribution[RiskLevel.Critical]);
Assert.Equal(1, summary.Distribution[RiskLevel.High]);
}
[Fact]
public void FleetRiskSummary_Empty_HasZeroValues()
{
var empty = FleetRiskSummary.Empty;
Assert.Equal(0, empty.TotalSubjects);
Assert.Equal(0, empty.AverageScore);
Assert.Equal(0, empty.CriticalAndHighCount);
Assert.Equal(0, empty.ElevatedRiskPercentage);
}
private static RiskAssessment CreateAssessment(string subjectId, RiskLevel level, float score)
{
var riskScore = new RiskScore(score, RiskCategory.Exploitability, 0.9f, DateTimeOffset.UtcNow);
return new RiskAssessment(
SubjectId: subjectId,
SubjectType: SubjectType.Image,
OverallScore: riskScore,
Factors: ImmutableArray<RiskFactor>.Empty,
BusinessContext: null,
Recommendations: ImmutableArray<string>.Empty,
AssessedAt: DateTimeOffset.UtcNow);
}
}
/// <summary>
/// Unit tests for <see cref="EntrypointRiskReport"/>.
/// </summary>
public sealed class EntrypointRiskReportTests
{
[Fact]
public void EntrypointRiskReport_Basic_CreatesWithoutTrend()
{
var explainer = new RiskExplainer();
var assessment = RiskAssessment.Empty("sha256:test", SubjectType.Image);
var report = EntrypointRiskReport.Basic(assessment, explainer);
Assert.Equal(assessment, report.Assessment);
Assert.NotNull(report.Report);
Assert.Null(report.Trend);
Assert.Empty(report.ComparableSubjects);
}
}

View File

@@ -0,0 +1,473 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Collections.Immutable;
using StellaOps.Scanner.EntryTrace.Binary;
using StellaOps.Scanner.EntryTrace.Mesh;
using StellaOps.Scanner.EntryTrace.Risk;
using StellaOps.Scanner.EntryTrace.Semantic;
using StellaOps.Scanner.EntryTrace.Temporal;
using Xunit;
namespace StellaOps.Scanner.EntryTrace.Tests.Risk;
/// <summary>
/// Unit tests for <see cref="IRiskContributor"/> implementations.
/// </summary>
public sealed class RiskContributorTests
{
private static readonly DateTimeOffset TestTime = new(2025, 6, 1, 12, 0, 0, TimeSpan.Zero);
[Fact]
public async Task SemanticRiskContributor_NoData_ReturnsEmpty()
{
var contributor = new SemanticRiskContributor();
var context = RiskContext.Empty("sha256:test", SubjectType.Image);
var factors = await contributor.ComputeFactorsAsync(context);
Assert.Empty(factors);
}
[Fact]
public async Task SemanticRiskContributor_WithNetworkListen_ReturnsExposureFactor()
{
var contributor = new SemanticRiskContributor();
var entrypoint = CreateSemanticEntrypoint(CapabilityClass.NetworkListen);
var context = new RiskContext(
SubjectId: "sha256:test",
SubjectType: SubjectType.Image,
SemanticEntrypoints: ImmutableArray.Create(entrypoint),
TemporalGraph: null,
MeshGraph: null,
BinaryAnalysis: null,
KnownVulnerabilities: ImmutableArray<VulnerabilityReference>.Empty);
var factors = await contributor.ComputeFactorsAsync(context);
Assert.Contains(factors, f => f.Name == "NetworkListen" && f.Category == RiskCategory.Exposure);
}
[Fact]
public async Task SemanticRiskContributor_WithProcessSpawnAndFileWrite_ReturnsPrivilegeFactor()
{
var contributor = new SemanticRiskContributor();
var entrypoint = CreateSemanticEntrypoint(CapabilityClass.ProcessSpawn | CapabilityClass.FileWrite);
var context = new RiskContext(
SubjectId: "sha256:test",
SubjectType: SubjectType.Image,
SemanticEntrypoints: ImmutableArray.Create(entrypoint),
TemporalGraph: null,
MeshGraph: null,
BinaryAnalysis: null,
KnownVulnerabilities: ImmutableArray<VulnerabilityReference>.Empty);
var factors = await contributor.ComputeFactorsAsync(context);
Assert.Contains(factors, f => f.Name == "ProcessSpawnWithFileWrite" && f.Category == RiskCategory.Privilege);
}
[Fact]
public async Task SemanticRiskContributor_WithThreatVectors_ReturnsExploitabilityFactors()
{
var contributor = new SemanticRiskContributor();
var entrypoint = CreateSemanticEntrypointWithThreat(ThreatVectorType.CommandInjection);
var context = new RiskContext(
SubjectId: "sha256:test",
SubjectType: SubjectType.Image,
SemanticEntrypoints: ImmutableArray.Create(entrypoint),
TemporalGraph: null,
MeshGraph: null,
BinaryAnalysis: null,
KnownVulnerabilities: ImmutableArray<VulnerabilityReference>.Empty);
var factors = await contributor.ComputeFactorsAsync(context);
Assert.Contains(factors, f =>
f.Name == "ThreatVector_CommandInjection" &&
f.Category == RiskCategory.Exploitability);
}
[Fact]
public async Task TemporalRiskContributor_NoData_ReturnsEmpty()
{
var contributor = new TemporalRiskContributor();
var context = RiskContext.Empty("sha256:test", SubjectType.Image);
var factors = await contributor.ComputeFactorsAsync(context);
Assert.Empty(factors);
}
[Fact]
public async Task TemporalRiskContributor_WithAttackSurfaceGrowth_ReturnsDriftFactor()
{
var contributor = new TemporalRiskContributor();
var graph = CreateTemporalGraph(EntrypointDrift.AttackSurfaceGrew);
var context = new RiskContext(
SubjectId: "sha256:test",
SubjectType: SubjectType.Image,
SemanticEntrypoints: ImmutableArray<SemanticEntrypoint>.Empty,
TemporalGraph: graph,
MeshGraph: null,
BinaryAnalysis: null,
KnownVulnerabilities: ImmutableArray<VulnerabilityReference>.Empty);
var factors = await contributor.ComputeFactorsAsync(context);
Assert.Contains(factors, f =>
f.Name == "AttackSurfaceGrowth" &&
f.Category == RiskCategory.DriftVelocity);
}
[Fact]
public async Task TemporalRiskContributor_WithPrivilegeEscalation_ReturnsPrivilegeFactor()
{
var contributor = new TemporalRiskContributor();
var graph = CreateTemporalGraph(EntrypointDrift.PrivilegeEscalation);
var context = new RiskContext(
SubjectId: "sha256:test",
SubjectType: SubjectType.Image,
SemanticEntrypoints: ImmutableArray<SemanticEntrypoint>.Empty,
TemporalGraph: graph,
MeshGraph: null,
BinaryAnalysis: null,
KnownVulnerabilities: ImmutableArray<VulnerabilityReference>.Empty);
var factors = await contributor.ComputeFactorsAsync(context);
Assert.Contains(factors, f =>
f.Name == "PrivilegeEscalation" &&
f.Category == RiskCategory.Privilege &&
f.Score >= 0.8f);
}
[Fact]
public async Task MeshRiskContributor_NoData_ReturnsEmpty()
{
var contributor = new MeshRiskContributor();
var context = RiskContext.Empty("sha256:test", SubjectType.Image);
var factors = await contributor.ComputeFactorsAsync(context);
Assert.Empty(factors);
}
[Fact]
public async Task MeshRiskContributor_WithIngressPaths_ReturnsExposureFactor()
{
var contributor = new MeshRiskContributor();
var graph = CreateMeshGraphWithIngress();
var context = new RiskContext(
SubjectId: "sha256:test",
SubjectType: SubjectType.Image,
SemanticEntrypoints: ImmutableArray<SemanticEntrypoint>.Empty,
TemporalGraph: null,
MeshGraph: graph,
BinaryAnalysis: null,
KnownVulnerabilities: ImmutableArray<VulnerabilityReference>.Empty);
var factors = await contributor.ComputeFactorsAsync(context);
Assert.Contains(factors, f =>
f.Name == "InternetExposure" &&
f.Category == RiskCategory.Exposure);
}
[Fact]
public async Task BinaryRiskContributor_NoData_ReturnsEmpty()
{
var contributor = new BinaryRiskContributor();
var context = RiskContext.Empty("sha256:test", SubjectType.Image);
var factors = await contributor.ComputeFactorsAsync(context);
Assert.Empty(factors);
}
[Fact]
public async Task BinaryRiskContributor_WithVulnerableMatch_ReturnsExploitabilityFactor()
{
var contributor = new BinaryRiskContributor();
var analysis = CreateBinaryAnalysisWithVulnerableMatch();
var context = new RiskContext(
SubjectId: "sha256:test",
SubjectType: SubjectType.Image,
SemanticEntrypoints: ImmutableArray<SemanticEntrypoint>.Empty,
TemporalGraph: null,
MeshGraph: null,
BinaryAnalysis: analysis,
KnownVulnerabilities: ImmutableArray<VulnerabilityReference>.Empty);
var factors = await contributor.ComputeFactorsAsync(context);
Assert.Contains(factors, f =>
f.Name.StartsWith("VulnerableFunction_") &&
f.Category == RiskCategory.Exploitability);
}
[Fact]
public async Task VulnerabilityRiskContributor_NoData_ReturnsEmpty()
{
var contributor = new VulnerabilityRiskContributor();
var context = RiskContext.Empty("sha256:test", SubjectType.Image);
var factors = await contributor.ComputeFactorsAsync(context);
Assert.Empty(factors);
}
[Fact]
public async Task VulnerabilityRiskContributor_WithCriticalCVE_ReturnsHighScore()
{
var contributor = new VulnerabilityRiskContributor();
var vuln = new VulnerabilityReference(
VulnerabilityId: "CVE-2024-12345",
Severity: VulnerabilitySeverity.Critical,
CvssScore: 9.8f,
ExploitAvailable: false,
AffectedPackage: "pkg:npm/lodash@4.17.15",
FixedVersion: "4.17.21");
var context = new RiskContext(
SubjectId: "sha256:test",
SubjectType: SubjectType.Image,
SemanticEntrypoints: ImmutableArray<SemanticEntrypoint>.Empty,
TemporalGraph: null,
MeshGraph: null,
BinaryAnalysis: null,
KnownVulnerabilities: ImmutableArray.Create(vuln));
var factors = await contributor.ComputeFactorsAsync(context);
var cveFactor = factors.First(f => f.Name == "CVE_CVE-2024-12345");
Assert.True(cveFactor.Score >= 0.9f);
Assert.Equal(RiskCategory.Exploitability, cveFactor.Category);
}
[Fact]
public async Task VulnerabilityRiskContributor_WithExploit_BoostsScore()
{
var contributor = new VulnerabilityRiskContributor();
var vulnWithExploit = new VulnerabilityReference(
VulnerabilityId: "CVE-2024-99999",
Severity: VulnerabilitySeverity.High,
CvssScore: 7.5f,
ExploitAvailable: true,
AffectedPackage: "pkg:npm/axios@0.21.0",
FixedVersion: "0.21.1");
var vulnWithoutExploit = new VulnerabilityReference(
VulnerabilityId: "CVE-2024-88888",
Severity: VulnerabilitySeverity.High,
CvssScore: 7.5f,
ExploitAvailable: false,
AffectedPackage: "pkg:npm/axios@0.21.0",
FixedVersion: "0.21.1");
var contextWithExploit = new RiskContext(
SubjectId: "sha256:test1",
SubjectType: SubjectType.Image,
SemanticEntrypoints: ImmutableArray<SemanticEntrypoint>.Empty,
TemporalGraph: null,
MeshGraph: null,
BinaryAnalysis: null,
KnownVulnerabilities: ImmutableArray.Create(vulnWithExploit));
var contextWithoutExploit = new RiskContext(
SubjectId: "sha256:test2",
SubjectType: SubjectType.Image,
SemanticEntrypoints: ImmutableArray<SemanticEntrypoint>.Empty,
TemporalGraph: null,
MeshGraph: null,
BinaryAnalysis: null,
KnownVulnerabilities: ImmutableArray.Create(vulnWithoutExploit));
var factorsWithExploit = await contributor.ComputeFactorsAsync(contextWithExploit);
var factorsWithoutExploit = await contributor.ComputeFactorsAsync(contextWithoutExploit);
Assert.True(factorsWithExploit[0].Score > factorsWithoutExploit[0].Score);
}
[Fact]
public void RiskContext_Empty_HasNoDataFlags()
{
var context = RiskContext.Empty("sha256:test", SubjectType.Image);
Assert.False(context.HasSemanticData);
Assert.False(context.HasTemporalData);
Assert.False(context.HasMeshData);
Assert.False(context.HasBinaryData);
Assert.False(context.HasVulnerabilityData);
}
[Fact]
public void VulnerabilityReference_IsCritical_TrueForCriticalSeverity()
{
var vuln = new VulnerabilityReference(
VulnerabilityId: "CVE-2024-1",
Severity: VulnerabilitySeverity.Critical,
CvssScore: 10.0f,
ExploitAvailable: true,
AffectedPackage: "pkg:npm/test@1.0.0",
FixedVersion: null);
Assert.True(vuln.IsCritical);
Assert.True(vuln.IsActivelyExploitable);
Assert.False(vuln.HasFix);
}
[Fact]
public void VulnerabilityReference_HasFix_TrueWhenFixedVersionPresent()
{
var vuln = new VulnerabilityReference(
VulnerabilityId: "CVE-2024-2",
Severity: VulnerabilitySeverity.High,
CvssScore: 8.0f,
ExploitAvailable: false,
AffectedPackage: "pkg:npm/test@1.0.0",
FixedVersion: "1.0.1");
Assert.True(vuln.HasFix);
Assert.False(vuln.IsActivelyExploitable);
}
#region Helper Methods
private static SemanticEntrypoint CreateSemanticEntrypoint(CapabilityClass capabilities)
{
var spec = new Semantic.EntrypointSpecification
{
Entrypoint = ImmutableArray.Create("/bin/app"),
Cmd = ImmutableArray<string>.Empty,
User = "root",
WorkingDirectory = "/app"
};
return new SemanticEntrypoint
{
Id = "entry-1",
Specification = spec,
Intent = ApplicationIntent.Unknown,
Capabilities = capabilities,
AttackSurface = ImmutableArray<ThreatVector>.Empty,
DataBoundaries = ImmutableArray<DataFlowBoundary>.Empty,
Confidence = SemanticConfidence.High("test"),
AnalyzedAt = TestTime.ToString("O")
};
}
private static SemanticEntrypoint CreateSemanticEntrypointWithThreat(ThreatVectorType threatType)
{
var spec = new Semantic.EntrypointSpecification
{
Entrypoint = ImmutableArray.Create("/bin/app"),
Cmd = ImmutableArray<string>.Empty,
User = "root",
WorkingDirectory = "/app"
};
var threat = new ThreatVector
{
Type = threatType,
Confidence = 0.85,
ContributingCapabilities = CapabilityClass.None,
Evidence = ImmutableArray.Create("test evidence"),
EntryPaths = ImmutableArray<string>.Empty
};
return new SemanticEntrypoint
{
Id = "entry-1",
Specification = spec,
Intent = ApplicationIntent.Unknown,
Capabilities = CapabilityClass.None,
AttackSurface = ImmutableArray.Create(threat),
DataBoundaries = ImmutableArray<DataFlowBoundary>.Empty,
Confidence = SemanticConfidence.High("test"),
AnalyzedAt = TestTime.ToString("O")
};
}
private static TemporalEntrypointGraph CreateTemporalGraph(EntrypointDrift drift)
{
var delta = new EntrypointDelta
{
FromVersion = "1.0.0",
ToVersion = "2.0.0",
FromDigest = "sha256:old",
ToDigest = "sha256:new",
AddedEntrypoints = ImmutableArray<SemanticEntrypoint>.Empty,
RemovedEntrypoints = ImmutableArray<SemanticEntrypoint>.Empty,
ModifiedEntrypoints = ImmutableArray<EntrypointModification>.Empty,
DriftCategories = ImmutableArray.Create(drift)
};
return new TemporalEntrypointGraph
{
ServiceId = "test-service",
CurrentVersion = "2.0.0",
PreviousVersion = "1.0.0",
Delta = delta,
Snapshots = ImmutableArray<EntrypointSnapshot>.Empty,
UpdatedAt = TestTime.ToString("O")
};
}
private static MeshEntrypointGraph CreateMeshGraphWithIngress()
{
var service = new ServiceNode
{
ServiceId = "svc-1",
ImageDigest = "sha256:test",
Entrypoints = ImmutableArray<SemanticEntrypoint>.Empty,
ExposedPorts = ImmutableArray.Create(8080),
VulnerableComponents = ImmutableArray<string>.Empty
};
var ingress = new IngressPath
{
IngressName = "main-ingress",
Host = "app.example.com",
Path = "/api",
TargetServiceId = "svc-1",
TargetPort = 8080,
TlsEnabled = true
};
return new MeshEntrypointGraph
{
MeshId = "cluster-1",
Services = ImmutableArray.Create(service),
Edges = ImmutableArray<CrossContainerEdge>.Empty,
IngressPaths = ImmutableArray.Create(ingress),
Type = MeshType.Kubernetes,
AnalyzedAt = TestTime.ToString("O")
};
}
private static BinaryAnalysisResult CreateBinaryAnalysisWithVulnerableMatch()
{
var match = new VulnerableFunctionMatch(
FunctionOffset: 0x1000,
FunctionName: "vulnerable_parse",
VulnerabilityId: "CVE-2024-1234",
SourcePackage: "libtest",
VulnerableVersions: "< 1.2.3",
VulnerableFunctionName: "vulnerable_parse",
MatchConfidence: 0.95f,
MatchEvidence: CorrelationEvidence.FingerprintMatch,
Severity: VulnerabilitySeverity.Critical);
return new BinaryAnalysisResult(
BinaryPath: "/usr/lib/libtest.so",
BinaryHash: "sha256:binarytest",
Architecture: BinaryArchitecture.X64,
Format: BinaryFormat.ELF,
Functions: ImmutableArray<FunctionSignature>.Empty,
RecoveredSymbols: ImmutableDictionary<long, SymbolInfo>.Empty,
SourceCorrelations: ImmutableArray<SourceCorrelation>.Empty,
VulnerableMatches: ImmutableArray.Create(match),
Metrics: BinaryAnalysisMetrics.Empty,
AnalyzedAt: TestTime);
}
#endregion
}

View File

@@ -0,0 +1,353 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Collections.Immutable;
using StellaOps.Scanner.EntryTrace.Risk;
using Xunit;
namespace StellaOps.Scanner.EntryTrace.Tests.Risk;
/// <summary>
/// Unit tests for <see cref="RiskScore"/> and related models.
/// </summary>
public sealed class RiskScoreTests
{
[Fact]
public void RiskScore_Zero_ReturnsNegligibleLevel()
{
var score = RiskScore.Zero;
Assert.Equal(0.0f, score.OverallScore);
Assert.Equal(RiskCategory.Unknown, score.Category);
Assert.Equal(RiskLevel.Negligible, score.Level);
Assert.False(score.IsElevated);
}
[Fact]
public void RiskScore_Critical_ReturnsCriticalLevel()
{
var score = RiskScore.Critical(RiskCategory.Exploitability);
Assert.Equal(1.0f, score.OverallScore);
Assert.Equal(RiskCategory.Exploitability, score.Category);
Assert.Equal(RiskLevel.Critical, score.Level);
Assert.True(score.IsElevated);
}
[Fact]
public void RiskScore_High_ReturnsHighLevel()
{
var score = RiskScore.High(RiskCategory.Exposure);
Assert.Equal(0.85f, score.OverallScore);
Assert.Equal(RiskLevel.High, score.Level);
Assert.True(score.IsElevated);
}
[Fact]
public void RiskScore_Medium_ReturnsMediumLevel()
{
var score = RiskScore.Medium(RiskCategory.Privilege);
Assert.Equal(0.5f, score.OverallScore);
Assert.Equal(RiskLevel.Medium, score.Level);
Assert.True(score.IsElevated);
}
[Fact]
public void RiskScore_Low_ReturnsLowLevel()
{
var score = RiskScore.Low(RiskCategory.DriftVelocity);
Assert.Equal(0.2f, score.OverallScore);
Assert.Equal(RiskLevel.Low, score.Level);
Assert.False(score.IsElevated);
}
[Theory]
[InlineData(0.0f, RiskLevel.Negligible)]
[InlineData(0.05f, RiskLevel.Negligible)]
[InlineData(0.1f, RiskLevel.Low)]
[InlineData(0.35f, RiskLevel.Low)]
[InlineData(0.4f, RiskLevel.Medium)]
[InlineData(0.65f, RiskLevel.Medium)]
[InlineData(0.7f, RiskLevel.High)]
[InlineData(0.85f, RiskLevel.High)]
[InlineData(0.9f, RiskLevel.Critical)]
[InlineData(1.0f, RiskLevel.Critical)]
public void RiskScore_Level_MapsCorrectly(float score, RiskLevel expected)
{
var riskScore = new RiskScore(score, RiskCategory.Unknown, 1.0f, DateTimeOffset.UtcNow);
Assert.Equal(expected, riskScore.Level);
}
[Theory]
[InlineData(0.8f, true)]
[InlineData(0.9f, true)]
[InlineData(0.79f, false)]
[InlineData(0.5f, false)]
public void RiskScore_IsHighConfidence_WorksCorrectly(float confidence, bool expected)
{
var score = new RiskScore(0.5f, RiskCategory.Unknown, confidence, DateTimeOffset.UtcNow);
Assert.Equal(expected, score.IsHighConfidence);
}
}
/// <summary>
/// Unit tests for <see cref="RiskFactor"/>.
/// </summary>
public sealed class RiskFactorTests
{
[Fact]
public void RiskFactor_Creation_SetsProperties()
{
var factor = new RiskFactor(
Name: "TestFactor",
Category: RiskCategory.Exploitability,
Score: 0.8f,
Weight: 0.25f,
Evidence: "Test evidence",
SourceId: "CVE-2024-1234");
Assert.Equal("TestFactor", factor.Name);
Assert.Equal(RiskCategory.Exploitability, factor.Category);
Assert.Equal(0.8f, factor.Score);
Assert.Equal(0.25f, factor.Weight);
Assert.Equal("Test evidence", factor.Evidence);
Assert.Equal("CVE-2024-1234", factor.SourceId);
}
[Fact]
public void RiskFactor_WeightedScore_ComputesCorrectly()
{
var factor = new RiskFactor(
Name: "Test",
Category: RiskCategory.Exposure,
Score: 0.6f,
Weight: 0.5f,
Evidence: "Test",
SourceId: null);
Assert.Equal(0.3f, factor.Contribution);
}
}
/// <summary>
/// Unit tests for <see cref="BusinessContext"/>.
/// </summary>
public sealed class BusinessContextTests
{
[Fact]
public void BusinessContext_Production_HasHigherMultiplier()
{
var prodContext = BusinessContext.ProductionInternetFacing;
var devContext = BusinessContext.Development;
Assert.Equal("production", prodContext.Environment);
Assert.Equal("development", devContext.Environment);
Assert.True(prodContext.RiskMultiplier > devContext.RiskMultiplier);
}
[Fact]
public void BusinessContext_ProductionInternetFacing_IncludesFlag()
{
var context = BusinessContext.ProductionInternetFacing;
Assert.True(context.IsInternetFacing);
Assert.True(context.IsProduction);
}
[Fact]
public void BusinessContext_Development_IsNotInternetFacing()
{
var context = BusinessContext.Development;
Assert.False(context.IsInternetFacing);
Assert.False(context.IsProduction);
}
[Fact]
public void BusinessContext_WithComplianceRegimes_IncludesRegimes()
{
var context = new BusinessContext(
Environment: "production",
IsInternetFacing: true,
DataClassification: DataClassification.Confidential,
CriticalityTier: 1,
ComplianceRegimes: ImmutableArray.Create("SOC2", "HIPAA"));
Assert.Equal(2, context.ComplianceRegimes.Length);
Assert.Contains("SOC2", context.ComplianceRegimes);
Assert.Contains("HIPAA", context.ComplianceRegimes);
Assert.True(context.HasComplianceRequirements);
}
[Fact]
public void BusinessContext_Unknown_HasDefaultValues()
{
var context = BusinessContext.Unknown;
Assert.Equal("unknown", context.Environment);
Assert.False(context.IsInternetFacing);
Assert.Equal(DataClassification.Unknown, context.DataClassification);
Assert.Equal(3, context.CriticalityTier);
}
[Fact]
public void BusinessContext_RiskMultiplier_CappedAtFive()
{
// Create maximum risk context
var context = new BusinessContext(
Environment: "production",
IsInternetFacing: true,
DataClassification: DataClassification.Restricted,
CriticalityTier: 1,
ComplianceRegimes: ImmutableArray.Create("SOC2", "HIPAA", "PCI-DSS"));
Assert.True(context.RiskMultiplier <= 5.0f);
}
}
/// <summary>
/// Unit tests for <see cref="RiskAssessment"/>.
/// </summary>
public sealed class RiskAssessmentTests
{
[Fact]
public void RiskAssessment_Creation_SetsAllProperties()
{
var score = RiskScore.High(RiskCategory.Exploitability);
var factors = ImmutableArray.Create(
new RiskFactor("Factor1", RiskCategory.Exploitability, 0.8f, 0.5f, "Evidence1", null));
var assessment = new RiskAssessment(
SubjectId: "sha256:abc123",
SubjectType: SubjectType.Image,
OverallScore: score,
Factors: factors,
BusinessContext: BusinessContext.ProductionInternetFacing,
Recommendations: ImmutableArray.Create("Patch the vulnerability"),
AssessedAt: DateTimeOffset.UtcNow);
Assert.Equal("sha256:abc123", assessment.SubjectId);
Assert.Equal(SubjectType.Image, assessment.SubjectType);
Assert.Equal(score, assessment.OverallScore);
Assert.Single(assessment.Factors);
Assert.NotNull(assessment.BusinessContext);
}
[Fact]
public void RiskAssessment_IsActionable_TrueWhenHasRecommendations()
{
var assessment = new RiskAssessment(
SubjectId: "sha256:abc123",
SubjectType: SubjectType.Image,
OverallScore: RiskScore.High(RiskCategory.Exploitability),
Factors: ImmutableArray<RiskFactor>.Empty,
BusinessContext: null,
Recommendations: ImmutableArray.Create("Patch the vulnerability"),
AssessedAt: DateTimeOffset.UtcNow);
Assert.True(assessment.IsActionable);
}
[Fact]
public void RiskAssessment_IsActionable_FalseWhenNoRecommendations()
{
var assessment = new RiskAssessment(
SubjectId: "sha256:abc123",
SubjectType: SubjectType.Image,
OverallScore: RiskScore.Low(RiskCategory.Unknown),
Factors: ImmutableArray<RiskFactor>.Empty,
BusinessContext: null,
Recommendations: ImmutableArray<string>.Empty,
AssessedAt: DateTimeOffset.UtcNow);
Assert.False(assessment.IsActionable);
}
[Fact]
public void RiskAssessment_RequiresImmediateAction_TrueForCritical()
{
var assessment = new RiskAssessment(
SubjectId: "sha256:abc123",
SubjectType: SubjectType.Image,
OverallScore: RiskScore.Critical(RiskCategory.Exploitability),
Factors: ImmutableArray<RiskFactor>.Empty,
BusinessContext: null,
Recommendations: ImmutableArray<string>.Empty,
AssessedAt: DateTimeOffset.UtcNow);
Assert.True(assessment.RequiresImmediateAction);
}
[Fact]
public void RiskAssessment_Empty_ReturnsZeroScore()
{
var assessment = RiskAssessment.Empty("sha256:test", SubjectType.Image);
Assert.Equal("sha256:test", assessment.SubjectId);
Assert.Equal(SubjectType.Image, assessment.SubjectType);
Assert.Equal(0.0f, assessment.OverallScore.OverallScore);
Assert.Empty(assessment.Factors);
Assert.Empty(assessment.Recommendations);
}
}
/// <summary>
/// Unit tests for <see cref="RiskTrend"/>.
/// </summary>
public sealed class RiskTrendTests
{
[Fact]
public void RiskTrend_Direction_Improving_WhenScoresDecrease()
{
var now = DateTimeOffset.UtcNow;
var snapshots = ImmutableArray.Create(
new RiskSnapshot(0.8f, now.AddDays(-2)),
new RiskSnapshot(0.6f, now.AddDays(-1)),
new RiskSnapshot(0.4f, now));
var trend = new RiskTrend("img", snapshots, TrendDirection.Decreasing, -0.2f);
Assert.Equal(TrendDirection.Decreasing, trend.TrendDirection);
Assert.True(trend.IsDecreasing);
Assert.True(trend.VelocityPerDay < 0);
}
[Fact]
public void RiskTrend_Direction_Worsening_WhenScoresIncrease()
{
var now = DateTimeOffset.UtcNow;
var snapshots = ImmutableArray.Create(
new RiskSnapshot(0.3f, now.AddDays(-2)),
new RiskSnapshot(0.5f, now.AddDays(-1)),
new RiskSnapshot(0.7f, now));
var trend = new RiskTrend("img", snapshots, TrendDirection.Increasing, 0.2f);
Assert.Equal(TrendDirection.Increasing, trend.TrendDirection);
Assert.True(trend.IsIncreasing);
Assert.True(trend.VelocityPerDay > 0);
}
[Fact]
public void RiskTrend_IsAccelerating_TrueWhenVelocityHigh()
{
var now = DateTimeOffset.UtcNow;
var snapshots = ImmutableArray.Create(
new RiskSnapshot(0.2f, now.AddDays(-1)),
new RiskSnapshot(0.7f, now));
var trend = new RiskTrend("img", snapshots, TrendDirection.Increasing, 0.5f);
Assert.True(trend.IsAccelerating);
}
[Fact]
public void RiskSnapshot_Creation_SetsProperties()
{
var timestamp = DateTimeOffset.UtcNow;
var snapshot = new RiskSnapshot(0.65f, timestamp);
Assert.Equal(0.65f, snapshot.Score);
Assert.Equal(timestamp, snapshot.Timestamp);
}
}

View File

@@ -0,0 +1,248 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Collections.Immutable;
using StellaOps.Scanner.EntryTrace.Parsing;
using StellaOps.Scanner.EntryTrace.Speculative;
using Xunit;
namespace StellaOps.Scanner.EntryTrace.Tests.Speculative;
public sealed class PathConfidenceScorerTests
{
private readonly PathConfidenceScorer _scorer = new();
[Fact]
public async Task ScorePathAsync_EmptyConstraints_HighConfidence()
{
var path = new ExecutionPath(
PathId: "test-path",
Constraints: ImmutableArray<PathConstraint>.Empty,
TerminalCommands: ImmutableArray<TerminalCommand>.Empty,
BranchHistory: ImmutableArray<BranchDecision>.Empty,
IsFeasible: true,
ReachabilityConfidence: 1.0f,
EnvDependencies: ImmutableHashSet<string>.Empty);
var analysis = await _scorer.ScorePathAsync(path);
Assert.True(analysis.Confidence >= 0.9f);
}
[Fact]
public async Task ScorePathAsync_UnknownConstraints_LowerConfidence()
{
var constraints = ImmutableArray.Create(
new PathConstraint(
"some_complex_expression",
false,
new ShellSpan(1, 1, 1, 30),
ConstraintKind.Unknown,
ImmutableArray<string>.Empty));
var path = new ExecutionPath(
"test-path",
constraints,
ImmutableArray<TerminalCommand>.Empty,
ImmutableArray<BranchDecision>.Empty,
IsFeasible: true,
ReachabilityConfidence: 0.5f,
ImmutableHashSet<string>.Empty);
var analysis = await _scorer.ScorePathAsync(path);
// Unknown constraints should reduce confidence
Assert.True(analysis.Confidence < 1.0f);
}
[Fact]
public async Task ScorePathAsync_EnvDependentPath_ModerateConfidence()
{
var constraints = ImmutableArray.Create(
new PathConstraint(
"[ -n \"$MY_VAR\" ]",
false,
new ShellSpan(1, 1, 1, 20),
ConstraintKind.StringEmpty,
ImmutableArray.Create("MY_VAR")));
var path = new ExecutionPath(
"test-path",
constraints,
ImmutableArray<TerminalCommand>.Empty,
ImmutableArray<BranchDecision>.Empty,
IsFeasible: true,
ReachabilityConfidence: 0.8f,
ImmutableHashSet.Create("MY_VAR"));
var analysis = await _scorer.ScorePathAsync(path);
// Env-dependent paths should have lower confidence than env-independent
Assert.True(analysis.Confidence < 1.0f);
Assert.Contains(analysis.Factors, f => f.Name.Contains("Env") || f.Name.Contains("env"));
}
[Fact]
public async Task ScorePathAsync_FileExistsConstraint_ModerateReduction()
{
var constraints = ImmutableArray.Create(
new PathConstraint(
"[ -f \"/etc/config\" ]",
false,
new ShellSpan(1, 1, 1, 25),
ConstraintKind.FileExists,
ImmutableArray<string>.Empty));
var path = new ExecutionPath(
"test-path",
constraints,
ImmutableArray<TerminalCommand>.Empty,
ImmutableArray<BranchDecision>.Empty,
IsFeasible: true,
ReachabilityConfidence: 0.7f,
ImmutableHashSet<string>.Empty);
var analysis = await _scorer.ScorePathAsync(path);
// File existence checks should reduce confidence moderately
Assert.True(analysis.Confidence > 0.5f);
Assert.True(analysis.Confidence < 1.0f);
}
[Fact]
public async Task ScorePathAsync_ManyConstraints_CumulativeReduction()
{
var constraints = ImmutableArray.Create(
new PathConstraint("cond1", false, new ShellSpan(1, 1, 1, 10), ConstraintKind.StringEquality, ImmutableArray.Create("A")),
new PathConstraint("cond2", false, new ShellSpan(2, 1, 2, 10), ConstraintKind.StringEquality, ImmutableArray.Create("B")),
new PathConstraint("cond3", false, new ShellSpan(3, 1, 3, 10), ConstraintKind.FileExists, ImmutableArray<string>.Empty),
new PathConstraint("cond4", false, new ShellSpan(4, 1, 4, 10), ConstraintKind.Unknown, ImmutableArray<string>.Empty));
var path = new ExecutionPath(
"test-path",
constraints,
ImmutableArray<TerminalCommand>.Empty,
ImmutableArray<BranchDecision>.Empty,
IsFeasible: true,
ReachabilityConfidence: 0.5f,
ImmutableHashSet.Create("A", "B"));
var analysis = await _scorer.ScorePathAsync(path);
// Multiple constraints should compound the confidence reduction
Assert.True(analysis.Confidence < 0.8f);
}
[Fact]
public async Task ScorePathAsync_InfeasiblePath_VeryLowConfidence()
{
var path = new ExecutionPath(
"test-path",
ImmutableArray<PathConstraint>.Empty,
ImmutableArray<TerminalCommand>.Empty,
ImmutableArray<BranchDecision>.Empty,
IsFeasible: false,
ReachabilityConfidence: 0.0f,
ImmutableHashSet<string>.Empty);
var analysis = await _scorer.ScorePathAsync(path);
Assert.True(analysis.Confidence <= 0.1f);
}
[Fact]
public async Task ScorePathAsync_ReturnsFactors()
{
var constraints = ImmutableArray.Create(
new PathConstraint(
"[ -n \"$VAR\" ]",
false,
new ShellSpan(1, 1, 1, 15),
ConstraintKind.StringEmpty,
ImmutableArray.Create("VAR")));
var path = new ExecutionPath(
"test-path",
constraints,
ImmutableArray<TerminalCommand>.Empty,
ImmutableArray<BranchDecision>.Empty,
IsFeasible: true,
ReachabilityConfidence: 0.8f,
ImmutableHashSet.Create("VAR"));
var analysis = await _scorer.ScorePathAsync(path);
Assert.NotEmpty(analysis.Factors);
Assert.All(analysis.Factors, f => Assert.NotEmpty(f.Name));
}
[Fact]
public async Task ScorePathAsync_CustomWeights_AffectsScoring()
{
var constraints = ImmutableArray.Create(
new PathConstraint(
"[ -n \"$VAR\" ]",
false,
new ShellSpan(1, 1, 1, 15),
ConstraintKind.StringEmpty,
ImmutableArray.Create("VAR")));
var path = new ExecutionPath(
"test-path",
constraints,
ImmutableArray<TerminalCommand>.Empty,
ImmutableArray<BranchDecision>.Empty,
IsFeasible: true,
ReachabilityConfidence: 0.8f,
ImmutableHashSet.Create("VAR"));
var defaultAnalysis = await _scorer.ScorePathAsync(path);
// Use custom weights that heavily penalize env dependencies
var customWeights = new PathConfidenceWeights(
ConstraintComplexityWeight: 0.1f,
EnvDependencyWeight: 0.8f,
BranchDepthWeight: 0.05f,
ConstraintTypeWeight: 0.025f,
FeasibilityWeight: 0.025f);
var customAnalysis = await _scorer.ScorePathAsync(path, customWeights);
// Custom weights should produce different (likely lower) confidence
Assert.NotEqual(defaultAnalysis.Confidence, customAnalysis.Confidence);
}
[Fact]
public void DefaultWeights_SumToOne()
{
var weights = PathConfidenceScorer.DefaultWeights;
var sum = weights.ConstraintComplexityWeight +
weights.EnvDependencyWeight +
weights.BranchDepthWeight +
weights.ConstraintTypeWeight +
weights.FeasibilityWeight;
Assert.Equal(1.0f, sum, 0.001f);
}
[Fact]
public async Task ScorePathAsync_Deterministic()
{
var constraints = ImmutableArray.Create(
new PathConstraint("cond", false, new ShellSpan(1, 1, 1, 10), ConstraintKind.StringEquality, ImmutableArray.Create("VAR")));
var path = new ExecutionPath(
"test-path",
constraints,
ImmutableArray<TerminalCommand>.Empty,
ImmutableArray<BranchDecision>.Empty,
IsFeasible: true,
ReachabilityConfidence: 0.8f,
ImmutableHashSet.Create("VAR"));
var analysis1 = await _scorer.ScorePathAsync(path);
var analysis2 = await _scorer.ScorePathAsync(path);
Assert.Equal(analysis1.Confidence, analysis2.Confidence);
Assert.Equal(analysis1.Factors.Length, analysis2.Factors.Length);
}
}

View File

@@ -0,0 +1,146 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using StellaOps.Scanner.EntryTrace.Speculative;
using Xunit;
namespace StellaOps.Scanner.EntryTrace.Tests.Speculative;
public sealed class PathEnumeratorTests
{
private readonly PathEnumerator _enumerator = new();
[Fact]
public async Task EnumerateAsync_SimpleScript_ReturnsResult()
{
const string script = """
#!/bin/bash
echo "hello"
""";
var result = await _enumerator.EnumerateAsync(script, "test.sh");
Assert.NotNull(result);
Assert.NotNull(result.Tree);
Assert.True(result.Metrics.TotalPaths >= 1);
}
[Fact]
public async Task EnumerateAsync_GroupsByTerminalCommand()
{
const string script = """
#!/bin/bash
if [ -n "$MODE" ]; then
/app/server --mode=prod
else
/app/server --mode=dev
fi
""";
var result = await _enumerator.EnumerateAsync(script, "test.sh");
// Both paths lead to /app/server - check PathsByCommand
var allPaths = result.PathsByCommand.Values.SelectMany(p => p).ToList();
Assert.True(allPaths.All(p =>
p.TerminalCommands.Any(c =>
c.GetConcreteCommand()?.Contains("/app/server") == true)));
}
[Fact]
public async Task EnumerateAsync_WithKnownEnvironment_UsesValues()
{
const string script = """
#!/bin/bash
echo "$HOME/test"
""";
var options = new PathEnumerationOptions(
KnownEnvironment: new Dictionary<string, string>
{
["HOME"] = "/root"
});
var result = await _enumerator.EnumerateAsync(script, "test.sh", options);
Assert.True(result.Metrics.TotalPaths >= 1);
}
[Fact]
public async Task EnumerateAsync_MaxPaths_Respected()
{
const string script = """
#!/bin/bash
case "$1" in
a) echo a ;;
b) echo b ;;
c) echo c ;;
d) echo d ;;
e) echo e ;;
esac
""";
var options = new PathEnumerationOptions(MaxPaths: 3);
var result = await _enumerator.EnumerateAsync(script, "test.sh", options);
// Verify the enumerator respects the limit in some form
// PathLimitReached should be set, or total paths should be limited
Assert.True(result.Metrics.TotalPaths <= options.MaxPaths || result.Metrics.PathLimitReached,
$"Expected at most {options.MaxPaths} paths or PathLimitReached flag, got {result.Metrics.TotalPaths}");
}
[Fact]
public async Task EnumerateAsync_MaxDepth_Respected()
{
const string script = """
#!/bin/bash
if [ -n "$A" ]; then
if [ -n "$B" ]; then
if [ -n "$C" ]; then
echo "deep"
fi
fi
fi
""";
var options = new PathEnumerationOptions(MaxDepth: 2);
var result = await _enumerator.EnumerateAsync(script, "test.sh", options);
Assert.True(result.Metrics.DepthLimitReached);
}
[Fact]
public async Task EnumerateAsync_PruneInfeasible_RemovesContradictions()
{
// This script has a logically impossible branch
const string script = """
#!/bin/bash
if [ "$X" = "yes" ]; then
if [ "$X" = "no" ]; then
echo "impossible"
fi
fi
""";
var options = new PathEnumerationOptions(PruneInfeasible: true);
var result = await _enumerator.EnumerateAsync(script, "test.sh", options);
// The contradictory path should be pruned or marked infeasible
var allPaths = result.PathsByCommand.Values.SelectMany(p => p).ToList();
var impossiblePaths = allPaths
.Where(p => !p.IsFeasible)
.ToList();
// Note: This depends on the constraint evaluator's capability
Assert.NotNull(result);
}
[Fact]
public async Task EnumerateAsync_ReturnsTreeWithPaths()
{
const string script = "#!/bin/bash\necho test";
var result = await _enumerator.EnumerateAsync(script, "/my/script.sh");
Assert.NotNull(result.Tree);
Assert.NotEmpty(result.Tree.AllPaths);
}
}

View File

@@ -0,0 +1,290 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using StellaOps.Scanner.EntryTrace.Speculative;
using Xunit;
namespace StellaOps.Scanner.EntryTrace.Tests.Speculative;
public sealed class ShellSymbolicExecutorTests
{
private readonly ShellSymbolicExecutor _executor = new();
[Fact]
public async Task ExecuteAsync_SimpleCommand_ProducesOnePath()
{
const string script = """
#!/bin/bash
echo "hello world"
""";
var tree = await _executor.ExecuteAsync(script, "test.sh");
Assert.NotEmpty(tree.AllPaths);
Assert.True(tree.AllPaths.Any(p =>
p.TerminalCommands.Any(c => c.GetConcreteCommand() == "echo")));
}
[Fact]
public async Task ExecuteAsync_IfElse_ProducesMultiplePaths()
{
const string script = """
#!/bin/bash
if [ -n "$VAR" ]; then
echo "var is set"
else
echo "var is empty"
fi
""";
var tree = await _executor.ExecuteAsync(script, "test.sh");
// Should have at least 2 paths: if-true and else
Assert.True(tree.AllPaths.Length >= 2,
$"Expected at least 2 paths, got {tree.AllPaths.Length}");
}
[Fact]
public async Task ExecuteAsync_Case_ProducesPathPerArm()
{
const string script = """
#!/bin/bash
case "$1" in
start)
echo "starting"
;;
stop)
echo "stopping"
;;
*)
echo "usage: $0 {start|stop}"
;;
esac
""";
var tree = await _executor.ExecuteAsync(script, "test.sh");
// Should have at least 3 paths: start, stop, default
Assert.True(tree.AllPaths.Length >= 3,
$"Expected at least 3 paths, got {tree.AllPaths.Length}");
}
[Fact]
public async Task ExecuteAsync_ExecReplacesShell_TerminatesPath()
{
const string script = """
#!/bin/bash
echo "before exec"
exec /bin/sleep infinity
echo "after exec"
""";
var tree = await _executor.ExecuteAsync(script, "test.sh");
// The path should terminate at exec and not include "after exec"
var execPaths = tree.AllPaths
.Where(p => p.TerminalCommands.Any(c => c.IsExec))
.ToList();
Assert.NotEmpty(execPaths);
// Commands after exec should not be recorded
foreach (var path in execPaths)
{
var afterExecCommands = path.TerminalCommands
.SkipWhile(c => !c.IsExec)
.Skip(1)
.ToList();
Assert.Empty(afterExecCommands);
}
}
[Fact]
public async Task ExecuteAsync_NestedIf_ProducesCorrectBranchHistory()
{
const string script = """
#!/bin/bash
if [ -n "$A" ]; then
if [ -n "$B" ]; then
echo "both"
fi
fi
""";
var tree = await _executor.ExecuteAsync(script, "test.sh");
// Find the path that took both if branches
var nestedPath = tree.AllPaths
.Where(p => p.BranchHistory.Length >= 2)
.FirstOrDefault();
Assert.NotNull(nestedPath);
}
[Fact]
public async Task ExecuteAsync_WithEnvironment_TracksVariables()
{
const string script = """
#!/bin/bash
echo "$HOME/test"
""";
var options = new SymbolicExecutionOptions(
InitialEnvironment: new Dictionary<string, string>
{
["HOME"] = "/home/user"
});
var tree = await _executor.ExecuteAsync(script, "test.sh", options);
Assert.NotEmpty(tree.AllPaths);
}
[Fact]
public async Task ExecuteAsync_DepthLimit_StopsExpansion()
{
// Script with many nested ifs would explode without depth limit
var script = """
#!/bin/bash
if [ -n "$A" ]; then
if [ -n "$B" ]; then
if [ -n "$C" ]; then
if [ -n "$D" ]; then
if [ -n "$E" ]; then
echo "deep"
fi
fi
fi
fi
fi
""";
var options = new SymbolicExecutionOptions(MaxDepth: 3);
var tree = await _executor.ExecuteAsync(script, "test.sh", options);
Assert.True(tree.DepthLimitReached);
}
[Fact]
public async Task ExecuteAsync_MaxPaths_LimitsExploration()
{
// Case with many arms
const string script = """
#!/bin/bash
case "$1" in
a) echo "a" ;;
b) echo "b" ;;
c) echo "c" ;;
d) echo "d" ;;
e) echo "e" ;;
f) echo "f" ;;
g) echo "g" ;;
h) echo "h" ;;
i) echo "i" ;;
j) echo "j" ;;
esac
""";
var options = new SymbolicExecutionOptions(MaxPaths: 5);
var tree = await _executor.ExecuteAsync(script, "test.sh", options);
Assert.True(tree.AllPaths.Length <= 5);
}
[Fact]
public async Task ExecuteAsync_VariableAssignment_UpdatesState()
{
const string script = """
#!/bin/bash
MYVAR="hello"
echo "$MYVAR"
""";
var tree = await _executor.ExecuteAsync(script, "test.sh");
// The echo command should have the concrete variable value
var echoCmd = tree.AllPaths
.SelectMany(p => p.TerminalCommands)
.FirstOrDefault(c => c.GetConcreteCommand() == "echo");
Assert.NotNull(echoCmd);
}
[Fact]
public async Task ExecuteAsync_CommandSubstitution_CreatesUnknownValue()
{
const string script = """
#!/bin/bash
TODAY=$(date)
echo "$TODAY"
""";
var tree = await _executor.ExecuteAsync(script, "test.sh");
// The variable should be marked as unknown due to command substitution
Assert.NotEmpty(tree.AllPaths);
}
[Fact]
public async Task ExecuteAsync_EmptyScript_ProducesEmptyPath()
{
const string script = "#!/bin/bash";
var tree = await _executor.ExecuteAsync(script, "test.sh");
Assert.NotEmpty(tree.AllPaths);
Assert.True(tree.AllPaths.All(p => p.TerminalCommands.IsEmpty));
}
[Fact]
public async Task ExecuteAsync_ScriptPath_IsRecorded()
{
const string script = "#!/bin/bash\necho test";
var tree = await _executor.ExecuteAsync(script, "/custom/path/myscript.sh");
Assert.Equal("/custom/path/myscript.sh", tree.ScriptPath);
}
[Fact]
public async Task ExecuteAsync_BranchCoverage_ComputesMetrics()
{
const string script = """
#!/bin/bash
if [ -n "$A" ]; then
echo "a"
else
echo "not a"
fi
""";
var tree = await _executor.ExecuteAsync(script, "test.sh");
Assert.True(tree.Coverage.TotalBranches > 0);
Assert.True(tree.Coverage.CoveredBranches > 0);
}
[Fact]
public async Task ExecuteAsync_Deterministic_SameInputProducesSameOutput()
{
const string script = """
#!/bin/bash
if [ -n "$VAR" ]; then
echo "set"
else
echo "empty"
fi
""";
var tree1 = await _executor.ExecuteAsync(script, "test.sh");
var tree2 = await _executor.ExecuteAsync(script, "test.sh");
Assert.Equal(tree1.AllPaths.Length, tree2.AllPaths.Length);
// Path IDs should be deterministic
var ids1 = tree1.AllPaths.Select(p => p.PathId).OrderBy(x => x).ToList();
var ids2 = tree2.AllPaths.Select(p => p.PathId).OrderBy(x => x).ToList();
Assert.Equal(ids1, ids2);
}
}

View File

@@ -0,0 +1,349 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Collections.Immutable;
using StellaOps.Scanner.EntryTrace.Parsing;
using StellaOps.Scanner.EntryTrace.Speculative;
using Xunit;
namespace StellaOps.Scanner.EntryTrace.Tests.Speculative;
public sealed class SymbolicStateTests
{
[Fact]
public void Initial_CreatesEmptyState()
{
var state = SymbolicState.Initial();
Assert.Empty(state.Variables);
Assert.Empty(state.PathConstraints);
Assert.Empty(state.TerminalCommands);
Assert.Equal(0, state.Depth);
Assert.Equal("root", state.PathId);
Assert.Empty(state.BranchHistory);
}
[Fact]
public void WithEnvironment_SetsVariablesFromDictionary()
{
var env = new Dictionary<string, string>
{
["HOME"] = "/home/user",
["PATH"] = "/usr/bin:/bin"
};
var state = SymbolicState.WithEnvironment(env);
Assert.Equal(2, state.Variables.Count);
var homeValue = state.GetVariable("HOME");
Assert.True(homeValue.TryGetConcrete(out var home));
Assert.Equal("/home/user", home);
}
[Fact]
public void SetVariable_AddsNewVariable()
{
var state = SymbolicState.Initial();
var newState = state.SetVariable("MYVAR", SymbolicValue.Concrete("value"));
Assert.Empty(state.Variables);
Assert.Single(newState.Variables);
Assert.True(newState.GetVariable("MYVAR").TryGetConcrete(out var value));
Assert.Equal("value", value);
}
[Fact]
public void GetVariable_ReturnsSymbolicForUnknown()
{
var state = SymbolicState.Initial();
var value = state.GetVariable("UNKNOWN_VAR");
Assert.False(value.IsConcrete);
Assert.IsType<SymbolicVariable>(value);
}
[Fact]
public void AddConstraint_AppendsToPathConstraints()
{
var state = SymbolicState.Initial();
var constraint = new PathConstraint(
Expression: "[ -n \"$VAR\" ]",
IsNegated: false,
Source: new ShellSpan(1, 1, 1, 15),
Kind: ConstraintKind.StringEmpty,
DependsOnEnv: ImmutableArray.Create("VAR"));
var newState = state.AddConstraint(constraint);
Assert.Empty(state.PathConstraints);
Assert.Single(newState.PathConstraints);
Assert.Equal(constraint, newState.PathConstraints[0]);
}
[Fact]
public void AddTerminalCommand_AppendsToCommands()
{
var state = SymbolicState.Initial();
var command = TerminalCommand.Concrete(
"/bin/echo",
new[] { "hello" },
new ShellSpan(1, 1, 1, 20));
var newState = state.AddTerminalCommand(command);
Assert.Empty(state.TerminalCommands);
Assert.Single(newState.TerminalCommands);
}
[Fact]
public void IncrementDepth_IncreasesDepthByOne()
{
var state = SymbolicState.Initial();
var deeper = state.IncrementDepth();
Assert.Equal(0, state.Depth);
Assert.Equal(1, deeper.Depth);
}
[Fact]
public void Fork_CreatesNewPathWithBranchSuffix()
{
var state = SymbolicState.Initial();
var decision = new BranchDecision(
new ShellSpan(1, 1, 5, 2),
BranchKind.If,
BranchIndex: 0,
TotalBranches: 2,
Predicate: "[ -n \"$VAR\" ]");
var forked = state.Fork(decision, "if-true");
Assert.Equal("root", state.PathId);
Assert.Equal("root/if-true", forked.PathId);
Assert.Single(forked.BranchHistory);
Assert.Equal(1, forked.Depth);
}
[Fact]
public void GetEnvDependencies_CollectsFromConstraintsAndVariables()
{
var state = SymbolicState.Initial()
.SetVariable("DERIVED", SymbolicValue.Symbolic("BASE_VAR"))
.AddConstraint(new PathConstraint(
"[ -n \"$OTHER_VAR\" ]",
false,
new ShellSpan(1, 1, 1, 20),
ConstraintKind.StringEmpty,
ImmutableArray.Create("OTHER_VAR")));
var deps = state.GetEnvDependencies();
Assert.Contains("BASE_VAR", deps);
Assert.Contains("OTHER_VAR", deps);
}
}
public sealed class PathConstraintTests
{
[Fact]
public void Negate_FlipsIsNegatedFlag()
{
var constraint = new PathConstraint(
Expression: "[ -f \"/path\" ]",
IsNegated: false,
Source: new ShellSpan(1, 1, 1, 15),
Kind: ConstraintKind.FileExists,
DependsOnEnv: ImmutableArray<string>.Empty);
var negated = constraint.Negate();
Assert.False(constraint.IsNegated);
Assert.True(negated.IsNegated);
Assert.Equal(constraint.Expression, negated.Expression);
}
[Fact]
public void IsEnvDependent_TrueWhenHasDependencies()
{
var dependent = new PathConstraint(
"[ \"$VAR\" = \"value\" ]",
false,
new ShellSpan(1, 1, 1, 20),
ConstraintKind.StringEquality,
ImmutableArray.Create("VAR"));
var independent = new PathConstraint(
"[ -f \"/etc/passwd\" ]",
false,
new ShellSpan(1, 1, 1, 20),
ConstraintKind.FileExists,
ImmutableArray<string>.Empty);
Assert.True(dependent.IsEnvDependent);
Assert.False(independent.IsEnvDependent);
}
[Fact]
public void ToCanonical_ProducesDeterministicString()
{
var constraint1 = new PathConstraint(
"[ -n \"$VAR\" ]",
false,
new ShellSpan(5, 3, 5, 18),
ConstraintKind.StringEmpty,
ImmutableArray.Create("VAR"));
var constraint2 = new PathConstraint(
"[ -n \"$VAR\" ]",
false,
new ShellSpan(5, 3, 5, 18),
ConstraintKind.StringEmpty,
ImmutableArray.Create("VAR"));
Assert.Equal(constraint1.ToCanonical(), constraint2.ToCanonical());
Assert.Equal("[ -n \"$VAR\" ]@5:3", constraint1.ToCanonical());
}
[Fact]
public void ToCanonical_IncludesNegationPrefix()
{
var constraint = new PathConstraint(
"[ -n \"$VAR\" ]",
IsNegated: true,
new ShellSpan(1, 1, 1, 15),
ConstraintKind.StringEmpty,
ImmutableArray.Create("VAR"));
Assert.StartsWith("!", constraint.ToCanonical());
}
}
public sealed class SymbolicValueTests
{
[Fact]
public void Concrete_IsConcrete()
{
var value = SymbolicValue.Concrete("hello");
Assert.True(value.IsConcrete);
Assert.True(value.TryGetConcrete(out var str));
Assert.Equal("hello", str);
Assert.Empty(value.GetDependentVariables());
}
[Fact]
public void Symbolic_IsNotConcrete()
{
var value = SymbolicValue.Symbolic("MY_VAR");
Assert.False(value.IsConcrete);
Assert.False(value.TryGetConcrete(out _));
Assert.Contains("MY_VAR", value.GetDependentVariables());
}
[Fact]
public void Unknown_HasReason()
{
var value = SymbolicValue.Unknown(UnknownValueReason.CommandSubstitution, "$(date)");
Assert.False(value.IsConcrete);
Assert.IsType<UnknownValue>(value);
var unknown = (UnknownValue)value;
Assert.Equal(UnknownValueReason.CommandSubstitution, unknown.Reason);
}
[Fact]
public void Composite_CombinesParts()
{
var parts = ImmutableArray.Create<SymbolicValue>(
SymbolicValue.Concrete("/home/"),
SymbolicValue.Symbolic("USER"),
SymbolicValue.Concrete("/bin"));
var composite = SymbolicValue.Composite(parts);
Assert.False(composite.IsConcrete);
Assert.IsType<CompositeValue>(composite);
var deps = composite.GetDependentVariables();
Assert.Contains("USER", deps);
}
}
public sealed class TerminalCommandTests
{
[Fact]
public void Concrete_CreatesConcreeteCommand()
{
var cmd = TerminalCommand.Concrete(
"/bin/ls",
new[] { "-la", "/tmp" },
new ShellSpan(1, 1, 1, 20));
Assert.True(cmd.IsConcrete);
Assert.Equal("/bin/ls", cmd.GetConcreteCommand());
Assert.Equal(2, cmd.Arguments.Length);
Assert.False(cmd.IsExec);
}
[Fact]
public void IsConcrete_FalseWhenCommandIsSymbolic()
{
var cmd = new TerminalCommand(
SymbolicValue.Symbolic("CMD"),
ImmutableArray<SymbolicValue>.Empty,
new ShellSpan(1, 1, 1, 10),
IsExec: false,
ImmutableDictionary<string, SymbolicValue>.Empty);
Assert.False(cmd.IsConcrete);
Assert.Null(cmd.GetConcreteCommand());
}
[Fact]
public void GetDependentVariables_CollectsFromCommandAndArgs()
{
var cmd = new TerminalCommand(
SymbolicValue.Symbolic("CMD"),
ImmutableArray.Create(
SymbolicValue.Symbolic("ARG1"),
SymbolicValue.Concrete("literal")),
new ShellSpan(1, 1, 1, 30),
IsExec: false,
ImmutableDictionary<string, SymbolicValue>.Empty);
var deps = cmd.GetDependentVariables();
Assert.Contains("CMD", deps);
Assert.Contains("ARG1", deps);
Assert.DoesNotContain("literal", deps);
}
}
public sealed class BranchDecisionTests
{
[Fact]
public void BranchDecision_StoresAllFields()
{
var decision = new BranchDecision(
new ShellSpan(10, 1, 15, 2),
BranchKind.If,
BranchIndex: 0,
TotalBranches: 3,
Predicate: "[ -n \"$VAR\" ]");
Assert.Equal(BranchKind.If, decision.BranchKind);
Assert.Equal(0, decision.BranchIndex);
Assert.Equal(3, decision.TotalBranches);
Assert.Equal("[ -n \"$VAR\" ]", decision.Predicate);
}
[Fact]
public void BranchKind_HasExpectedValues()
{
Assert.Equal(0, (int)BranchKind.If);
Assert.Equal(1, (int)BranchKind.Elif);
Assert.Equal(2, (int)BranchKind.Else);
Assert.Equal(3, (int)BranchKind.Case);
Assert.Equal(4, (int)BranchKind.Loop);
Assert.Equal(5, (int)BranchKind.FallThrough);
}
}

View File

@@ -8,6 +8,19 @@
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.*">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.*">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.EntryTrace/StellaOps.Scanner.EntryTrace.csproj" />
</ItemGroup>

View File

@@ -1,3 +1,5 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Collections.Immutable;
using StellaOps.Scanner.EntryTrace.Semantic;
using StellaOps.Scanner.EntryTrace.Temporal;
@@ -6,382 +8,271 @@ using Xunit;
namespace StellaOps.Scanner.EntryTrace.Tests.Temporal;
/// <summary>
/// Unit tests for InMemoryTemporalEntrypointStore.
/// Part of Sprint 0412 - Task TEST-001.
/// Unit tests for <see cref="InMemoryTemporalEntrypointStore"/>.
/// </summary>
public sealed class InMemoryTemporalEntrypointStoreTests
{
private readonly InMemoryTemporalEntrypointStore _store = new();
[Fact]
public async Task StoreSnapshotAsync_StoresAndReturnsGraph()
public async Task GetGraphAsync_NotFound_ReturnsNull()
{
// Arrange
var snapshot = CreateSnapshot("v1.0.0", "sha256:abc123", 2);
var store = new InMemoryTemporalEntrypointStore();
// Act
var graph = await _store.StoreSnapshotAsync("my-service", snapshot);
var result = await store.GetGraphAsync("nonexistent");
// Assert
Assert.NotNull(graph);
Assert.Equal("my-service", graph.ServiceId);
Assert.Single(graph.Snapshots);
Assert.Equal("v1.0.0", graph.CurrentVersion);
Assert.Null(graph.PreviousVersion);
Assert.Null(graph.Delta);
Assert.Null(result);
}
[Fact]
public async Task StoreSnapshotAsync_MultipleVersions_CreatesDelta()
public async Task StoreSnapshotAsync_CreatesNewGraph()
{
// Arrange
var snapshot1 = CreateSnapshot("v1.0.0", "sha256:abc", 2);
var snapshot2 = CreateSnapshot("v2.0.0", "sha256:def", 3);
var store = new InMemoryTemporalEntrypointStore();
var snapshot = CreateSnapshot("v1.0.0", "sha256:aaa");
// Act
await _store.StoreSnapshotAsync("my-service", snapshot1);
var graph = await _store.StoreSnapshotAsync("my-service", snapshot2);
var graph = await store.StoreSnapshotAsync("myapp-api", snapshot);
// Assert
Assert.NotNull(graph);
Assert.Equal(2, graph.Snapshots.Length);
Assert.Equal("v2.0.0", graph.CurrentVersion);
Assert.Equal("myapp-api", graph.ServiceId);
Assert.Equal("v1.0.0", graph.CurrentVersion);
Assert.Single(graph.Snapshots);
}
[Fact]
public async Task StoreSnapshotAsync_UpdatesExistingGraph()
{
// Arrange
var store = new InMemoryTemporalEntrypointStore();
var snapshot1 = CreateSnapshot("v1.0.0", "sha256:aaa");
var snapshot2 = CreateSnapshot("v1.1.0", "sha256:bbb");
await store.StoreSnapshotAsync("myapp-api", snapshot1);
// Act
var graph = await store.StoreSnapshotAsync("myapp-api", snapshot2);
// Assert
Assert.Equal("v1.1.0", graph.CurrentVersion);
Assert.Equal("v1.0.0", graph.PreviousVersion);
Assert.Equal(2, graph.Snapshots.Length);
}
[Fact]
public async Task StoreSnapshotAsync_ComputesDelta()
{
// Arrange
var store = new InMemoryTemporalEntrypointStore();
var entrypoint1 = CreateSemanticEntrypoint("ep-1", ApplicationIntent.WebServer);
var snapshot1 = CreateSnapshot("v1.0.0", "sha256:aaa", entrypoint1);
var entrypoint2 = CreateSemanticEntrypoint("ep-2", ApplicationIntent.CliTool);
var snapshot2 = CreateSnapshot("v1.1.0", "sha256:bbb", entrypoint2);
await store.StoreSnapshotAsync("myapp-api", snapshot1);
// Act
var graph = await store.StoreSnapshotAsync("myapp-api", snapshot2);
// Assert
Assert.NotNull(graph.Delta);
Assert.Equal("v1.0.0", graph.Delta.FromVersion);
Assert.Equal("v2.0.0", graph.Delta.ToVersion);
Assert.Equal("v1.1.0", graph.Delta.ToVersion);
}
[Fact]
public async Task GetGraphAsync_ReturnsStoredGraph()
public async Task GetSnapshotAsync_ReturnsStoredSnapshot()
{
// Arrange
var snapshot = CreateSnapshot("v1.0.0", "sha256:abc", 2);
await _store.StoreSnapshotAsync("my-service", snapshot);
var store = new InMemoryTemporalEntrypointStore();
var snapshot = CreateSnapshot("v1.0.0", "sha256:aaa");
await store.StoreSnapshotAsync("myapp-api", snapshot);
// Act
var graph = await _store.GetGraphAsync("my-service");
var result = await store.GetSnapshotAsync("myapp-api", "v1.0.0");
// Assert
Assert.NotNull(graph);
Assert.Equal("my-service", graph.ServiceId);
Assert.NotNull(result);
Assert.Equal("v1.0.0", result.Version);
}
[Fact]
public async Task GetGraphAsync_NonExistentService_ReturnsNull()
{
// Act
var graph = await _store.GetGraphAsync("non-existent");
// Assert
Assert.Null(graph);
}
[Fact]
public async Task ComputeDeltaAsync_CalculatesDifferences()
public async Task GetSnapshotAsync_NotFound_ReturnsNull()
{
// Arrange
var oldEntrypoints = CreateEntrypoints(2);
var newEntrypoints = CreateEntrypoints(3);
var oldSnapshot = new EntrypointSnapshot
{
Version = "v1.0.0",
ImageDigest = "sha256:old",
AnalyzedAt = DateTime.UtcNow.AddDays(-1).ToString("O"),
Entrypoints = oldEntrypoints,
ContentHash = EntrypointSnapshot.ComputeHash(oldEntrypoints)
};
var newSnapshot = new EntrypointSnapshot
{
Version = "v2.0.0",
ImageDigest = "sha256:new",
AnalyzedAt = DateTime.UtcNow.ToString("O"),
Entrypoints = newEntrypoints,
ContentHash = EntrypointSnapshot.ComputeHash(newEntrypoints)
};
var store = new InMemoryTemporalEntrypointStore();
var snapshot = CreateSnapshot("v1.0.0", "sha256:aaa");
await store.StoreSnapshotAsync("myapp-api", snapshot);
// Act
var delta = await _store.ComputeDeltaAsync(oldSnapshot, newSnapshot);
var result = await store.GetSnapshotAsync("myapp-api", "v2.0.0");
// Assert
Assert.Null(result);
}
[Fact]
public async Task ComputeDeltaAsync_ReturnsDelta()
{
// Arrange
var store = new InMemoryTemporalEntrypointStore();
var snapshot1 = CreateSnapshot("v1.0.0", "sha256:aaa");
var snapshot2 = CreateSnapshot("v1.1.0", "sha256:bbb");
await store.StoreSnapshotAsync("myapp-api", snapshot1);
await store.StoreSnapshotAsync("myapp-api", snapshot2);
// Act
var delta = await store.ComputeDeltaAsync("myapp-api", "v1.0.0", "v1.1.0");
// Assert
Assert.NotNull(delta);
Assert.Equal("v1.0.0", delta.FromVersion);
Assert.Equal("v2.0.0", delta.ToVersion);
// Since we use different entrypoint IDs, all new ones are "added" and old ones "removed"
Assert.True(delta.AddedEntrypoints.Length > 0 || delta.RemovedEntrypoints.Length > 0);
Assert.Equal("v1.1.0", delta.ToVersion);
}
[Fact]
public async Task ComputeDeltaAsync_SameContent_ReturnsNoDrift()
public async Task ComputeDeltaAsync_ServiceNotFound_ReturnsNull()
{
// Arrange
var entrypoints = CreateEntrypoints(2);
var snapshot1 = new EntrypointSnapshot
{
Version = "v1.0.0",
ImageDigest = "sha256:same",
AnalyzedAt = DateTime.UtcNow.ToString("O"),
Entrypoints = entrypoints,
ContentHash = EntrypointSnapshot.ComputeHash(entrypoints)
};
var snapshot2 = new EntrypointSnapshot
{
Version = "v1.0.1",
ImageDigest = "sha256:same2",
AnalyzedAt = DateTime.UtcNow.ToString("O"),
Entrypoints = entrypoints,
ContentHash = EntrypointSnapshot.ComputeHash(entrypoints)
};
var store = new InMemoryTemporalEntrypointStore();
// Act
var delta = await _store.ComputeDeltaAsync(snapshot1, snapshot2);
var delta = await store.ComputeDeltaAsync("nonexistent", "v1.0.0", "v1.1.0");
// Assert
Assert.NotNull(delta);
Assert.Empty(delta.AddedEntrypoints);
Assert.Empty(delta.RemovedEntrypoints);
Assert.Empty(delta.ModifiedEntrypoints);
Assert.Equal(EntrypointDrift.None, delta.DriftCategories);
Assert.Null(delta);
}
[Fact]
public async Task PruneSnapshotsAsync_RemovesOldSnapshots()
public async Task ListServicesAsync_ReturnsAllServices()
{
// Arrange
for (var i = 0; i < 15; i++)
{
var snapshot = CreateSnapshot($"v{i}.0.0", $"sha256:hash{i}", 2);
await _store.StoreSnapshotAsync("my-service", snapshot);
}
var store = new InMemoryTemporalEntrypointStore();
await store.StoreSnapshotAsync("service-a", CreateSnapshot("v1.0.0", "sha256:aaa"));
await store.StoreSnapshotAsync("service-b", CreateSnapshot("v1.0.0", "sha256:bbb"));
await store.StoreSnapshotAsync("service-c", CreateSnapshot("v1.0.0", "sha256:ccc"));
// Act - Keep only last 5
var prunedCount = await _store.PruneSnapshotsAsync("my-service", keepCount: 5);
var graph = await _store.GetGraphAsync("my-service");
// Act
var services = await store.ListServicesAsync();
// Assert
Assert.Equal(10, prunedCount);
Assert.Equal(3, services.Count);
Assert.Contains("service-a", services);
Assert.Contains("service-b", services);
Assert.Contains("service-c", services);
}
[Fact]
public async Task ListServicesAsync_ReturnsOrderedList()
{
// Arrange
var store = new InMemoryTemporalEntrypointStore();
await store.StoreSnapshotAsync("zeta", CreateSnapshot("v1.0.0", "sha256:aaa"));
await store.StoreSnapshotAsync("alpha", CreateSnapshot("v1.0.0", "sha256:bbb"));
await store.StoreSnapshotAsync("beta", CreateSnapshot("v1.0.0", "sha256:ccc"));
// Act
var services = await store.ListServicesAsync();
// Assert
Assert.Equal("alpha", services[0]);
Assert.Equal("beta", services[1]);
Assert.Equal("zeta", services[2]);
}
[Fact]
public async Task PruneSnapshotsAsync_KeepsSpecifiedCount()
{
// Arrange
var store = new InMemoryTemporalEntrypointStore();
await store.StoreSnapshotAsync("myapp", CreateSnapshot("v1.0.0", "sha256:aaa"));
await store.StoreSnapshotAsync("myapp", CreateSnapshot("v1.1.0", "sha256:bbb"));
await store.StoreSnapshotAsync("myapp", CreateSnapshot("v1.2.0", "sha256:ccc"));
await store.StoreSnapshotAsync("myapp", CreateSnapshot("v1.3.0", "sha256:ddd"));
// Act
var pruned = await store.PruneSnapshotsAsync("myapp", keepCount: 2);
// Assert
Assert.Equal(2, pruned);
var graph = await store.GetGraphAsync("myapp");
Assert.NotNull(graph);
Assert.Equal(5, graph.Snapshots.Length);
Assert.Equal(2, graph.Snapshots.Length);
Assert.Equal("v1.2.0", graph.Snapshots[0].Version); // oldest kept
Assert.Equal("v1.3.0", graph.Snapshots[1].Version); // newest
}
[Fact]
public async Task PruneSnapshotsAsync_NonExistentService_ReturnsZero()
{
// Act
var prunedCount = await _store.PruneSnapshotsAsync("non-existent", keepCount: 5);
// Assert
Assert.Equal(0, prunedCount);
}
[Fact]
public async Task StoreSnapshotAsync_DetectsIntentChange()
public async Task PruneSnapshotsAsync_NoOpWhenLessThanKeepCount()
{
// Arrange
var snapshot1 = new EntrypointSnapshot
{
Version = "v1.0.0",
ImageDigest = "sha256:old",
AnalyzedAt = DateTime.UtcNow.ToString("O"),
Entrypoints =
[
new SemanticEntrypoint
{
EntrypointId = "ep-1",
FilePath = "/app/main.py",
FunctionName = "handle",
Intent = ApplicationIntent.ApiEndpoint,
Capabilities = [CapabilityClass.NetworkListener],
ThreatVectors = [],
Confidence = new SemanticConfidence { Overall = 0.9 }
}
],
ContentHash = "hash1"
};
var snapshot2 = new EntrypointSnapshot
{
Version = "v2.0.0",
ImageDigest = "sha256:new",
AnalyzedAt = DateTime.UtcNow.ToString("O"),
Entrypoints =
[
new SemanticEntrypoint
{
EntrypointId = "ep-1",
FilePath = "/app/main.py",
FunctionName = "handle",
Intent = ApplicationIntent.Worker, // Changed!
Capabilities = [CapabilityClass.NetworkListener],
ThreatVectors = [],
Confidence = new SemanticConfidence { Overall = 0.9 }
}
],
ContentHash = "hash2"
};
var store = new InMemoryTemporalEntrypointStore();
await store.StoreSnapshotAsync("myapp", CreateSnapshot("v1.0.0", "sha256:aaa"));
// Act
await _store.StoreSnapshotAsync("svc", snapshot1);
var graph = await _store.StoreSnapshotAsync("svc", snapshot2);
var pruned = await store.PruneSnapshotsAsync("myapp", keepCount: 5);
// Assert
Assert.NotNull(graph.Delta);
Assert.True(graph.Delta.DriftCategories.HasFlag(EntrypointDrift.IntentChanged));
Assert.Single(graph.Delta.ModifiedEntrypoints);
Assert.Equal(0, pruned);
var graph = await store.GetGraphAsync("myapp");
Assert.NotNull(graph);
Assert.Single(graph.Snapshots);
}
[Fact]
public async Task StoreSnapshotAsync_DetectsCapabilitiesExpanded()
public async Task MaxSnapshotsLimit_AutoPrunes()
{
// Arrange
var snapshot1 = new EntrypointSnapshot
{
Version = "v1.0.0",
ImageDigest = "sha256:old",
AnalyzedAt = DateTime.UtcNow.ToString("O"),
Entrypoints =
[
new SemanticEntrypoint
{
EntrypointId = "ep-1",
FilePath = "/app/main.py",
FunctionName = "handle",
Intent = ApplicationIntent.ApiEndpoint,
Capabilities = [CapabilityClass.NetworkListener],
ThreatVectors = [],
Confidence = new SemanticConfidence { Overall = 0.9 }
}
],
ContentHash = "hash1"
};
// Arrange - store with max 3 snapshots
var store = new InMemoryTemporalEntrypointStore(maxSnapshotsPerService: 3);
var snapshot2 = new EntrypointSnapshot
{
Version = "v2.0.0",
ImageDigest = "sha256:new",
AnalyzedAt = DateTime.UtcNow.ToString("O"),
Entrypoints =
[
new SemanticEntrypoint
{
EntrypointId = "ep-1",
FilePath = "/app/main.py",
FunctionName = "handle",
Intent = ApplicationIntent.ApiEndpoint,
Capabilities = [CapabilityClass.NetworkListener, CapabilityClass.FileSystemAccess], // Added!
ThreatVectors = [],
Confidence = new SemanticConfidence { Overall = 0.9 }
}
],
ContentHash = "hash2"
};
// Act
await _store.StoreSnapshotAsync("svc", snapshot1);
var graph = await _store.StoreSnapshotAsync("svc", snapshot2);
// Act - add 5 snapshots
await store.StoreSnapshotAsync("myapp", CreateSnapshot("v1.0.0", "sha256:aaa"));
await store.StoreSnapshotAsync("myapp", CreateSnapshot("v1.1.0", "sha256:bbb"));
await store.StoreSnapshotAsync("myapp", CreateSnapshot("v1.2.0", "sha256:ccc"));
await store.StoreSnapshotAsync("myapp", CreateSnapshot("v1.3.0", "sha256:ddd"));
await store.StoreSnapshotAsync("myapp", CreateSnapshot("v1.4.0", "sha256:eee"));
// Assert
Assert.NotNull(graph.Delta);
Assert.True(graph.Delta.DriftCategories.HasFlag(EntrypointDrift.CapabilitiesExpanded));
var graph = await store.GetGraphAsync("myapp");
Assert.NotNull(graph);
Assert.True(graph.Snapshots.Length <= 3, "Should auto-prune to max snapshots");
}
[Fact]
public async Task StoreSnapshotAsync_DetectsAttackSurfaceGrew()
private static EntrypointSnapshot CreateSnapshot(
string version,
string digest,
SemanticEntrypoint? entrypoint = null)
{
// Arrange
var snapshot1 = new EntrypointSnapshot
{
Version = "v1.0.0",
ImageDigest = "sha256:old",
AnalyzedAt = DateTime.UtcNow.ToString("O"),
Entrypoints =
[
new SemanticEntrypoint
{
EntrypointId = "ep-1",
FilePath = "/app/main.py",
FunctionName = "handle",
Intent = ApplicationIntent.ApiEndpoint,
Capabilities = [CapabilityClass.NetworkListener],
ThreatVectors = [ThreatVector.NetworkExposure],
Confidence = new SemanticConfidence { Overall = 0.9 }
}
],
ContentHash = "hash1"
};
var snapshot2 = new EntrypointSnapshot
{
Version = "v2.0.0",
ImageDigest = "sha256:new",
AnalyzedAt = DateTime.UtcNow.ToString("O"),
Entrypoints =
[
new SemanticEntrypoint
{
EntrypointId = "ep-1",
FilePath = "/app/main.py",
FunctionName = "handle",
Intent = ApplicationIntent.ApiEndpoint,
Capabilities = [CapabilityClass.NetworkListener],
ThreatVectors = [ThreatVector.NetworkExposure, ThreatVector.FilePathTraversal], // Added!
Confidence = new SemanticConfidence { Overall = 0.9 }
}
],
ContentHash = "hash2"
};
// Act
await _store.StoreSnapshotAsync("svc", snapshot1);
var graph = await _store.StoreSnapshotAsync("svc", snapshot2);
// Assert
Assert.NotNull(graph.Delta);
Assert.True(graph.Delta.DriftCategories.HasFlag(EntrypointDrift.AttackSurfaceGrew));
}
#region Helper Methods
private static EntrypointSnapshot CreateSnapshot(string version, string digest, int entrypointCount)
{
var entrypoints = CreateEntrypoints(entrypointCount);
return new EntrypointSnapshot
{
Version = version,
ImageDigest = digest,
AnalyzedAt = DateTime.UtcNow.ToString("O"),
Entrypoints = entrypoints,
ContentHash = EntrypointSnapshot.ComputeHash(entrypoints)
AnalyzedAt = "2025-12-20T12:00:00Z",
Entrypoints = entrypoint is not null
? ImmutableArray.Create(entrypoint)
: ImmutableArray<SemanticEntrypoint>.Empty,
ContentHash = "hash-" + version
};
}
private static ImmutableArray<SemanticEntrypoint> CreateEntrypoints(int count)
private static SemanticEntrypoint CreateSemanticEntrypoint(string id, ApplicationIntent intent)
{
var builder = ImmutableArray.CreateBuilder<SemanticEntrypoint>(count);
for (var i = 0; i < count; i++)
return new SemanticEntrypoint
{
builder.Add(new SemanticEntrypoint
{
EntrypointId = $"ep-{Guid.NewGuid():N}",
FilePath = $"/app/handler{i}.py",
FunctionName = $"handle_{i}",
Intent = ApplicationIntent.ApiEndpoint,
Capabilities = [CapabilityClass.NetworkListener],
ThreatVectors = [ThreatVector.NetworkExposure],
Confidence = new SemanticConfidence
{
Overall = 0.9,
IntentConfidence = 0.95,
CapabilityConfidence = 0.85
}
});
}
return builder.ToImmutable();
Id = id,
Specification = new Semantic.EntrypointSpecification(),
Intent = intent,
Capabilities = CapabilityClass.None,
AttackSurface = ImmutableArray<ThreatVector>.Empty,
DataBoundaries = ImmutableArray<DataFlowBoundary>.Empty,
Confidence = SemanticConfidence.Medium("test"),
Language = "unknown",
AnalyzedAt = "2025-12-20T12:00:00Z"
};
}
#endregion
}

View File

@@ -1,3 +1,5 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Collections.Immutable;
using StellaOps.Scanner.EntryTrace.Semantic;
using StellaOps.Scanner.EntryTrace.Temporal;
@@ -6,285 +8,334 @@ using Xunit;
namespace StellaOps.Scanner.EntryTrace.Tests.Temporal;
/// <summary>
/// Unit tests for TemporalEntrypointGraph and related types.
/// Part of Sprint 0412 - Task TEST-001.
/// Unit tests for <see cref="TemporalEntrypointGraph"/> and related records.
/// </summary>
public sealed class TemporalEntrypointGraphTests
{
[Fact]
public void TemporalEntrypointGraph_Creation_SetsProperties()
public void GetSnapshot_ReturnsCorrectSnapshot()
{
// Arrange
var snapshot1 = CreateSnapshot("v1.0.0", "sha256:abc123", 2);
var snapshot2 = CreateSnapshot("v1.1.0", "sha256:def456", 3);
// Act
var snapshot1 = CreateSnapshot("v1.0.0", "sha256:aaa");
var snapshot2 = CreateSnapshot("v1.1.0", "sha256:bbb");
var graph = new TemporalEntrypointGraph
{
ServiceId = "my-service",
Snapshots = [snapshot1, snapshot2],
ServiceId = "myapp-api",
Snapshots = ImmutableArray.Create(snapshot1, snapshot2),
CurrentVersion = "v1.1.0",
PreviousVersion = "v1.0.0"
PreviousVersion = "v1.0.0",
UpdatedAt = "2025-12-20T12:00:00Z"
};
// Act
var result = graph.GetSnapshot("v1.0.0");
// Assert
Assert.Equal("my-service", graph.ServiceId);
Assert.Equal(2, graph.Snapshots.Length);
Assert.Equal("v1.1.0", graph.CurrentVersion);
Assert.Equal("v1.0.0", graph.PreviousVersion);
Assert.NotNull(result);
Assert.Equal("v1.0.0", result.Version);
Assert.Equal("sha256:aaa", result.ImageDigest);
}
[Fact]
public void EntrypointSnapshot_ContentHash_IsDeterministic()
public void GetSnapshot_ByDigest_ReturnsCorrectSnapshot()
{
// Arrange
var entrypoints = CreateEntrypoints(3);
// Act
var snapshot1 = new EntrypointSnapshot
var snapshot1 = CreateSnapshot("v1.0.0", "sha256:aaa");
var snapshot2 = CreateSnapshot("v1.1.0", "sha256:bbb");
var graph = new TemporalEntrypointGraph
{
Version = "v1.0.0",
ImageDigest = "sha256:abc123",
AnalyzedAt = "2025-01-01T00:00:00Z",
Entrypoints = entrypoints,
ContentHash = EntrypointSnapshot.ComputeHash(entrypoints)
ServiceId = "myapp-api",
Snapshots = ImmutableArray.Create(snapshot1, snapshot2),
CurrentVersion = "v1.1.0",
UpdatedAt = "2025-12-20T12:00:00Z"
};
var snapshot2 = new EntrypointSnapshot
{
Version = "v1.0.0",
ImageDigest = "sha256:abc123",
AnalyzedAt = "2025-01-01T12:00:00Z", // Different time
Entrypoints = entrypoints,
ContentHash = EntrypointSnapshot.ComputeHash(entrypoints)
};
// Assert - Same content should produce same hash
Assert.Equal(snapshot1.ContentHash, snapshot2.ContentHash);
}
[Fact]
public void EntrypointSnapshot_ContentHash_DiffersForDifferentContent()
{
// Arrange
var entrypoints1 = CreateEntrypoints(2);
var entrypoints2 = CreateEntrypoints(3);
// Act
var hash1 = EntrypointSnapshot.ComputeHash(entrypoints1);
var hash2 = EntrypointSnapshot.ComputeHash(entrypoints2);
var result = graph.GetSnapshot("sha256:bbb");
// Assert
Assert.NotEqual(hash1, hash2);
Assert.NotNull(result);
Assert.Equal("v1.1.0", result.Version);
}
[Fact]
public void EntrypointDelta_TracksChanges()
public void GetSnapshot_NotFound_ReturnsNull()
{
// Arrange
var added = CreateEntrypoints(1);
var removed = CreateEntrypoints(1);
var modified = new EntrypointModification
var graph = new TemporalEntrypointGraph
{
EntrypointId = "ep-1",
OldIntent = ApplicationIntent.ApiEndpoint,
NewIntent = ApplicationIntent.Worker,
OldCapabilities = ImmutableArray<CapabilityClass>.Empty,
NewCapabilities = [CapabilityClass.NetworkListener],
Drift = EntrypointDrift.IntentChanged
ServiceId = "myapp-api",
Snapshots = ImmutableArray<EntrypointSnapshot>.Empty,
CurrentVersion = "v1.0.0",
UpdatedAt = "2025-12-20T12:00:00Z"
};
// Act
var result = graph.GetSnapshot("v2.0.0");
// Assert
Assert.Null(result);
}
[Fact]
public void ComputeDrift_NoDelta_ReturnsEmpty()
{
// Arrange
var graph = new TemporalEntrypointGraph
{
ServiceId = "myapp-api",
Snapshots = ImmutableArray<EntrypointSnapshot>.Empty,
CurrentVersion = "v1.0.0",
UpdatedAt = "2025-12-20T12:00:00Z",
Delta = null
};
// Act
var drift = graph.ComputeDrift();
// Assert
Assert.Empty(drift);
}
[Fact]
public void ComputeDrift_WithDelta_ReturnsDriftCategories()
{
// Arrange
var delta = new EntrypointDelta
{
FromVersion = "v1.0.0",
ToVersion = "v2.0.0",
FromDigest = "sha256:old",
ToDigest = "sha256:new",
AddedEntrypoints = added,
RemovedEntrypoints = removed,
ModifiedEntrypoints = [modified],
DriftCategories = EntrypointDrift.IntentChanged
};
// Assert
Assert.Equal(1, delta.AddedEntrypoints.Length);
Assert.Equal(1, delta.RemovedEntrypoints.Length);
Assert.Equal(1, delta.ModifiedEntrypoints.Length);
Assert.True(delta.DriftCategories.HasFlag(EntrypointDrift.IntentChanged));
}
[Fact]
public void TemporalEntrypointGraphBuilder_BuildsGraph()
{
// Arrange
var builder = new TemporalEntrypointGraphBuilder("test-service");
var snapshot1 = CreateSnapshot("v1.0.0", "sha256:abc", 2);
var snapshot2 = CreateSnapshot("v2.0.0", "sha256:def", 3);
// Act
var graph = builder
.WithSnapshot(snapshot1)
.WithSnapshot(snapshot2)
.WithCurrentVersion("v2.0.0")
.WithPreviousVersion("v1.0.0")
.Build();
// Assert
Assert.Equal("test-service", graph.ServiceId);
Assert.Equal(2, graph.Snapshots.Length);
Assert.Equal("v2.0.0", graph.CurrentVersion);
}
[Fact]
public void EntrypointDrift_IsRiskIncrease_DetectsRiskyChanges()
{
// Arrange
var riskIncrease = EntrypointDrift.AttackSurfaceGrew |
EntrypointDrift.PrivilegeEscalation;
var riskDecrease = EntrypointDrift.AttackSurfaceShrank |
EntrypointDrift.CapabilitiesReduced;
// Act & Assert
Assert.True(riskIncrease.IsRiskIncrease());
Assert.False(riskDecrease.IsRiskIncrease());
}
[Fact]
public void EntrypointDrift_IsMaterialChange_DetectsMaterialChanges()
{
// Arrange
var material = EntrypointDrift.IntentChanged;
var nonMaterial = EntrypointDrift.None;
// Act & Assert
Assert.True(material.IsMaterialChange());
Assert.False(nonMaterial.IsMaterialChange());
}
[Fact]
public void EntrypointDrift_ToDescription_FormatsCategories()
{
// Arrange
var drift = EntrypointDrift.IntentChanged | EntrypointDrift.PortsAdded;
// Act
var description = drift.ToDescription();
// Assert
Assert.Contains("IntentChanged", description);
Assert.Contains("PortsAdded", description);
}
[Fact]
public void EntrypointDrift_AllRiskFlags_AreConsistent()
{
// Arrange
var allRisks = EntrypointDrift.AttackSurfaceGrew |
EntrypointDrift.CapabilitiesExpanded |
EntrypointDrift.PrivilegeEscalation |
EntrypointDrift.PortsAdded |
EntrypointDrift.SecurityContextWeakened |
EntrypointDrift.NewVulnerableComponent |
EntrypointDrift.ExposedToIngress;
// Act
var isRisk = allRisks.IsRiskIncrease();
// Assert
Assert.True(isRisk);
}
[Fact]
public void EntrypointSnapshot_EmptyEntrypoints_ProducesValidHash()
{
// Arrange
var emptyEntrypoints = ImmutableArray<SemanticEntrypoint>.Empty;
// Act
var hash = EntrypointSnapshot.ComputeHash(emptyEntrypoints);
// Assert
Assert.NotNull(hash);
Assert.NotEmpty(hash);
}
[Fact]
public void TemporalEntrypointGraph_WithDelta_TracksVersionDiff()
{
// Arrange
var oldEntrypoints = CreateEntrypoints(2);
var newEntrypoints = CreateEntrypoints(3);
var delta = new EntrypointDelta
{
FromVersion = "v1",
ToVersion = "v2",
FromDigest = "sha256:old",
ToDigest = "sha256:new",
AddedEntrypoints = newEntrypoints.Skip(2).ToImmutableArray(),
ToVersion = "v1.1.0",
FromDigest = "sha256:aaa",
ToDigest = "sha256:bbb",
AddedEntrypoints = ImmutableArray<SemanticEntrypoint>.Empty,
RemovedEntrypoints = ImmutableArray<SemanticEntrypoint>.Empty,
ModifiedEntrypoints = ImmutableArray<EntrypointModification>.Empty,
DriftCategories = EntrypointDrift.AttackSurfaceGrew
DriftCategories = ImmutableArray.Create(EntrypointDrift.CapabilitiesExpanded)
};
// Act
var graph = new TemporalEntrypointGraph
{
ServiceId = "svc",
Snapshots = [],
CurrentVersion = "v2",
PreviousVersion = "v1",
ServiceId = "myapp-api",
Snapshots = ImmutableArray<EntrypointSnapshot>.Empty,
CurrentVersion = "v1.1.0",
PreviousVersion = "v1.0.0",
UpdatedAt = "2025-12-20T12:00:00Z",
Delta = delta
};
// Act
var drift = graph.ComputeDrift();
// Assert
Assert.NotNull(graph.Delta);
Assert.Equal("v1", graph.Delta.FromVersion);
Assert.Equal("v2", graph.Delta.ToVersion);
Assert.True(graph.Delta.DriftCategories.HasFlag(EntrypointDrift.AttackSurfaceGrew));
Assert.Single(drift);
Assert.Contains(EntrypointDrift.CapabilitiesExpanded, drift);
}
#region Helper Methods
private static EntrypointSnapshot CreateSnapshot(string version, string digest, int entrypointCount)
[Fact]
public void Builder_CreatesGraph()
{
// Arrange
var snapshot = CreateSnapshot("v1.0.0", "sha256:aaa");
// Act
var graph = TemporalEntrypointGraph.CreateBuilder()
.WithServiceId("myapp-api")
.AddSnapshot(snapshot)
.WithCurrentVersion("v1.0.0")
.Build();
// Assert
Assert.Equal("myapp-api", graph.ServiceId);
Assert.Equal("v1.0.0", graph.CurrentVersion);
Assert.Single(graph.Snapshots);
}
private static EntrypointSnapshot CreateSnapshot(string version, string digest)
{
var entrypoints = CreateEntrypoints(entrypointCount);
return new EntrypointSnapshot
{
Version = version,
ImageDigest = digest,
AnalyzedAt = DateTime.UtcNow.ToString("O"),
Entrypoints = entrypoints,
ContentHash = EntrypointSnapshot.ComputeHash(entrypoints)
AnalyzedAt = "2025-12-20T12:00:00Z",
Entrypoints = ImmutableArray<SemanticEntrypoint>.Empty,
ContentHash = "hash-" + version
};
}
}
/// <summary>
/// Unit tests for <see cref="EntrypointDelta"/>.
/// </summary>
public sealed class EntrypointDeltaTests
{
[Fact]
public void HasChanges_True_WhenAddedEntrypoints()
{
// Arrange
var delta = CreateDelta(added: 1);
// Assert
Assert.True(delta.HasChanges);
}
[Fact]
public void HasChanges_True_WhenRemovedEntrypoints()
{
// Arrange
var delta = CreateDelta(removed: 1);
// Assert
Assert.True(delta.HasChanges);
}
[Fact]
public void HasChanges_False_WhenNoChanges()
{
// Arrange
var delta = CreateDelta();
// Assert
Assert.False(delta.HasChanges);
}
[Fact]
public void IsRiskIncrease_True_WhenCapabilitiesExpanded()
{
// Arrange
var delta = CreateDelta(drift: EntrypointDrift.CapabilitiesExpanded);
// Assert
Assert.True(delta.IsRiskIncrease);
}
[Fact]
public void IsRiskIncrease_True_WhenAttackSurfaceGrew()
{
// Arrange
var delta = CreateDelta(drift: EntrypointDrift.AttackSurfaceGrew);
// Assert
Assert.True(delta.IsRiskIncrease);
}
[Fact]
public void IsRiskIncrease_True_WhenPrivilegeEscalation()
{
// Arrange
var delta = CreateDelta(drift: EntrypointDrift.PrivilegeEscalation);
// Assert
Assert.True(delta.IsRiskIncrease);
}
[Fact]
public void IsRiskIncrease_False_WhenOnlyMinorChanges()
{
// Arrange
var delta = CreateDelta(drift: EntrypointDrift.None);
// Assert
Assert.False(delta.IsRiskIncrease);
}
private static EntrypointDelta CreateDelta(
int added = 0,
int removed = 0,
EntrypointDrift? drift = null)
{
var addedList = new List<SemanticEntrypoint>();
for (var i = 0; i < added; i++)
{
addedList.Add(CreateMinimalEntrypoint($"added-{i}"));
}
var removedList = new List<SemanticEntrypoint>();
for (var i = 0; i < removed; i++)
{
removedList.Add(CreateMinimalEntrypoint($"removed-{i}"));
}
return new EntrypointDelta
{
FromVersion = "v1.0.0",
ToVersion = "v1.1.0",
FromDigest = "sha256:aaa",
ToDigest = "sha256:bbb",
AddedEntrypoints = addedList.ToImmutableArray(),
RemovedEntrypoints = removedList.ToImmutableArray(),
ModifiedEntrypoints = ImmutableArray<EntrypointModification>.Empty,
DriftCategories = drift.HasValue
? ImmutableArray.Create(drift.Value)
: ImmutableArray<EntrypointDrift>.Empty
};
}
private static ImmutableArray<SemanticEntrypoint> CreateEntrypoints(int count)
private static SemanticEntrypoint CreateMinimalEntrypoint(string id)
{
var builder = ImmutableArray.CreateBuilder<SemanticEntrypoint>(count);
for (var i = 0; i < count; i++)
return new SemanticEntrypoint
{
builder.Add(new SemanticEntrypoint
{
EntrypointId = $"ep-{i}",
FilePath = $"/app/handler{i}.py",
FunctionName = $"handle_{i}",
Intent = ApplicationIntent.ApiEndpoint,
Capabilities = [CapabilityClass.NetworkListener],
ThreatVectors = [ThreatVector.NetworkExposure],
Confidence = new SemanticConfidence
{
Overall = 0.9,
IntentConfidence = 0.95,
CapabilityConfidence = 0.85
}
});
}
return builder.ToImmutable();
Id = id,
Specification = new Semantic.EntrypointSpecification(),
Intent = ApplicationIntent.Unknown,
Capabilities = CapabilityClass.None,
AttackSurface = ImmutableArray<ThreatVector>.Empty,
DataBoundaries = ImmutableArray<DataFlowBoundary>.Empty,
Confidence = SemanticConfidence.Medium("test"),
Language = "unknown",
AnalyzedAt = "2025-12-20T12:00:00Z"
};
}
}
/// <summary>
/// Unit tests for <see cref="EntrypointSnapshot"/>.
/// </summary>
public sealed class EntrypointSnapshotTests
{
[Fact]
public void EntrypointCount_ReturnsCorrectCount()
{
// Arrange
var entrypoint = new SemanticEntrypoint
{
Id = "ep-1",
Specification = new Semantic.EntrypointSpecification(),
Intent = ApplicationIntent.WebServer,
Capabilities = CapabilityClass.NetworkListen,
AttackSurface = ImmutableArray<ThreatVector>.Empty,
DataBoundaries = ImmutableArray<DataFlowBoundary>.Empty,
Confidence = SemanticConfidence.High("test"),
Language = "python",
AnalyzedAt = "2025-12-20T12:00:00Z"
};
var snapshot = new EntrypointSnapshot
{
Version = "v1.0.0",
ImageDigest = "sha256:aaa",
AnalyzedAt = "2025-12-20T12:00:00Z",
Entrypoints = ImmutableArray.Create(entrypoint),
ContentHash = "hash-v1"
};
// Assert
Assert.Equal(1, snapshot.EntrypointCount);
}
#endregion
[Fact]
public void ExposedPorts_DefaultsToEmpty()
{
// Arrange
var snapshot = new EntrypointSnapshot
{
Version = "v1.0.0",
ImageDigest = "sha256:aaa",
AnalyzedAt = "2025-12-20T12:00:00Z",
Entrypoints = ImmutableArray<SemanticEntrypoint>.Empty,
ContentHash = "hash-v1"
};
// Assert
Assert.Empty(snapshot.ExposedPorts);
}
}