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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user