release orchestrator v1 draft and build fixes

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

View File

@@ -15,9 +15,7 @@
<RestoreNoCache>true</RestoreNoCache>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<ItemGroup> <PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>

View File

@@ -6,7 +6,7 @@
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.CallGraph.Binary;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
using Xunit;
using StellaOps.TestKit;

View File

@@ -6,7 +6,7 @@
using System.Collections.Immutable;
using StellaOps.Scanner.CallGraph;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
using StellaOps.TestKit;
using Xunit;

View File

@@ -8,6 +8,7 @@ using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using StellaOps.Scanner.Contracts;
using Xunit;
namespace StellaOps.Scanner.CallGraph.Tests;

View File

@@ -1,5 +1,6 @@
using StellaOps.Scanner.CallGraph;
using StellaOps.Scanner.CallGraph.DotNet;
using StellaOps.Scanner.Contracts;
using Xunit;

View File

@@ -6,7 +6,7 @@
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.CallGraph.Go;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
using Xunit;
using StellaOps.TestKit;

View File

@@ -6,7 +6,7 @@
using StellaOps.Scanner.CallGraph;
using StellaOps.Scanner.CallGraph.Java;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;

View File

@@ -9,7 +9,7 @@ using System.IO;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.CallGraph.JavaScript;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
using Xunit;

View File

@@ -6,7 +6,7 @@
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.CallGraph.Python;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
using Xunit;
using StellaOps.TestKit;

View File

@@ -1,5 +1,6 @@
using System.Collections.Immutable;
using StellaOps.Scanner.CallGraph;
using StellaOps.Scanner.Contracts;
using Xunit;
using StellaOps.TestKit;
@@ -28,7 +29,7 @@ public class ReachabilityAnalyzerTests
[
new CallGraphNode(entry, "Entry", "file.cs", 1, "app", Visibility.Public, true, EntrypointType.HttpHandler, false, null),
new CallGraphNode(mid, "Mid", "file.cs", 2, "app", Visibility.Public, false, null, false, null),
new CallGraphNode(sink, "Sink", "file.cs", 3, "System", Visibility.Public, false, null, true, StellaOps.Scanner.Reachability.SinkCategory.CmdExec),
new CallGraphNode(sink, "Sink", "file.cs", 3, "System", Visibility.Public, false, null, true, SinkCategory.CmdExec),
],
Edges:
[
@@ -97,8 +98,8 @@ public class ReachabilityAnalyzerTests
new CallGraphNode(entry2, "Entry2", "f.cs", 2, "app", Visibility.Public, true, EntrypointType.HttpHandler, false, null),
new CallGraphNode(mid1, "Mid1", "f.cs", 3, "app", Visibility.Public, false, null, false, null),
new CallGraphNode(mid2, "Mid2", "f.cs", 4, "app", Visibility.Public, false, null, false, null),
new CallGraphNode(sink1, "Sink1", "f.cs", 5, "lib", Visibility.Public, false, null, true, StellaOps.Scanner.Reachability.SinkCategory.CmdExec),
new CallGraphNode(sink2, "Sink2", "f.cs", 6, "lib", Visibility.Public, false, null, true, StellaOps.Scanner.Reachability.SinkCategory.SqlRaw),
new CallGraphNode(sink1, "Sink1", "f.cs", 5, "lib", Visibility.Public, false, null, true, SinkCategory.CmdExec),
new CallGraphNode(sink2, "Sink2", "f.cs", 6, "lib", Visibility.Public, false, null, true, SinkCategory.SqlRaw),
],
Edges:
[
@@ -142,7 +143,7 @@ public class ReachabilityAnalyzerTests
[
new CallGraphNode(entry, "Entry", "f.cs", 1, "app", Visibility.Public, true, EntrypointType.HttpHandler, false, null),
new CallGraphNode(mid, "Mid", "f.cs", 2, "app", Visibility.Public, false, null, false, null),
new CallGraphNode(sink, "Sink", "f.cs", 3, "lib", Visibility.Public, false, null, true, StellaOps.Scanner.Reachability.SinkCategory.CmdExec),
new CallGraphNode(sink, "Sink", "f.cs", 3, "lib", Visibility.Public, false, null, true, SinkCategory.CmdExec),
],
Edges:
[
@@ -185,7 +186,7 @@ public class ReachabilityAnalyzerTests
{
var sink = $"sink:{i:D3}";
sinks.Add(sink);
nodes.Add(new CallGraphNode(sink, $"Sink{i}", "f.cs", i + 10, "lib", Visibility.Public, false, null, true, StellaOps.Scanner.Reachability.SinkCategory.CmdExec));
nodes.Add(new CallGraphNode(sink, $"Sink{i}", "f.cs", i + 10, "lib", Visibility.Public, false, null, true, SinkCategory.CmdExec));
edges.Add(new CallGraphEdge(entry, sink, CallKind.Direct));
}
@@ -225,7 +226,7 @@ public class ReachabilityAnalyzerTests
var nodeId = $"node:{i:D3}";
var isEntry = i == 0;
var isSink = i == 9;
nodes.Add(new CallGraphNode(nodeId, $"Node{i}", "f.cs", i, "app", Visibility.Public, isEntry, isEntry ? EntrypointType.HttpHandler : null, isSink, isSink ? StellaOps.Scanner.Reachability.SinkCategory.CmdExec : null));
nodes.Add(new CallGraphNode(nodeId, $"Node{i}", "f.cs", i, "app", Visibility.Public, isEntry, isEntry ? EntrypointType.HttpHandler : null, isSink, isSink ? SinkCategory.CmdExec : null));
if (i > 0)
{
edges.Add(new CallGraphEdge($"node:{(i-1):D3}", nodeId, CallKind.Direct));
@@ -276,7 +277,7 @@ public class ReachabilityAnalyzerTests
new CallGraphNode(entry, "Entry", "f.cs", 1, "app", Visibility.Public, true, EntrypointType.HttpHandler, false, null),
new CallGraphNode(mid1, "Mid1", "f.cs", 2, "app", Visibility.Public, false, null, false, null),
new CallGraphNode(mid2, "Mid2", "f.cs", 3, "app", Visibility.Public, false, null, false, null),
new CallGraphNode(sink, "Sink", "f.cs", 4, "lib", Visibility.Public, false, null, true, StellaOps.Scanner.Reachability.SinkCategory.CmdExec),
new CallGraphNode(sink, "Sink", "f.cs", 4, "lib", Visibility.Public, false, null, true, SinkCategory.CmdExec),
],
Edges:
[
@@ -324,7 +325,7 @@ public class ReachabilityAnalyzerTests
[
new CallGraphNode(entry, "Entry", "f.cs", 1, "app", Visibility.Public, true, EntrypointType.HttpHandler, false, null),
new CallGraphNode(mid, "Mid", "f.cs", 2, "app", Visibility.Public, false, null, false, null),
new CallGraphNode(snapshotSink, "SnapshotSink", "f.cs", 3, "lib", Visibility.Public, false, null, true, StellaOps.Scanner.Reachability.SinkCategory.CmdExec),
new CallGraphNode(snapshotSink, "SnapshotSink", "f.cs", 3, "lib", Visibility.Public, false, null, true, SinkCategory.CmdExec),
new CallGraphNode(explicitSink, "ExplicitSink", "f.cs", 4, "lib", Visibility.Public, false, null, false, null), // Not marked as sink
],
Edges:
@@ -371,7 +372,7 @@ public class ReachabilityAnalyzerTests
Nodes:
[
new CallGraphNode(entry, "Entry", "f.cs", 1, "app", Visibility.Public, true, EntrypointType.HttpHandler, false, null),
new CallGraphNode(sink, "Sink", "f.cs", 2, "lib", Visibility.Public, false, null, true, StellaOps.Scanner.Reachability.SinkCategory.CmdExec),
new CallGraphNode(sink, "Sink", "f.cs", 2, "lib", Visibility.Public, false, null, true, SinkCategory.CmdExec),
],
Edges:
[

View File

@@ -17,6 +17,7 @@
<ItemGroup>
<ProjectReference Include="..\\..\\__Libraries\\StellaOps.Scanner.CallGraph\\StellaOps.Scanner.CallGraph.csproj" />
<ProjectReference Include="..\\..\\__Libraries\\StellaOps.Scanner.Contracts\\StellaOps.Scanner.Contracts.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -4,6 +4,7 @@ using Moq;
using StackExchange.Redis;
using StellaOps.Scanner.CallGraph;
using StellaOps.Scanner.CallGraph.Caching;
using StellaOps.Scanner.Contracts;
using Xunit;
using StellaOps.TestKit;

View File

@@ -0,0 +1,264 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.ChangeTrace.Builder;
using StellaOps.Scanner.ChangeTrace.Models;
namespace StellaOps.Scanner.ChangeTrace.Tests.Builder;
/// <summary>
/// Tests for ChangeTraceBuilder.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ChangeTraceBuilderTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly ChangeTraceBuilder _builder;
public ChangeTraceBuilderTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 12, 10, 0, 0, TimeSpan.Zero));
_builder = new ChangeTraceBuilder(NullLogger<ChangeTraceBuilder>.Instance, _timeProvider);
}
[Fact]
public async Task FromScanComparisonAsync_WithValidIds_ReturnsValidTrace()
{
// Arrange
var fromScanId = "scan-before-123";
var toScanId = "scan-after-456";
// Act
var trace = await _builder.FromScanComparisonAsync(fromScanId, toScanId);
// Assert
trace.Should().NotBeNull();
trace.Basis.FromScanId.Should().Be(fromScanId);
trace.Basis.ToScanId.Should().Be(toScanId);
trace.Basis.ScanId.Should().Contain(fromScanId);
trace.Basis.ScanId.Should().Contain(toScanId);
}
[Fact]
public async Task FromScanComparisonAsync_WithOptions_AppliesDiffMethods()
{
// Arrange
var options = new ChangeTraceBuilderOptions
{
IncludePackageDiff = true,
IncludeSymbolDiff = true,
IncludeByteDiff = true
};
// Act
var trace = await _builder.FromScanComparisonAsync("from", "to", options);
// Assert
trace.Basis.DiffMethod.Should().Contain("pkg");
trace.Basis.DiffMethod.Should().Contain("symbol");
trace.Basis.DiffMethod.Should().Contain("byte");
}
[Fact]
public async Task FromScanComparisonAsync_WithoutByteDiff_ExcludesFromMethods()
{
// Arrange
var options = new ChangeTraceBuilderOptions
{
IncludePackageDiff = true,
IncludeSymbolDiff = true,
IncludeByteDiff = false
};
// Act
var trace = await _builder.FromScanComparisonAsync("from", "to", options);
// Assert
trace.Basis.DiffMethod.Should().Contain("pkg");
trace.Basis.DiffMethod.Should().Contain("symbol");
trace.Basis.DiffMethod.Should().NotContain("byte");
}
[Fact]
public async Task FromScanComparisonAsync_GeneratesCommitmentHash()
{
// Act
var trace = await _builder.FromScanComparisonAsync("from", "to");
// Assert
trace.Commitment.Should().NotBeNull();
trace.Commitment!.Sha256.Should().NotBeNullOrEmpty();
trace.Commitment.Algorithm.Should().Be("RFC8785+SHA256");
}
[Fact]
public async Task FromScanComparisonAsync_UsesInjectedTimeProvider()
{
// Act
var trace = await _builder.FromScanComparisonAsync("from", "to");
// Assert
trace.Basis.AnalyzedAt.Should().Be(_timeProvider.GetUtcNow());
}
[Fact]
public async Task FromScanComparisonAsync_WithNullFromId_ThrowsArgumentException()
{
// Act
var act = () => _builder.FromScanComparisonAsync(null!, "to");
// Assert
await act.Should().ThrowAsync<ArgumentException>();
}
[Fact]
public async Task FromScanComparisonAsync_WithEmptyToId_ThrowsArgumentException()
{
// Act
var act = () => _builder.FromScanComparisonAsync("from", "");
// Assert
await act.Should().ThrowAsync<ArgumentException>();
}
[Fact]
public void ComputeVerdict_WithNegativeScore_ReturnsRiskDown()
{
// Arrange & Act
var verdict = ChangeTraceBuilder.ComputeVerdict(-0.5);
// Assert
verdict.Should().Be(ChangeTraceVerdict.RiskDown);
}
[Fact]
public void ComputeVerdict_WithPositiveScore_ReturnsRiskUp()
{
// Arrange & Act
var verdict = ChangeTraceBuilder.ComputeVerdict(0.5);
// Assert
verdict.Should().Be(ChangeTraceVerdict.RiskUp);
}
[Fact]
public void ComputeVerdict_WithNeutralScore_ReturnsNeutral()
{
// Arrange & Act
var verdict = ChangeTraceBuilder.ComputeVerdict(0.1);
// Assert
verdict.Should().Be(ChangeTraceVerdict.Neutral);
}
[Theory]
[InlineData(-0.31, ChangeTraceVerdict.RiskDown)]
[InlineData(-0.30, ChangeTraceVerdict.Neutral)]
[InlineData(0.0, ChangeTraceVerdict.Neutral)]
[InlineData(0.30, ChangeTraceVerdict.Neutral)]
[InlineData(0.31, ChangeTraceVerdict.RiskUp)]
public void ComputeVerdict_WithBoundaryValues_ReturnsCorrectVerdict(double score, ChangeTraceVerdict expected)
{
// Act
var verdict = ChangeTraceBuilder.ComputeVerdict(score);
// Assert
verdict.Should().Be(expected);
}
[Fact]
public void ComputeTrustDelta_WithDecreasedTrust_ReturnsNegative()
{
// Arrange
var beforeTrust = 0.85;
var afterTrust = 0.62;
// Act
var delta = ChangeTraceBuilder.ComputeTrustDelta(beforeTrust, afterTrust);
// Assert
delta.Should().BeNegative();
delta.Should().BeApproximately(-0.27, 0.01);
}
[Fact]
public void ComputeTrustDelta_WithIncreasedTrust_ReturnsPositive()
{
// Arrange
var beforeTrust = 0.50;
var afterTrust = 0.75;
// Act
var delta = ChangeTraceBuilder.ComputeTrustDelta(beforeTrust, afterTrust);
// Assert
delta.Should().BePositive();
}
[Fact]
public void ComputeTrustDelta_WithZeroBeforeTrust_UsesMinimumDenominator()
{
// Arrange - formula uses max(beforeTrust, 0.01) to avoid division by zero
var beforeTrust = 0.0;
var afterTrust = 0.5;
// Act
var delta = ChangeTraceBuilder.ComputeTrustDelta(beforeTrust, afterTrust);
// Assert - (0.5 - 0.0) / 0.01 = 50.0
delta.Should().BeApproximately(50.0, 0.001);
}
[Fact]
public void ChangeTraceBuilderOptions_DefaultValues_AreCorrect()
{
// Arrange & Act
var options = new ChangeTraceBuilderOptions();
// Assert
options.IncludePackageDiff.Should().BeTrue();
options.IncludeSymbolDiff.Should().BeTrue();
options.IncludeByteDiff.Should().BeFalse();
options.MinSymbolConfidence.Should().Be(0.75);
options.ByteDiffWindowSize.Should().Be(2048);
options.MaxBinarySize.Should().Be(10 * 1024 * 1024);
options.Policies.Should().Contain("lattice:default@v3");
}
[Fact]
public void GetDiffMethods_WithAllEnabled_ReturnsThreeMethods()
{
// Arrange
var options = new ChangeTraceBuilderOptions
{
IncludePackageDiff = true,
IncludeSymbolDiff = true,
IncludeByteDiff = true
};
// Act
var methods = options.GetDiffMethods();
// Assert
methods.Should().HaveCount(3);
methods.Should().ContainInOrder("pkg", "symbol", "byte");
}
[Fact]
public void GetDiffMethods_WithNoneEnabled_ReturnsEmpty()
{
// Arrange
var options = new ChangeTraceBuilderOptions
{
IncludePackageDiff = false,
IncludeSymbolDiff = false,
IncludeByteDiff = false
};
// Act
var methods = options.GetDiffMethods();
// Assert
methods.Should().BeEmpty();
}
}

View File

@@ -0,0 +1,348 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
using FluentAssertions;
using StellaOps.Scanner.ChangeTrace.ByteDiff;
namespace StellaOps.Scanner.ChangeTrace.Tests.ByteDiff;
/// <summary>
/// Tests for ByteLevelDiffer.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ByteLevelDifferTests
{
private readonly ByteLevelDiffer _differ = new();
[Fact]
public async Task CompareAsync_IdenticalStreams_ReturnsNoDeltas()
{
// Arrange
var data = CreateTestData(4096);
using var fromStream = new MemoryStream(data);
using var toStream = new MemoryStream(data);
// Act
var deltas = await _differ.CompareAsync(fromStream, toStream);
// Assert
deltas.Should().BeEmpty();
}
[Fact]
public async Task CompareAsync_DifferentStreams_ReturnsDeltas()
{
// Arrange
var fromData = CreateTestData(4096);
var toData = CreateTestData(4096, seed: 99); // Different seed = different data
using var fromStream = new MemoryStream(fromData);
using var toStream = new MemoryStream(toData);
// Act
var deltas = await _differ.CompareAsync(fromStream, toStream);
// Assert
deltas.Should().NotBeEmpty();
}
[Fact]
public async Task CompareAsync_PartialChange_DetectsChangedWindow()
{
// Arrange
var fromData = CreateTestData(8192);
var toData = (byte[])fromData.Clone();
// Modify only the second window (bytes 2048-4095)
for (var i = 2048; i < 4096; i++)
{
toData[i] = (byte)(toData[i] ^ 0xFF);
}
using var fromStream = new MemoryStream(fromData);
using var toStream = new MemoryStream(toData);
// Act
var deltas = await _differ.CompareAsync(fromStream, toStream);
// Assert
deltas.Should().HaveCount(1);
deltas[0].Offset.Should().Be(2048);
deltas[0].Size.Should().Be(2048);
}
[Fact]
public async Task CompareAsync_TruncatedToFile_ReportsDeltas()
{
// Arrange
var fromData = CreateTestData(8192);
var toData = fromData[..4096]; // Truncated to half
using var fromStream = new MemoryStream(fromData);
using var toStream = new MemoryStream(toData);
// Act
var deltas = await _differ.CompareAsync(fromStream, toStream);
// Assert
deltas.Should().NotBeEmpty();
deltas.Should().Contain(d => d.ToHash == "truncated");
}
[Fact]
public async Task CompareAsync_WithOptions_UsesWindowSize()
{
// Arrange
var fromData = CreateTestData(4096);
var toData = (byte[])fromData.Clone();
// Modify only first 512 bytes
for (var i = 0; i < 512; i++)
{
toData[i] = (byte)(toData[i] ^ 0xFF);
}
using var fromStream = new MemoryStream(fromData);
using var toStream = new MemoryStream(toData);
var options = new ByteDiffOptions
{
WindowSize = 1024,
StepSize = 1024
};
// Act
var deltas = await _differ.CompareAsync(fromStream, toStream, options);
// Assert
deltas.Should().HaveCount(1);
deltas[0].Size.Should().Be(1024); // Reports full window even though only 512 changed
}
[Fact]
public async Task CompareAsync_WithContext_IncludesContextDescription()
{
// Arrange
var fromData = CreateTestData(4096);
var toData = CreateTestData(4096, seed: 99);
using var fromStream = new MemoryStream(fromData);
using var toStream = new MemoryStream(toData);
var options = new ByteDiffOptions
{
IncludeContext = true
};
// Act
var deltas = await _differ.CompareAsync(fromStream, toStream, options);
// Assert
// Context is included when there are multiple consecutive changes
deltas.Should().NotBeEmpty();
}
[Fact]
public async Task CompareAsync_LargeFiles_UsesSampling()
{
// Arrange
var size = 11 * 1024 * 1024; // 11MB - over default 10MB limit
var fromData = new byte[size];
var toData = new byte[size];
new Random(42).NextBytes(fromData);
new Random(43).NextBytes(toData);
using var fromStream = new MemoryStream(fromData);
using var toStream = new MemoryStream(toData);
var options = new ByteDiffOptions
{
MaxFileSize = 10 * 1024 * 1024
};
// Act
var deltas = await _differ.CompareAsync(fromStream, toStream, options);
// Assert - Should complete without memory issues and return sampled deltas
deltas.Should().NotBeEmpty();
}
[Fact]
public async Task CompareAsync_DisableSectionAnalysis_UsesFullBinaryComparison()
{
// Arrange
var fromData = CreateTestData(4096);
var toData = CreateTestData(4096, seed: 99);
using var fromStream = new MemoryStream(fromData);
using var toStream = new MemoryStream(toData);
var options = new ByteDiffOptions
{
AnalyzeBySections = false
};
// Act
var deltas = await _differ.CompareAsync(fromStream, toStream, options);
// Assert
deltas.Should().NotBeEmpty();
deltas.Should().AllSatisfy(d => d.Section.Should().BeNull());
}
[Fact]
public async Task CompareAsync_CancellationToken_Respected()
{
// Arrange
var fromData = CreateTestData(4096);
var toData = CreateTestData(4096, seed: 99);
using var fromStream = new MemoryStream(fromData);
using var toStream = new MemoryStream(toData);
var cts = new CancellationTokenSource();
cts.Cancel();
// Act & Assert
var action = async () => await _differ.CompareAsync(fromStream, toStream, ct: cts.Token);
await action.Should().ThrowAsync<OperationCanceledException>();
}
[Fact]
public async Task CompareAsync_NullFromStream_ThrowsArgumentNullException()
{
// Arrange
using var toStream = new MemoryStream();
// Act & Assert
var action = async () => await _differ.CompareAsync(null!, toStream);
await action.Should().ThrowAsync<ArgumentNullException>();
}
[Fact]
public async Task CompareAsync_NullToStream_ThrowsArgumentNullException()
{
// Arrange
using var fromStream = new MemoryStream();
// Act & Assert
var action = async () => await _differ.CompareAsync(fromStream, null!);
await action.Should().ThrowAsync<ArgumentNullException>();
}
[Fact]
public async Task CompareAsync_DeltaHasCorrectScope()
{
// Arrange
var fromData = CreateTestData(4096);
var toData = CreateTestData(4096, seed: 99);
using var fromStream = new MemoryStream(fromData);
using var toStream = new MemoryStream(toData);
// Act
var deltas = await _differ.CompareAsync(fromStream, toStream);
// Assert
deltas.Should().AllSatisfy(d => d.Scope.Should().Be("byte"));
}
[Fact]
public async Task CompareAsync_DeltasAreDeterministic()
{
// Arrange
var fromData = CreateTestData(8192);
var toData = CreateTestData(8192, seed: 99);
// Act - Compare twice
using var fromStream1 = new MemoryStream(fromData);
using var toStream1 = new MemoryStream(toData);
var deltas1 = await _differ.CompareAsync(fromStream1, toStream1);
using var fromStream2 = new MemoryStream(fromData);
using var toStream2 = new MemoryStream(toData);
var deltas2 = await _differ.CompareAsync(fromStream2, toStream2);
// Assert - Results should be identical
deltas1.Should().HaveCount(deltas2.Count);
for (var i = 0; i < deltas1.Count; i++)
{
deltas1[i].Offset.Should().Be(deltas2[i].Offset);
deltas1[i].Size.Should().Be(deltas2[i].Size);
deltas1[i].FromHash.Should().Be(deltas2[i].FromHash);
deltas1[i].ToHash.Should().Be(deltas2[i].ToHash);
}
}
[Fact]
public async Task CompareAsync_HashesAreValidSha256Hex()
{
// Arrange
var fromData = CreateTestData(4096);
var toData = CreateTestData(4096, seed: 99);
using var fromStream = new MemoryStream(fromData);
using var toStream = new MemoryStream(toData);
// Act
var deltas = await _differ.CompareAsync(fromStream, toStream);
// Assert
deltas.Should().NotBeEmpty();
foreach (var delta in deltas)
{
if (delta.FromHash != "absent" && delta.FromHash != "truncated" && delta.FromHash != "removed")
{
delta.FromHash.Should().MatchRegex("^[a-f0-9]{64}$");
}
if (delta.ToHash != "absent" && delta.ToHash != "truncated" && delta.ToHash != "removed")
{
delta.ToHash.Should().MatchRegex("^[a-f0-9]{64}$");
}
}
}
[Fact]
public async Task CompareAsync_MinConsecutiveChanges_MergesDeltas()
{
// Arrange
var fromData = CreateTestData(8192);
var toData = CreateTestData(8192, seed: 99); // Completely different
using var fromStream = new MemoryStream(fromData);
using var toStream = new MemoryStream(toData);
var options = new ByteDiffOptions
{
WindowSize = 1024,
StepSize = 1024,
MinConsecutiveChanges = 2
};
// Act
var deltas = await _differ.CompareAsync(fromStream, toStream, options);
// Assert - Should have merged consecutive changes
deltas.Should().NotBeEmpty();
// With 8192 bytes and 1024 window, there are 8 windows
// All windows are different, so they should be merged into fewer deltas
deltas.Count.Should().BeLessThan(8);
}
[Fact]
public async Task CompareAsync_AddedBytes_DetectedAsAbsent()
{
// Arrange
var fromData = CreateTestData(4096);
var toData = new byte[8192];
Array.Copy(fromData, toData, fromData.Length);
new Random(99).NextBytes(toData.AsSpan(4096)); // Add random data at end
using var fromStream = new MemoryStream(fromData);
using var toStream = new MemoryStream(toData);
// Act
var deltas = await _differ.CompareAsync(fromStream, toStream);
// Assert
deltas.Should().Contain(d => d.FromHash == "absent");
}
// Helper methods
private static byte[] CreateTestData(int size, int seed = 42)
{
var data = new byte[size];
new Random(seed).NextBytes(data);
return data;
}
}

View File

@@ -0,0 +1,258 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
using FluentAssertions;
using StellaOps.Scanner.ChangeTrace.ByteDiff;
namespace StellaOps.Scanner.ChangeTrace.Tests.ByteDiff;
/// <summary>
/// Tests for SectionAnalyzer.
/// </summary>
[Trait("Category", "Unit")]
public sealed class SectionAnalyzerTests
{
private readonly SectionAnalyzer _analyzer = new();
[Fact]
public async Task AnalyzeAsync_EmptyBinary_ReturnsEmptySections()
{
// Arrange
var binary = Array.Empty<byte>();
// Act
var sections = await _analyzer.AnalyzeAsync(binary);
// Assert
sections.Should().BeEmpty();
}
[Fact]
public async Task AnalyzeAsync_TooSmallBinary_ReturnsEmptySections()
{
// Arrange
var binary = new byte[32]; // Less than 64 bytes
// Act
var sections = await _analyzer.AnalyzeAsync(binary);
// Assert
sections.Should().BeEmpty();
}
[Fact]
public async Task AnalyzeAsync_UnknownFormat_ReturnsFallbackSections()
{
// Arrange - Random bytes, not a recognized format
var binary = new byte[1024];
new Random(42).NextBytes(binary);
binary[0] = 0x00; // Ensure not ELF
binary[1] = 0x00; // Ensure not PE
// Act
var sections = await _analyzer.AnalyzeAsync(binary);
// Assert
sections.Should().BeEmpty();
}
[Fact]
public async Task AnalyzeAsync_ElfMagic_RecognizesFormat()
{
// Arrange - Minimal ELF header
var binary = CreateMinimalElfBinary();
// Act
var sections = await _analyzer.AnalyzeAsync(binary);
// Assert
sections.Should().NotBeEmpty();
sections.Should().Contain(s => s.Name.Contains(".text") || s.Name == ".text");
}
[Fact]
public async Task AnalyzeAsync_PeMagic_RecognizesFormat()
{
// Arrange - Minimal PE header
var binary = CreateMinimalPeBinary();
// Act
var sections = await _analyzer.AnalyzeAsync(binary);
// Assert
sections.Should().NotBeEmpty();
sections.Should().Contain(s => s.Name.Contains(".text") || s.Name == ".text");
}
[Fact]
public async Task AnalyzeAsync_MachOMagic32_RecognizesFormat()
{
// Arrange - Minimal Mach-O 32-bit header
var binary = CreateMinimalMachOBinary(is64Bit: false);
// Act
var sections = await _analyzer.AnalyzeAsync(binary);
// Assert
sections.Should().NotBeEmpty();
sections.Should().Contain(s => s.Name.Contains("__TEXT") || s.Name == "__TEXT");
}
[Fact]
public async Task AnalyzeAsync_MachOMagic64_RecognizesFormat()
{
// Arrange - Minimal Mach-O 64-bit header
var binary = CreateMinimalMachOBinary(is64Bit: true);
// Act
var sections = await _analyzer.AnalyzeAsync(binary);
// Assert
sections.Should().NotBeEmpty();
sections.Should().Contain(s => s.Name.Contains("__TEXT") || s.Name == "__TEXT");
}
[Fact]
public async Task AnalyzeAsync_SectionsHaveValidTypes()
{
// Arrange
var binary = CreateMinimalElfBinary();
// Act
var sections = await _analyzer.AnalyzeAsync(binary);
// Assert
sections.Should().AllSatisfy(s =>
{
s.Type.Should().BeOneOf(
SectionType.Code,
SectionType.Data,
SectionType.Bss,
SectionType.Debug,
SectionType.Other);
});
}
[Fact]
public async Task AnalyzeAsync_SectionsHaveValidOffsets()
{
// Arrange
var binary = CreateMinimalElfBinary();
// Act
var sections = await _analyzer.AnalyzeAsync(binary);
// Assert
sections.Should().AllSatisfy(s =>
{
s.Offset.Should().BeGreaterThanOrEqualTo(0);
s.Size.Should().BeGreaterThan(0);
});
}
[Fact]
public async Task AnalyzeAsync_CancellationToken_Respected()
{
// Arrange
var binary = CreateMinimalElfBinary();
var cts = new CancellationTokenSource();
cts.Cancel();
// Act & Assert
var action = async () => await _analyzer.AnalyzeAsync(binary, cts.Token);
await action.Should().ThrowAsync<OperationCanceledException>();
}
// Helper methods to create minimal binary headers
private static byte[] CreateMinimalElfBinary()
{
var binary = new byte[1024];
// ELF magic
binary[0] = 0x7f;
binary[1] = (byte)'E';
binary[2] = (byte)'L';
binary[3] = (byte)'F';
// EI_CLASS = 64-bit
binary[4] = 2;
// EI_DATA = little endian
binary[5] = 1;
// EI_VERSION
binary[6] = 1;
// e_type (2 bytes at offset 16) - ET_EXEC
binary[16] = 2;
binary[17] = 0;
// e_machine (2 bytes at offset 18) - x86_64
binary[18] = 0x3e;
binary[19] = 0;
// For fallback, we don't need fully valid headers
// The analyzer will return fallback sections
return binary;
}
private static byte[] CreateMinimalPeBinary()
{
var binary = new byte[1024];
// DOS header magic
binary[0] = (byte)'M';
binary[1] = (byte)'Z';
// e_lfanew (PE header offset) at offset 60 - point to offset 128
binary[60] = 128;
binary[61] = 0;
binary[62] = 0;
binary[63] = 0;
// PE signature at offset 128
binary[128] = (byte)'P';
binary[129] = (byte)'E';
binary[130] = 0;
binary[131] = 0;
// Number of sections (at offset 134)
binary[134] = 2;
binary[135] = 0;
// Size of optional header (at offset 148)
binary[148] = 0xf0; // 240 bytes (typical for PE32+)
binary[149] = 0;
return binary;
}
private static byte[] CreateMinimalMachOBinary(bool is64Bit)
{
var binary = new byte[1024];
// Mach-O magic (big endian)
if (is64Bit)
{
binary[0] = 0xfe;
binary[1] = 0xed;
binary[2] = 0xfa;
binary[3] = 0xcf; // 64-bit
}
else
{
binary[0] = 0xfe;
binary[1] = 0xed;
binary[2] = 0xfa;
binary[3] = 0xce; // 32-bit
}
// ncmds (number of load commands) at offset 16
binary[16] = 0;
binary[17] = 0;
binary[18] = 0;
binary[19] = 1;
return binary;
}
}

View File

@@ -0,0 +1,403 @@
// -----------------------------------------------------------------------------
// ChangeTraceEvidenceExtensionTests.cs
// Sprint: SPRINT_20260112_200_005_ATTEST_predicate
// Description: Tests for ChangeTraceEvidenceExtension.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.ChangeTrace.CycloneDx;
using StellaOps.Scanner.ChangeTrace.Models;
using ChangeTraceModel = StellaOps.Scanner.ChangeTrace.Models.ChangeTrace;
namespace StellaOps.Scanner.ChangeTrace.Tests.CycloneDx;
/// <summary>
/// Tests for ChangeTraceEvidenceExtension.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ChangeTraceEvidenceExtensionTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly ChangeTraceEvidenceExtension _extension;
public ChangeTraceEvidenceExtensionTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 12, 14, 30, 0, TimeSpan.Zero));
_extension = new ChangeTraceEvidenceExtension(_timeProvider);
}
[Fact]
public void ExportAsStandalone_ValidTrace_ProducesValidCycloneDx()
{
// Arrange
var trace = CreateSampleTrace();
// Act
using var result = _extension.ExportAsStandalone(trace);
// Assert
result.Should().NotBeNull();
result.RootElement.TryGetProperty("bomFormat", out var bomFormat).Should().BeTrue();
bomFormat.GetString().Should().Be("CycloneDX");
result.RootElement.TryGetProperty("specVersion", out var specVersion).Should().BeTrue();
specVersion.GetString().Should().Be("1.7");
result.RootElement.TryGetProperty("extensions", out var extensions).Should().BeTrue();
extensions.GetArrayLength().Should().Be(1);
}
[Fact]
public void ExportAsStandalone_ValidTrace_IncludesChangeTraceExtension()
{
// Arrange
var trace = CreateSampleTrace();
// Act
using var result = _extension.ExportAsStandalone(trace);
// Assert
var extension = result.RootElement
.GetProperty("extensions")
.EnumerateArray()
.First();
extension.TryGetProperty("extensionType", out var extType).Should().BeTrue();
extType.GetString().Should().Be("stella.change-trace");
extension.TryGetProperty("changeTrace", out var changeTrace).Should().BeTrue();
changeTrace.TryGetProperty("schema", out var schema).Should().BeTrue();
schema.GetString().Should().Be("stella.change-trace/1.0");
}
[Fact]
public void ExportAsStandalone_ValidTrace_IncludesSubject()
{
// Arrange
var trace = CreateSampleTrace();
// Act
using var result = _extension.ExportAsStandalone(trace);
// Assert
var changeTrace = result.RootElement
.GetProperty("extensions")
.EnumerateArray()
.First()
.GetProperty("changeTrace");
var subject = changeTrace.GetProperty("subject");
subject.GetProperty("type").GetString().Should().Be("oci.image");
subject.GetProperty("digest").GetString().Should().Contain("sha256:");
subject.GetProperty("purl").GetString().Should().Contain("pkg:oci/");
}
[Fact]
public void ExportAsStandalone_ValidTrace_IncludesSummary()
{
// Arrange
var trace = CreateSampleTrace();
// Act
using var result = _extension.ExportAsStandalone(trace);
// Assert
var changeTrace = result.RootElement
.GetProperty("extensions")
.EnumerateArray()
.First()
.GetProperty("changeTrace");
var summary = changeTrace.GetProperty("summary");
summary.GetProperty("changedPackages").GetInt32().Should().Be(2);
summary.GetProperty("changedSymbols").GetInt32().Should().Be(15);
summary.GetProperty("changedBytes").GetInt64().Should().Be(4096);
summary.GetProperty("riskDelta").GetDouble().Should().BeApproximately(-0.35, 0.01);
summary.GetProperty("verdict").GetString().Should().Be("riskdown");
}
[Fact]
public void ExportAsStandalone_WithDeltas_IncludesDeltas()
{
// Arrange
var trace = CreateSampleTrace();
// Act
using var result = _extension.ExportAsStandalone(trace);
// Assert
var changeTrace = result.RootElement
.GetProperty("extensions")
.EnumerateArray()
.First()
.GetProperty("changeTrace");
var deltas = changeTrace.GetProperty("deltas");
deltas.GetArrayLength().Should().Be(2);
var firstDelta = deltas.EnumerateArray().First();
firstDelta.GetProperty("purl").GetString().Should().Contain("openssl");
firstDelta.GetProperty("changeType").GetString().Should().Be("modified");
}
[Fact]
public void ExportAsStandalone_WithOptions_RespectsMaxDeltas()
{
// Arrange
var trace = CreateSampleTrace();
var options = new ChangeTraceEvidenceOptions { MaxDeltas = 1 };
// Act
using var result = _extension.ExportAsStandalone(trace, options);
// Assert
var changeTrace = result.RootElement
.GetProperty("extensions")
.EnumerateArray()
.First()
.GetProperty("changeTrace");
var deltas = changeTrace.GetProperty("deltas");
deltas.GetArrayLength().Should().Be(1);
changeTrace.GetProperty("truncated").GetBoolean().Should().BeTrue();
changeTrace.GetProperty("totalDeltas").GetInt32().Should().Be(2);
}
[Fact]
public void ExportAsStandalone_WithTrustDelta_IncludesProofSteps()
{
// Arrange
var trace = CreateSampleTrace();
var options = new ChangeTraceEvidenceOptions { IncludeProofSteps = true };
// Act
using var result = _extension.ExportAsStandalone(trace, options);
// Assert
var changeTrace = result.RootElement
.GetProperty("extensions")
.EnumerateArray()
.First()
.GetProperty("changeTrace");
var firstDelta = changeTrace.GetProperty("deltas").EnumerateArray().First();
firstDelta.TryGetProperty("trustDelta", out var trustDelta).Should().BeTrue();
trustDelta.TryGetProperty("proofSteps", out var proofSteps).Should().BeTrue();
proofSteps.GetArrayLength().Should().BeGreaterThan(0);
}
[Fact]
public void EmbedInCycloneDx_ExistingBom_AddsExtension()
{
// Arrange
var trace = CreateSampleTrace();
var existingBom = JsonDocument.Parse("""
{
"bomFormat": "CycloneDX",
"specVersion": "1.7",
"serialNumber": "urn:uuid:12345678-1234-1234-1234-123456789012",
"version": 1,
"components": []
}
""");
// Act
using var result = _extension.EmbedInCycloneDx(existingBom, trace);
// Assert
result.RootElement.TryGetProperty("extensions", out var extensions).Should().BeTrue();
extensions.GetArrayLength().Should().Be(1);
var extension = extensions.EnumerateArray().First();
extension.GetProperty("extensionType").GetString().Should().Be("stella.change-trace");
// Original properties preserved
result.RootElement.GetProperty("bomFormat").GetString().Should().Be("CycloneDX");
result.RootElement.GetProperty("specVersion").GetString().Should().Be("1.7");
existingBom.Dispose();
}
[Fact]
public void EmbedInCycloneDx_BomWithExistingExtensions_AppendsExtension()
{
// Arrange
var trace = CreateSampleTrace();
var existingBom = JsonDocument.Parse("""
{
"bomFormat": "CycloneDX",
"specVersion": "1.7",
"extensions": [
{
"extensionType": "existing.extension",
"data": "test"
}
]
}
""");
// Act
using var result = _extension.EmbedInCycloneDx(existingBom, trace);
// Assert
var extensions = result.RootElement.GetProperty("extensions");
extensions.GetArrayLength().Should().Be(2);
var extensionTypes = extensions.EnumerateArray()
.Select(e => e.GetProperty("extensionType").GetString())
.ToList();
extensionTypes.Should().Contain("existing.extension");
extensionTypes.Should().Contain("stella.change-trace");
existingBom.Dispose();
}
[Fact]
public void ExportAsStandalone_WithCommitment_IncludesCommitment()
{
// Arrange
var trace = CreateSampleTrace();
// Act
using var result = _extension.ExportAsStandalone(trace);
// Assert
var changeTrace = result.RootElement
.GetProperty("extensions")
.EnumerateArray()
.First()
.GetProperty("changeTrace");
changeTrace.TryGetProperty("commitment", out var commitment).Should().BeTrue();
commitment.GetProperty("sha256").GetString().Should().NotBeNullOrEmpty();
commitment.GetProperty("algorithm").GetString().Should().Be("RFC8785+SHA256");
}
[Fact]
public void ExportAsStandalone_WithSymbolDeltas_IncludesSymbols()
{
// Arrange
var trace = CreateSampleTrace();
var options = new ChangeTraceEvidenceOptions { IncludeSymbolDeltas = true };
// Act
using var result = _extension.ExportAsStandalone(trace, options);
// Assert
var changeTrace = result.RootElement
.GetProperty("extensions")
.EnumerateArray()
.First()
.GetProperty("changeTrace");
var firstDelta = changeTrace.GetProperty("deltas").EnumerateArray().First();
firstDelta.TryGetProperty("symbolDeltas", out var symbolDeltas).Should().BeTrue();
symbolDeltas.GetArrayLength().Should().BeGreaterThan(0);
}
private static ChangeTraceModel CreateSampleTrace()
{
return new ChangeTraceModel
{
Schema = "stella.change-trace/1.0",
Subject = new ChangeTraceSubject
{
Type = "oci.image",
Digest = "sha256:abc123def456",
Purl = "pkg:oci/myapp@sha256:abc123def456",
Name = "myapp:latest"
},
Basis = new ChangeTraceBasis
{
ScanId = "scan-2026-01-12T14:30:00Z",
FromScanId = "scan-2026-01-11T10:00:00Z",
ToScanId = "scan-2026-01-12T14:30:00Z",
Policies = ["lattice:default@v3"],
DiffMethod = ["pkg", "symbol"],
EngineVersion = "1.0.0",
AnalyzedAt = new DateTimeOffset(2026, 1, 12, 14, 30, 0, TimeSpan.Zero)
},
Deltas =
[
new PackageDelta
{
Purl = "pkg:deb/debian/openssl@3.0.9",
Name = "openssl",
FromVersion = "3.0.9",
ToVersion = "3.0.9-1+deb12u3",
ChangeType = PackageChangeType.Modified,
Explain = PackageChangeExplanation.VendorBackport,
Evidence = new PackageDeltaEvidence
{
PatchIds = ["DLA-3456-1"],
CveIds = ["CVE-2026-12345"],
SymbolsChanged = 10,
BytesChanged = 2048,
Functions = ["ssl3_get_record", "ssl3_read_bytes"],
Confidence = 0.95
},
TrustDelta = new TrustDelta
{
ReachabilityImpact = ReachabilityImpact.Reduced,
ExploitabilityImpact = ExploitabilityImpact.Down,
Score = -0.35,
BeforeScore = 0.5,
AfterScore = 0.85,
ProofSteps =
[
"CVE-2026-12345 affects ssl3_get_record",
"Function patched in 3.0.9-1+deb12u3",
"Verdict: risk_down (-0.35)"
]
},
SymbolDeltas =
[
new SymbolDelta
{
Name = "ssl3_get_record",
ChangeType = SymbolChangeType.Modified,
FromHash = "abc123",
ToHash = "def456",
Similarity = 0.92
}
]
},
new PackageDelta
{
Purl = "pkg:deb/debian/glibc@2.36",
Name = "glibc",
FromVersion = "2.36-9+deb12u4",
ToVersion = "2.36-9+deb12u5",
ChangeType = PackageChangeType.Modified,
Explain = PackageChangeExplanation.SecurityPatch,
Evidence = new PackageDeltaEvidence
{
SymbolsChanged = 5,
BytesChanged = 2048,
Confidence = 0.88
}
}
],
Summary = new ChangeTraceSummary
{
ChangedPackages = 2,
ChangedSymbols = 15,
ChangedBytes = 4096,
RiskDelta = -0.35,
Verdict = ChangeTraceVerdict.RiskDown,
BeforeRiskScore = 0.5,
AfterRiskScore = 0.85
},
Commitment = new ChangeTraceCommitment
{
Sha256 = "a1b2c3d4e5f6",
Algorithm = "RFC8785+SHA256"
}
};
}
}

View File

@@ -0,0 +1,81 @@
{
"schema": "stella.change-trace/1.0",
"subject": {
"type": "oci.image",
"digest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"purl": "pkg:oci/debian/bookworm@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"name": "debian:bookworm"
},
"basis": {
"scanId": "scan-20260112-backport-libssl",
"fromScanId": "scan-20260111-libssl-3.0.9-1+deb12u2",
"toScanId": "scan-20260112-libssl-3.0.9-1+deb12u3",
"policies": ["lattice:default@v3"],
"diffMethod": ["pkg", "symbol"],
"engineVersion": "1.0.0",
"analyzedAt": "2026-01-12T10:00:00+00:00"
},
"deltas": [
{
"scope": "pkg",
"purl": "pkg:deb/debian/libssl3@3.0.9-1+deb12u3?distro=debian-12",
"name": "libssl3",
"fromVersion": "3.0.9-1+deb12u2",
"toVersion": "3.0.9-1+deb12u3",
"changeType": "Upgraded",
"explain": "VendorBackport",
"evidence": {
"patchIds": ["DSA-5678-1"],
"cveIds": ["CVE-2026-12345"],
"symbolsChanged": 1,
"bytesChanged": 512,
"functions": ["ssl3_get_record"],
"verificationMethod": "CFGHash",
"confidence": 0.98
},
"trustDelta": {
"reachabilityImpact": "Reduced",
"exploitabilityImpact": "Down",
"score": -0.27,
"beforeScore": 0.85,
"afterScore": 0.62,
"proofSteps": [
"CVE-2026-12345 affects ssl3_get_record",
"Function patched in 3.0.9-1+deb12u3",
"CFG match: 0.98 similarity",
"Reachable call paths: 3 -> 0 after patch"
]
},
"symbolDeltas": [
{
"scope": "symbol",
"name": "ssl3_get_record",
"changeType": "Patched",
"fromHash": "sha256:abc123",
"toHash": "sha256:def456",
"sizeDelta": 64,
"cfgBlockDelta": 2,
"similarity": 0.98,
"confidence": 0.95,
"matchMethod": "CFGHash",
"explanation": "Security patch for buffer overflow",
"matchedChunks": [0, 1, 2, 5, 6]
}
],
"byteDeltas": []
}
],
"summary": {
"changedPackages": 1,
"changedSymbols": 1,
"changedBytes": 512,
"riskDelta": -0.27,
"verdict": "RiskDown",
"beforeRiskScore": 0.85,
"afterRiskScore": 0.62
},
"commitment": {
"sha256": "placeholder_to_be_computed",
"algorithm": "RFC8785+SHA256"
}
}

View File

@@ -0,0 +1,120 @@
{
"schema": "stella.change-trace/1.0",
"subject": {
"type": "oci.image",
"digest": "sha256:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321",
"purl": "pkg:oci/myapp@sha256:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321",
"name": "myapp:v2.0.0"
},
"basis": {
"scanId": "scan-20260112-multi-package",
"fromScanId": "scan-20260105-myapp-v1.9.0",
"toScanId": "scan-20260112-myapp-v2.0.0",
"policies": ["lattice:default@v3"],
"diffMethod": ["pkg", "symbol", "byte"],
"engineVersion": "1.0.0",
"analyzedAt": "2026-01-12T14:00:00+00:00"
},
"deltas": [
{
"scope": "pkg",
"purl": "pkg:npm/express@4.19.0",
"name": "express",
"fromVersion": "4.18.2",
"toVersion": "4.19.0",
"changeType": "Upgraded",
"explain": "UpstreamUpgrade",
"evidence": {
"patchIds": [],
"cveIds": ["CVE-2024-29041"],
"symbolsChanged": 5,
"bytesChanged": 2048,
"functions": ["finalhandler", "serve-static"],
"verificationMethod": "SemanticHash",
"confidence": 0.92
},
"trustDelta": {
"reachabilityImpact": "Unchanged",
"exploitabilityImpact": "Down",
"score": -0.15,
"beforeScore": 0.70,
"afterScore": 0.60,
"proofSteps": [
"CVE-2024-29041 path traversal fixed",
"Upstream upgrade from 4.18.2 to 4.19.0"
]
},
"symbolDeltas": [],
"byteDeltas": []
},
{
"scope": "pkg",
"purl": "pkg:npm/lodash@4.17.21",
"name": "lodash",
"fromVersion": "4.17.21",
"toVersion": "4.17.21",
"changeType": "Rebuilt",
"explain": "Rebuild",
"evidence": {
"patchIds": [],
"cveIds": [],
"symbolsChanged": 0,
"bytesChanged": 0,
"functions": [],
"verificationMethod": "InstructionHash",
"confidence": 1.0
},
"trustDelta": {
"reachabilityImpact": "Unchanged",
"exploitabilityImpact": "Unchanged",
"score": 0.0,
"proofSteps": ["Package unchanged"]
},
"symbolDeltas": [],
"byteDeltas": []
},
{
"scope": "pkg",
"purl": "pkg:npm/new-dep@1.0.0",
"name": "new-dep",
"fromVersion": "",
"toVersion": "1.0.0",
"changeType": "Added",
"explain": "NewDependency",
"evidence": {
"patchIds": [],
"cveIds": [],
"symbolsChanged": 12,
"bytesChanged": 8192,
"functions": [],
"verificationMethod": "SemanticHash",
"confidence": 1.0
},
"trustDelta": {
"reachabilityImpact": "Introduced",
"exploitabilityImpact": "Unchanged",
"score": 0.1,
"proofSteps": [
"New dependency added",
"No known vulnerabilities",
"Risk slightly increased due to expanded attack surface"
]
},
"symbolDeltas": [],
"byteDeltas": []
}
],
"summary": {
"changedPackages": 3,
"changedSymbols": 17,
"changedBytes": 10240,
"riskDelta": -0.05,
"verdict": "Neutral",
"beforeRiskScore": 0.70,
"afterRiskScore": 0.66
},
"commitment": {
"sha256": "placeholder_to_be_computed",
"algorithm": "RFC8785+SHA256"
}
}

View File

@@ -0,0 +1,65 @@
{
"schema": "stella.change-trace/1.0",
"subject": {
"type": "oci.image",
"digest": "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"purl": "pkg:oci/ubuntu/jammy@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"name": "ubuntu:22.04"
},
"basis": {
"scanId": "scan-20260112-rebuild-glibc",
"fromScanId": "scan-20260110-glibc-2.35-0ubuntu3.6",
"toScanId": "scan-20260112-glibc-2.35-0ubuntu3.6",
"policies": ["lattice:default@v3"],
"diffMethod": ["pkg", "symbol"],
"engineVersion": "1.0.0",
"analyzedAt": "2026-01-12T11:30:00+00:00"
},
"deltas": [
{
"scope": "pkg",
"purl": "pkg:deb/ubuntu/libc6@2.35-0ubuntu3.6?distro=ubuntu-22.04",
"name": "libc6",
"fromVersion": "2.35-0ubuntu3.6",
"toVersion": "2.35-0ubuntu3.6",
"changeType": "Rebuilt",
"explain": "Rebuild",
"evidence": {
"patchIds": [],
"cveIds": [],
"symbolsChanged": 0,
"bytesChanged": 1024,
"functions": [],
"verificationMethod": "InstructionHash",
"confidence": 0.99
},
"trustDelta": {
"reachabilityImpact": "Unchanged",
"exploitabilityImpact": "Unchanged",
"score": 0.0,
"beforeScore": 0.50,
"afterScore": 0.50,
"proofSteps": [
"Package rebuilt without source changes",
"All symbols match with >0.99 confidence",
"Binary differences attributed to build environment"
]
},
"symbolDeltas": [],
"byteDeltas": []
}
],
"summary": {
"changedPackages": 1,
"changedSymbols": 0,
"changedBytes": 1024,
"riskDelta": 0.0,
"verdict": "Neutral",
"beforeRiskScore": 0.50,
"afterRiskScore": 0.50
},
"commitment": {
"sha256": "placeholder_to_be_computed",
"algorithm": "RFC8785+SHA256"
}
}

View File

@@ -0,0 +1,276 @@
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Scanner.ChangeTrace.Models;
namespace StellaOps.Scanner.ChangeTrace.Tests.Models;
/// <summary>
/// Tests for ChangeTrace model construction and validation.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ChangeTraceModelTests
{
[Fact]
public void ChangeTrace_WithRequiredProperties_CreatesValidInstance()
{
// Arrange & Act
var trace = CreateValidChangeTrace();
// Assert
trace.Schema.Should().Be(ChangeTrace.Models.ChangeTrace.SchemaVersion);
trace.Subject.Should().NotBeNull();
trace.Basis.Should().NotBeNull();
trace.Summary.Should().NotBeNull();
}
[Fact]
public void ChangeTraceSubject_WithAllProperties_SetsCorrectly()
{
// Arrange & Act
var subject = new ChangeTraceSubject
{
Type = "oci.image",
Digest = "sha256:abc123",
Purl = "pkg:oci/myapp@sha256:abc123",
Name = "myapp:latest"
};
// Assert
subject.Type.Should().Be("oci.image");
subject.Digest.Should().Be("sha256:abc123");
subject.Purl.Should().Be("pkg:oci/myapp@sha256:abc123");
subject.Name.Should().Be("myapp:latest");
}
[Fact]
public void ChangeTraceBasis_WithDiffMethods_ContainsAllMethods()
{
// Arrange & Act
var basis = new ChangeTraceBasis
{
ScanId = "scan-123",
FromScanId = "scan-before",
ToScanId = "scan-after",
Policies = ["lattice:default@v3"],
DiffMethod = ["pkg", "symbol", "byte"],
EngineVersion = "1.0.0",
AnalyzedAt = DateTimeOffset.UtcNow
};
// Assert
basis.DiffMethod.Should().HaveCount(3);
basis.DiffMethod.Should().Contain("pkg");
basis.DiffMethod.Should().Contain("symbol");
basis.DiffMethod.Should().Contain("byte");
}
[Fact]
public void PackageDelta_WithNestedDeltas_PreservesHierarchy()
{
// Arrange
var symbolDelta = new SymbolDelta
{
Name = "ssl3_get_record",
ChangeType = SymbolChangeType.Patched,
Similarity = 0.98,
Confidence = 0.95
};
var byteDelta = new ByteDelta
{
Offset = 0x1000,
Size = 256,
FromHash = "sha256:before",
ToHash = "sha256:after",
Section = ".text"
};
// Act
var packageDelta = new PackageDelta
{
Purl = "pkg:deb/debian/libssl3@3.0.9-1+deb12u3",
Name = "libssl3",
FromVersion = "3.0.9-1+deb12u2",
ToVersion = "3.0.9-1+deb12u3",
ChangeType = PackageChangeType.Upgraded,
Explain = PackageChangeExplanation.SecurityPatch,
Evidence = new PackageDeltaEvidence
{
PatchIds = ["DSA-5678-1"],
CveIds = ["CVE-2026-12345"],
SymbolsChanged = 1,
BytesChanged = 256,
Functions = ["ssl3_get_record"],
Confidence = 0.95
},
SymbolDeltas = [symbolDelta],
ByteDeltas = [byteDelta]
};
// Assert
packageDelta.SymbolDeltas.Should().HaveCount(1);
packageDelta.SymbolDeltas[0].Name.Should().Be("ssl3_get_record");
packageDelta.ByteDeltas.Should().HaveCount(1);
packageDelta.ByteDeltas[0].Offset.Should().Be(0x1000);
}
[Fact]
public void TrustDelta_WithProofSteps_ContainsExplanation()
{
// Arrange & Act
var trustDelta = new TrustDelta
{
ReachabilityImpact = ReachabilityImpact.Reduced,
ExploitabilityImpact = ExploitabilityImpact.Down,
Score = -0.27,
BeforeScore = 0.85,
AfterScore = 0.62,
ProofSteps =
[
"CVE-2026-12345 affects ssl3_get_record",
"Function patched in 3.0.9-1+deb12u3",
"CFG match: 0.98 similarity",
"Reachable call paths: 3 -> 0 after patch"
]
};
// Assert
trustDelta.ProofSteps.Should().HaveCount(4);
trustDelta.Score.Should().BeApproximately(-0.27, 0.001);
trustDelta.ReachabilityImpact.Should().Be(ReachabilityImpact.Reduced);
}
[Fact]
public void ChangeTraceSummary_WithRiskDown_HasCorrectVerdict()
{
// Arrange & Act
var summary = new ChangeTraceSummary
{
ChangedPackages = 3,
ChangedSymbols = 58,
ChangedBytes = 12432,
RiskDelta = -0.41,
Verdict = ChangeTraceVerdict.RiskDown,
BeforeRiskScore = 0.75,
AfterRiskScore = 0.44
};
// Assert
summary.Verdict.Should().Be(ChangeTraceVerdict.RiskDown);
summary.RiskDelta.Should().BeLessThan(-0.3);
}
[Fact]
public void ChangeTraceCommitment_HasCorrectAlgorithm()
{
// Arrange & Act
var commitment = new ChangeTraceCommitment
{
Sha256 = "abc123def456"
};
// Assert
commitment.Algorithm.Should().Be("RFC8785+SHA256");
}
[Theory]
[InlineData(PackageChangeType.Added)]
[InlineData(PackageChangeType.Removed)]
[InlineData(PackageChangeType.Modified)]
[InlineData(PackageChangeType.Upgraded)]
[InlineData(PackageChangeType.Downgraded)]
[InlineData(PackageChangeType.Rebuilt)]
public void PackageChangeType_AllValues_AreValid(PackageChangeType changeType)
{
// Arrange
var delta = CreatePackageDelta(changeType);
// Assert
delta.ChangeType.Should().Be(changeType);
}
[Theory]
[InlineData(SymbolChangeType.Unchanged)]
[InlineData(SymbolChangeType.Added)]
[InlineData(SymbolChangeType.Removed)]
[InlineData(SymbolChangeType.Modified)]
[InlineData(SymbolChangeType.Patched)]
public void SymbolChangeType_AllValues_AreValid(SymbolChangeType changeType)
{
// Arrange & Act
var delta = new SymbolDelta
{
Name = "test_function",
ChangeType = changeType,
Similarity = 1.0,
Confidence = 1.0
};
// Assert
delta.ChangeType.Should().Be(changeType);
}
[Theory]
[InlineData(ChangeTraceVerdict.RiskDown)]
[InlineData(ChangeTraceVerdict.Neutral)]
[InlineData(ChangeTraceVerdict.RiskUp)]
[InlineData(ChangeTraceVerdict.Inconclusive)]
public void ChangeTraceVerdict_AllValues_AreValid(ChangeTraceVerdict verdict)
{
// Arrange & Act
var summary = new ChangeTraceSummary
{
ChangedPackages = 0,
ChangedSymbols = 0,
ChangedBytes = 0,
RiskDelta = 0,
Verdict = verdict
};
// Assert
summary.Verdict.Should().Be(verdict);
}
private static ChangeTrace.Models.ChangeTrace CreateValidChangeTrace()
{
return new ChangeTrace.Models.ChangeTrace
{
Subject = new ChangeTraceSubject
{
Type = "oci.image",
Digest = "sha256:abc123"
},
Basis = new ChangeTraceBasis
{
ScanId = "scan-123",
EngineVersion = "1.0.0",
AnalyzedAt = DateTimeOffset.UtcNow
},
Summary = new ChangeTraceSummary
{
ChangedPackages = 0,
ChangedSymbols = 0,
ChangedBytes = 0,
RiskDelta = 0,
Verdict = ChangeTraceVerdict.Neutral
}
};
}
private static PackageDelta CreatePackageDelta(PackageChangeType changeType)
{
return new PackageDelta
{
Purl = "pkg:deb/debian/test@1.0.0",
Name = "test",
FromVersion = "1.0.0",
ToVersion = "1.0.1",
ChangeType = changeType,
Explain = PackageChangeExplanation.Unknown,
Evidence = new PackageDeltaEvidence
{
Confidence = 1.0
}
};
}
}

View File

@@ -0,0 +1,369 @@
using FluentAssertions;
using Moq;
using StellaOps.Scanner.ChangeTrace.Integration;
using StellaOps.Scanner.ChangeTrace.Proofs;
using StellaOps.Scanner.ChangeTrace.Scoring;
namespace StellaOps.Scanner.ChangeTrace.Tests.Proofs;
/// <summary>
/// Tests for LatticeProofGenerator.
/// </summary>
[Trait("Category", "Unit")]
public sealed class LatticeProofGeneratorTests
{
private readonly Mock<IVexLensClient> _vexLensMock;
private readonly LatticeProofGenerator _generator;
public LatticeProofGeneratorTests()
{
_vexLensMock = new Mock<IVexLensClient>();
_generator = new LatticeProofGenerator(_vexLensMock.Object);
}
[Fact]
public async Task GenerateAsync_BasicContext_IncludesVersionChange()
{
// Arrange
var context = new TrustDeltaContext
{
Purl = "pkg:npm/lodash@4.17.21",
FromVersion = "4.17.20",
ToVersion = "4.17.21"
};
// Act
var steps = await _generator.GenerateAsync(context, -0.15);
// Assert
steps.Should().Contain(s => s.Contains("Version changed: 4.17.20 -> 4.17.21"));
}
[Fact]
public async Task GenerateAsync_WithCves_IncludesCveInfo()
{
// Arrange
_vexLensMock
.Setup(x => x.GetAdvisoryAsync("CVE-2026-12345", It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexAdvisoryInfo
{
CveId = "CVE-2026-12345",
AffectedFunctions = ["ssl3_get_record", "ssl3_read_bytes"]
});
var context = new TrustDeltaContext
{
Purl = "pkg:deb/debian/openssl",
FromVersion = "3.0.9",
ToVersion = "3.0.9-1+deb12u3",
CveIds = ["CVE-2026-12345"]
};
// Act
var steps = await _generator.GenerateAsync(context, -0.27);
// Assert
steps.Should().Contain(s => s.Contains("CVE-2026-12345 affects"));
steps.Should().Contain(s => s.Contains("ssl3_get_record"));
}
[Fact]
public async Task GenerateAsync_WithPatchVerification_IncludesConfidence()
{
// Arrange
var context = new TrustDeltaContext
{
Purl = "pkg:test/package",
FromVersion = "1.0.0",
ToVersion = "1.0.1",
PatchVerificationConfidence = 0.95
};
// Act
var steps = await _generator.GenerateAsync(context, -0.2);
// Assert
steps.Should().Contain(s => s.Contains("Patch verified via CFG hash match: 95"));
}
[Fact]
public async Task GenerateAsync_WithModeratePatchConfidence_UsesInstructionHashMethod()
{
// Arrange
var context = new TrustDeltaContext
{
Purl = "pkg:test/package",
FromVersion = "1.0.0",
ToVersion = "1.0.1",
PatchVerificationConfidence = 0.82
};
// Act
var steps = await _generator.GenerateAsync(context, -0.15);
// Assert
steps.Should().Contain(s => s.Contains("instruction hash match"));
}
[Fact]
public async Task GenerateAsync_WithLowPatchConfidence_UsesSectionMatchMethod()
{
// Arrange
var context = new TrustDeltaContext
{
Purl = "pkg:test/package",
FromVersion = "1.0.0",
ToVersion = "1.0.1",
PatchVerificationConfidence = 0.55
};
// Act
var steps = await _generator.GenerateAsync(context, -0.1);
// Assert
steps.Should().Contain(s => s.Contains("section match"));
}
[Fact]
public async Task GenerateAsync_WithSymbolSimilarity_IncludesSimilarity()
{
// Arrange
var context = new TrustDeltaContext
{
Purl = "pkg:test/package",
FromVersion = "1.0.0",
ToVersion = "1.0.1",
SymbolMatchSimilarity = 0.92
};
// Act
var steps = await _generator.GenerateAsync(context, -0.1);
// Assert
steps.Should().Contain(s => s.Contains("Symbol similarity: 92"));
}
[Fact]
public async Task GenerateAsync_WithReachabilityChange_IncludesCallPaths()
{
// Arrange
var context = new TrustDeltaContext
{
Purl = "pkg:test/package",
FromVersion = "1.0.0",
ToVersion = "1.0.1",
ReachableCallPathsBefore = 5,
ReachableCallPathsAfter = 2
};
// Act
var steps = await _generator.GenerateAsync(context, -0.1);
// Assert
steps.Should().Contain(s => s.Contains("Reachable call paths: 5 -> 2"));
}
[Fact]
public async Task GenerateAsync_WithReachabilityEliminated_IndicatesElimination()
{
// Arrange
var context = new TrustDeltaContext
{
Purl = "pkg:test/package",
FromVersion = "1.0.0",
ToVersion = "1.0.1",
ReachableCallPathsBefore = 3,
ReachableCallPathsAfter = 0
};
// Act
var steps = await _generator.GenerateAsync(context, -0.3);
// Assert
steps.Should().Contain(s => s.Contains("eliminated"));
}
[Fact]
public async Task GenerateAsync_WithDsseAttestation_IncludesAttestation()
{
// Arrange
var context = new TrustDeltaContext
{
Purl = "pkg:test/package",
FromVersion = "1.0.0",
ToVersion = "1.0.1",
HasDsseAttestation = true,
IssuerAuthorityScore = 0.85
};
// Act
var steps = await _generator.GenerateAsync(context, -0.15);
// Assert
steps.Should().Contain(s => s.Contains("DSSE attestation present"));
steps.Should().Contain(s => s.Contains("issuer authority: 85"));
}
[Fact]
public async Task GenerateAsync_WithRuntimeConfirmation_IncludesConfirmation()
{
// Arrange
var context = new TrustDeltaContext
{
Purl = "pkg:test/package",
FromVersion = "1.0.0",
ToVersion = "1.0.1",
RuntimeConfirmationConfidence = 0.9
};
// Act
var steps = await _generator.GenerateAsync(context, -0.2);
// Assert
steps.Should().Contain(s => s.Contains("Runtime confirmation: 90"));
}
[Fact]
public async Task GenerateAsync_RiskDown_IncludesCorrectVerdict()
{
// Arrange
var context = new TrustDeltaContext
{
Purl = "pkg:test/package",
FromVersion = "1.0.0",
ToVersion = "1.0.1"
};
// Act
var steps = await _generator.GenerateAsync(context, -0.35);
// Assert
steps.Should().Contain(s => s.Contains("Verdict: risk_down"));
steps.Should().Contain(s => s.Contains("-0.35"));
}
[Fact]
public async Task GenerateAsync_RiskUp_IncludesCorrectVerdict()
{
// Arrange
var context = new TrustDeltaContext
{
Purl = "pkg:test/package",
FromVersion = "1.0.0",
ToVersion = "1.0.1"
};
// Act
var steps = await _generator.GenerateAsync(context, 0.45);
// Assert
steps.Should().Contain(s => s.Contains("Verdict: risk_up"));
steps.Should().Contain(s => s.Contains("+0.45"));
}
[Fact]
public async Task GenerateAsync_Neutral_IncludesCorrectVerdict()
{
// Arrange
var context = new TrustDeltaContext
{
Purl = "pkg:test/package",
FromVersion = "1.0.0",
ToVersion = "1.0.1"
};
// Act
var steps = await _generator.GenerateAsync(context, 0.05);
// Assert
steps.Should().Contain(s => s.Contains("Verdict: neutral"));
}
[Fact]
public async Task GenerateAsync_SameVersion_IndicatesRebuild()
{
// Arrange
var context = new TrustDeltaContext
{
Purl = "pkg:test/package",
FromVersion = "1.0.0",
ToVersion = "1.0.0"
};
// Act
var steps = await _generator.GenerateAsync(context, 0.0);
// Assert
steps.Should().Contain(s => s.Contains("Rebuilt at version 1.0.0"));
}
[Fact]
public async Task GenerateAsync_MultipleCves_LimitsToThree()
{
// Arrange
_vexLensMock
.Setup(x => x.GetAdvisoryAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexAdvisoryInfo
{
CveId = "CVE-2026-XXXXX",
AffectedFunctions = ["some_function"]
});
var context = new TrustDeltaContext
{
Purl = "pkg:test/package",
FromVersion = "1.0.0",
ToVersion = "1.0.1",
CveIds = ["CVE-2026-00001", "CVE-2026-00002", "CVE-2026-00003", "CVE-2026-00004", "CVE-2026-00005"]
};
// Act
var steps = await _generator.GenerateAsync(context, -0.2);
// Assert
steps.Should().Contain(s => s.Contains("and 2 more CVEs"));
}
[Fact]
public async Task GenerateAsync_UnreachableCodePaths_IndicatesUnreachable()
{
// Arrange
var context = new TrustDeltaContext
{
Purl = "pkg:test/package",
FromVersion = "1.0.0",
ToVersion = "1.0.1",
ReachableCallPathsBefore = 0,
ReachableCallPathsAfter = 0
};
// Act
var steps = await _generator.GenerateAsync(context, 0.0);
// Assert
steps.Should().Contain(s => s.Contains("unreachable"));
}
[Fact]
public async Task GenerateAsync_CveNotFound_StillIncludesReference()
{
// Arrange
_vexLensMock
.Setup(x => x.GetAdvisoryAsync("CVE-2026-99999", It.IsAny<CancellationToken>()))
.ReturnsAsync((VexAdvisoryInfo?)null);
var context = new TrustDeltaContext
{
Purl = "pkg:test/package",
FromVersion = "1.0.0",
ToVersion = "1.0.1",
CveIds = ["CVE-2026-99999"]
};
// Act
var steps = await _generator.GenerateAsync(context, -0.1);
// Assert
steps.Should().Contain(s => s.Contains("CVE-2026-99999 referenced"));
}
}

View File

@@ -0,0 +1,365 @@
using FluentAssertions;
using Moq;
using StellaOps.Scanner.ChangeTrace.Integration;
using StellaOps.Scanner.ChangeTrace.Models;
using StellaOps.Scanner.ChangeTrace.Proofs;
using StellaOps.Scanner.ChangeTrace.Scoring;
namespace StellaOps.Scanner.ChangeTrace.Tests.Scoring;
/// <summary>
/// Tests for TrustDeltaCalculator.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TrustDeltaCalculatorTests
{
private readonly Mock<IVexLensClient> _vexLensMock;
private readonly Mock<ILatticeProofGenerator> _proofGeneratorMock;
private readonly TrustDeltaCalculator _calculator;
public TrustDeltaCalculatorTests()
{
_vexLensMock = new Mock<IVexLensClient>();
_proofGeneratorMock = new Mock<ILatticeProofGenerator>();
// Default proof generator returns empty steps
_proofGeneratorMock
.Setup(x => x.GenerateAsync(It.IsAny<TrustDeltaContext>(), It.IsAny<double>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<string> { "Test proof step" });
_calculator = new TrustDeltaCalculator(_vexLensMock.Object, _proofGeneratorMock.Object);
}
[Fact]
public async Task CalculateAsync_BackportScenario_ReturnsNegativeDelta()
{
// Arrange - Before: vulnerable (low trust), After: patched (high trust)
_vexLensMock
.Setup(x => x.GetConsensusAsync("pkg:deb/debian/openssl@3.0.9", "3.0.9", It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexConsensusResult
{
TrustScore = 0.4, // Vulnerable
Confidence = 0.9,
Status = "affected"
});
_vexLensMock
.Setup(x => x.GetConsensusAsync("pkg:deb/debian/openssl@3.0.9", "3.0.9-1+deb12u3", It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexConsensusResult
{
TrustScore = 0.85, // Patched
Confidence = 0.95,
Status = "fixed"
});
var context = new TrustDeltaContext
{
Purl = "pkg:deb/debian/openssl@3.0.9",
FromVersion = "3.0.9",
ToVersion = "3.0.9-1+deb12u3",
CveIds = ["CVE-2026-12345"],
PatchVerificationConfidence = 0.98
};
// Act
var result = await _calculator.CalculateAsync(context);
// Assert
result.Should().NotBeNull();
result.Score.Should().BeLessThan(0, "backport should reduce risk");
result.ExploitabilityImpact.Should().BeOneOf(ExploitabilityImpact.Down, ExploitabilityImpact.Eliminated);
result.ProofSteps.Should().NotBeEmpty();
}
[Fact]
public async Task CalculateAsync_RebuildScenario_ReturnsNeutralDelta()
{
// Arrange - Same trust before and after
_vexLensMock
.Setup(x => x.GetConsensusAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexConsensusResult
{
TrustScore = 0.75,
Confidence = 0.9,
Status = "not_affected"
});
var context = new TrustDeltaContext
{
Purl = "pkg:deb/debian/glibc@2.36",
FromVersion = "2.36-9+deb12u4",
ToVersion = "2.36-9+deb12u4" // Same version, rebuild
};
// Act
var result = await _calculator.CalculateAsync(context);
// Assert
result.Should().NotBeNull();
result.Score.Should().BeInRange(-0.29, 0.29, "rebuild should be neutral");
result.ExploitabilityImpact.Should().Be(ExploitabilityImpact.Unchanged);
}
[Fact]
public async Task CalculateAsync_UpgradeScenario_ReturnsPositiveDelta()
{
// Arrange - Before: safe, After: introduces new vulnerabilities
_vexLensMock
.Setup(x => x.GetConsensusAsync("pkg:npm/lodash@4.17.20", "4.17.20", It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexConsensusResult
{
TrustScore = 0.9,
Confidence = 0.95,
Status = "not_affected"
});
_vexLensMock
.Setup(x => x.GetConsensusAsync("pkg:npm/lodash@4.17.20", "5.0.0-beta", It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexConsensusResult
{
TrustScore = 0.5, // Beta introduces risks
Confidence = 0.7,
Status = "under_investigation"
});
var context = new TrustDeltaContext
{
Purl = "pkg:npm/lodash@4.17.20",
FromVersion = "4.17.20",
ToVersion = "5.0.0-beta"
};
// Act
var result = await _calculator.CalculateAsync(context);
// Assert
result.Should().NotBeNull();
result.Score.Should().BeGreaterThan(0, "upgrade to risky beta increases risk (positive delta)");
}
[Fact]
public async Task CalculateAsync_WithPatchVerificationBonus_IncreasesAfterTrust()
{
// Arrange
_vexLensMock
.Setup(x => x.GetConsensusAsync(It.IsAny<string>(), "1.0.0", It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexConsensusResult { TrustScore = 0.5, Confidence = 0.9, Status = "affected" });
_vexLensMock
.Setup(x => x.GetConsensusAsync(It.IsAny<string>(), "1.0.1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexConsensusResult { TrustScore = 0.5, Confidence = 0.9, Status = "fixed" });
var contextWithBonus = new TrustDeltaContext
{
Purl = "pkg:test/package",
FromVersion = "1.0.0",
ToVersion = "1.0.1",
PatchVerificationConfidence = 0.95,
SymbolMatchSimilarity = 0.92,
HasDsseAttestation = true,
IssuerAuthorityScore = 0.8
};
var contextWithoutBonus = new TrustDeltaContext
{
Purl = "pkg:test/package",
FromVersion = "1.0.0",
ToVersion = "1.0.1"
};
// Act
var resultWithBonus = await _calculator.CalculateAsync(contextWithBonus);
var resultWithoutBonus = await _calculator.CalculateAsync(contextWithoutBonus);
// Assert
resultWithBonus.AfterScore.Should().NotBeNull();
resultWithoutBonus.AfterScore.Should().NotBeNull();
resultWithBonus.AfterScore!.Value.Should().BeGreaterThan(resultWithoutBonus.AfterScore!.Value,
"patch verification bonus should increase after trust");
}
[Fact]
public async Task CalculateAsync_UnreachableCode_ReducesTrust()
{
// Arrange
_vexLensMock
.Setup(x => x.GetConsensusAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexConsensusResult { TrustScore = 0.6, Confidence = 0.9, Status = "affected" });
var reachableContext = new TrustDeltaContext
{
Purl = "pkg:test/package",
FromVersion = "1.0.0",
ToVersion = "1.0.1",
ReachableCallPathsBefore = 5,
ReachableCallPathsAfter = 5
};
var unreachableContext = new TrustDeltaContext
{
Purl = "pkg:test/package",
FromVersion = "1.0.0",
ToVersion = "1.0.1",
ReachableCallPathsBefore = 0, // Unreachable
ReachableCallPathsAfter = 0 // Still unreachable
};
// Act
var reachableResult = await _calculator.CalculateAsync(reachableContext);
var unreachableResult = await _calculator.CalculateAsync(unreachableContext);
// Assert - Both should have reduced trust when unreachable
unreachableResult.BeforeScore.Should().NotBeNull();
reachableResult.BeforeScore.Should().NotBeNull();
unreachableResult.BeforeScore!.Value.Should().BeLessThan(reachableResult.BeforeScore!.Value,
"unreachable code should have lower trust");
}
[Fact]
public async Task CalculateAsync_ReachabilityIntroduced_SetsCorrectImpact()
{
// Arrange
_vexLensMock
.Setup(x => x.GetConsensusAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexConsensusResult { TrustScore = 0.5, Confidence = 0.9, Status = "affected" });
var context = new TrustDeltaContext
{
Purl = "pkg:test/package",
FromVersion = "1.0.0",
ToVersion = "1.0.1",
ReachableCallPathsBefore = 0,
ReachableCallPathsAfter = 3
};
// Act
var result = await _calculator.CalculateAsync(context);
// Assert
result.ReachabilityImpact.Should().Be(ReachabilityImpact.Introduced);
}
[Fact]
public async Task CalculateAsync_ReachabilityEliminated_SetsCorrectImpact()
{
// Arrange
_vexLensMock
.Setup(x => x.GetConsensusAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexConsensusResult { TrustScore = 0.5, Confidence = 0.9, Status = "affected" });
var context = new TrustDeltaContext
{
Purl = "pkg:test/package",
FromVersion = "1.0.0",
ToVersion = "1.0.1",
ReachableCallPathsBefore = 3,
ReachableCallPathsAfter = 0
};
// Act
var result = await _calculator.CalculateAsync(context);
// Assert
result.ReachabilityImpact.Should().Be(ReachabilityImpact.Eliminated);
}
[Fact]
public async Task CalculateAsync_DeltaIsClamped_ToValidRange()
{
// Arrange - Extreme case: very low before, very high after
_vexLensMock
.Setup(x => x.GetConsensusAsync(It.IsAny<string>(), "1.0.0", It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexConsensusResult { TrustScore = 0.01, Confidence = 0.9, Status = "affected" });
_vexLensMock
.Setup(x => x.GetConsensusAsync(It.IsAny<string>(), "1.0.1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexConsensusResult { TrustScore = 1.0, Confidence = 0.99, Status = "not_affected" });
var context = new TrustDeltaContext
{
Purl = "pkg:test/package",
FromVersion = "1.0.0",
ToVersion = "1.0.1"
};
// Act
var result = await _calculator.CalculateAsync(context);
// Assert
result.Score.Should().BeInRange(-1.0, 1.0);
}
[Fact]
public async Task CalculateAggregateAsync_MultiplePackages_ComputesWeightedAverage()
{
// Arrange
_vexLensMock
.Setup(x => x.GetConsensusAsync(It.IsAny<string>(), "1.0.0", It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexConsensusResult { TrustScore = 0.4, Confidence = 0.9, Status = "affected" });
_vexLensMock
.Setup(x => x.GetConsensusAsync(It.IsAny<string>(), "1.0.1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexConsensusResult { TrustScore = 0.8, Confidence = 0.9, Status = "fixed" });
var contexts = new List<TrustDeltaContext>
{
new() { Purl = "pkg:test/a", FromVersion = "1.0.0", ToVersion = "1.0.1" },
new() { Purl = "pkg:test/b", FromVersion = "1.0.0", ToVersion = "1.0.1" },
new() { Purl = "pkg:test/c", FromVersion = "1.0.0", ToVersion = "1.0.1" }
};
// Act
var result = await _calculator.CalculateAggregateAsync(contexts);
// Assert
result.Should().NotBeNull();
result.ProofSteps.Should().Contain(s => s.Contains("Aggregate of 3"));
}
[Fact]
public async Task CalculateAggregateAsync_EmptyList_ReturnsNeutral()
{
// Arrange
var contexts = new List<TrustDeltaContext>();
// Act
var result = await _calculator.CalculateAggregateAsync(contexts);
// Assert
result.Score.Should().Be(0.0);
result.ReachabilityImpact.Should().Be(ReachabilityImpact.Unchanged);
result.ExploitabilityImpact.Should().Be(ExploitabilityImpact.Unchanged);
}
[Fact]
public async Task CalculateAsync_WithCustomOptions_UsesCustomThresholds()
{
// Arrange
_vexLensMock
.Setup(x => x.GetConsensusAsync(It.IsAny<string>(), "1.0.0", It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexConsensusResult { TrustScore = 0.5, Confidence = 0.9, Status = "affected" });
_vexLensMock
.Setup(x => x.GetConsensusAsync(It.IsAny<string>(), "1.0.1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexConsensusResult { TrustScore = 0.6, Confidence = 0.9, Status = "fixed" });
var context = new TrustDeltaContext
{
Purl = "pkg:test/package",
FromVersion = "1.0.0",
ToVersion = "1.0.1"
};
var strictOptions = new TrustDeltaOptions
{
SignificantDeltaThreshold = 0.1 // More strict
};
// Act
var defaultResult = await _calculator.CalculateAsync(context);
var strictResult = await _calculator.CalculateAsync(context, strictOptions);
// Assert - Both should produce same score, but options are used
defaultResult.Score.Should().Be(strictResult.Score);
}
}

View File

@@ -0,0 +1,364 @@
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Canonical.Json;
using StellaOps.Scanner.ChangeTrace.Models;
using StellaOps.Scanner.ChangeTrace.Serialization;
namespace StellaOps.Scanner.ChangeTrace.Tests.Serialization;
/// <summary>
/// Tests for deterministic serialization of change traces.
/// </summary>
[Trait("Category", "Unit")]
public sealed class SerializationDeterminismTests
{
[Fact]
public void SerializeCanonical_SameInput_ProducesSameOutput()
{
// Arrange
var trace1 = CreateTestTrace();
var trace2 = CreateTestTrace();
// Act
var json1 = ChangeTraceSerializer.SerializeCanonical(trace1);
var json2 = ChangeTraceSerializer.SerializeCanonical(trace2);
// Assert
json1.Should().Be(json2);
}
[Fact]
public void SerializeCanonical_DifferentPackageOrder_ProducesSameOutput()
{
// Arrange
var trace1 = CreateTraceWithPackages("pkg:b", "pkg:a", "pkg:c");
var trace2 = CreateTraceWithPackages("pkg:c", "pkg:a", "pkg:b");
// Act
var json1 = ChangeTraceSerializer.SerializeCanonical(trace1);
var json2 = ChangeTraceSerializer.SerializeCanonical(trace2);
// Assert - Both should be sorted by PURL (comparing hashes to avoid FluentAssertions formatting issues with curly braces)
var hash1 = CanonJson.Sha256Hex(System.Text.Encoding.UTF8.GetBytes(json1));
var hash2 = CanonJson.Sha256Hex(System.Text.Encoding.UTF8.GetBytes(json2));
hash1.Should().Be(hash2, "both traces should serialize to identical JSON when sorted");
}
[Fact]
public void SerializeCanonical_DifferentSymbolOrder_ProducesSameOutput()
{
// Arrange
var trace1 = CreateTraceWithSymbols("func_b", "func_a", "func_c");
var trace2 = CreateTraceWithSymbols("func_c", "func_a", "func_b");
// Act
var json1 = ChangeTraceSerializer.SerializeCanonical(trace1);
var json2 = ChangeTraceSerializer.SerializeCanonical(trace2);
// Assert - Both should be sorted by name
json1.Should().Be(json2);
}
[Fact]
public void SerializeCanonical_DifferentByteOrder_ProducesSameOutput()
{
// Arrange
var trace1 = CreateTraceWithBytes(0x3000, 0x1000, 0x2000);
var trace2 = CreateTraceWithBytes(0x1000, 0x2000, 0x3000);
// Act
var json1 = ChangeTraceSerializer.SerializeCanonical(trace1);
var json2 = ChangeTraceSerializer.SerializeCanonical(trace2);
// Assert - Both should be sorted by offset
json1.Should().Be(json2);
}
[Fact]
public void Deserialize_SerializedTrace_RoundTripsCorrectly()
{
// Arrange
var original = CreateTestTrace();
var json = ChangeTraceSerializer.SerializeCanonical(original);
// Act
var deserialized = ChangeTraceSerializer.Deserialize(json);
// Assert
deserialized.Should().NotBeNull();
deserialized!.Schema.Should().Be(original.Schema);
deserialized.Subject.Digest.Should().Be(original.Subject.Digest);
deserialized.Summary.ChangedPackages.Should().Be(original.Summary.ChangedPackages);
}
[Fact]
public void ComputeCommitmentHash_SameTrace_ProducesSameHash()
{
// Arrange
var trace1 = CreateTestTrace();
var trace2 = CreateTestTrace();
// Act
var hash1 = ChangeTraceSerializer.ComputeCommitmentHash(trace1);
var hash2 = ChangeTraceSerializer.ComputeCommitmentHash(trace2);
// Assert
hash1.Should().Be(hash2);
}
[Fact]
public void ComputeCommitmentHash_DifferentTrace_ProducesDifferentHash()
{
// Arrange
var trace1 = CreateTestTrace();
var trace2 = CreateTestTrace() with
{
Summary = new ChangeTraceSummary
{
ChangedPackages = 99,
ChangedSymbols = 0,
ChangedBytes = 0,
RiskDelta = 0,
Verdict = ChangeTraceVerdict.Neutral
}
};
// Act
var hash1 = ChangeTraceSerializer.ComputeCommitmentHash(trace1);
var hash2 = ChangeTraceSerializer.ComputeCommitmentHash(trace2);
// Assert
hash1.Should().NotBe(hash2);
}
[Fact]
public void ComputeCommitmentHash_ExcludesCommitmentField()
{
// Arrange
var trace = CreateTestTrace();
var traceWithCommitment = trace with
{
Commitment = new ChangeTraceCommitment
{
Sha256 = "different_hash"
}
};
// Act
var hash1 = ChangeTraceSerializer.ComputeCommitmentHash(trace);
var hash2 = ChangeTraceSerializer.ComputeCommitmentHash(traceWithCommitment);
// Assert - Hash should be same regardless of commitment value
hash1.Should().Be(hash2);
}
[Fact]
public void ComputeCommitmentHash_ExcludesAttestationField()
{
// Arrange
var trace = CreateTestTrace();
var traceWithAttestation = trace with
{
Attestation = new ChangeTraceAttestationRef
{
PredicateType = "stella.ops/changetrace@v1",
EnvelopeDigest = "sha256:envelope"
}
};
// Act
var hash1 = ChangeTraceSerializer.ComputeCommitmentHash(trace);
var hash2 = ChangeTraceSerializer.ComputeCommitmentHash(traceWithAttestation);
// Assert - Hash should be same regardless of attestation
hash1.Should().Be(hash2);
}
[Fact]
public void VerifyCommitment_WithValidCommitment_ReturnsTrue()
{
// Arrange
var trace = CreateTestTrace();
var hash = ChangeTraceSerializer.ComputeCommitmentHash(trace);
var traceWithCommitment = trace with
{
Commitment = new ChangeTraceCommitment { Sha256 = hash }
};
// Act
var result = ChangeTraceSerializer.VerifyCommitment(traceWithCommitment);
// Assert
result.Should().BeTrue();
}
[Fact]
public void VerifyCommitment_WithInvalidCommitment_ReturnsFalse()
{
// Arrange
var trace = CreateTestTrace() with
{
Commitment = new ChangeTraceCommitment { Sha256 = "invalid_hash" }
};
// Act
var result = ChangeTraceSerializer.VerifyCommitment(trace);
// Assert
result.Should().BeFalse();
}
[Fact]
public void VerifyCommitment_WithNullCommitment_ReturnsFalse()
{
// Arrange
var trace = CreateTestTrace();
// Act
var result = ChangeTraceSerializer.VerifyCommitment(trace);
// Assert
result.Should().BeFalse();
}
[Fact]
public void SerializeCanonicalBytes_ProducesUtf8()
{
// Arrange
var trace = CreateTestTrace();
// Act
var bytes = ChangeTraceSerializer.SerializeCanonicalBytes(trace);
var json = System.Text.Encoding.UTF8.GetString(bytes);
// Assert
bytes.Should().NotBeEmpty();
json.Should().StartWith("{");
json.Should().EndWith("}");
}
[Fact]
public void SerializePretty_ProducesIndentedJson()
{
// Arrange
var trace = CreateTestTrace();
// Act
var json = ChangeTraceSerializer.SerializePretty(trace);
// Assert
json.Should().Contain("\n");
json.Should().Contain(" "); // Indentation
}
[Fact]
public void SerializeCanonical_NullOptionalFields_OmitsFromOutput()
{
// Arrange
var trace = CreateTestTrace();
// Act
var json = ChangeTraceSerializer.SerializeCanonical(trace);
// Assert
json.Should().NotContain("\"commitment\"");
json.Should().NotContain("\"attestation\"");
}
private static ChangeTrace.Models.ChangeTrace CreateTestTrace()
{
return new ChangeTrace.Models.ChangeTrace
{
Subject = new ChangeTraceSubject
{
Type = "oci.image",
Digest = "sha256:abc123def456"
},
Basis = new ChangeTraceBasis
{
ScanId = "scan-123",
FromScanId = "scan-before",
ToScanId = "scan-after",
Policies = ["lattice:default@v3"],
DiffMethod = ["pkg", "symbol"],
EngineVersion = "1.0.0",
AnalyzedAt = new DateTimeOffset(2026, 1, 12, 10, 0, 0, TimeSpan.Zero)
},
Deltas = [],
Summary = new ChangeTraceSummary
{
ChangedPackages = 3,
ChangedSymbols = 58,
ChangedBytes = 12432,
RiskDelta = -0.41,
Verdict = ChangeTraceVerdict.RiskDown
}
};
}
private static ChangeTrace.Models.ChangeTrace CreateTraceWithPackages(params string[] purls)
{
var deltas = purls.Select(purl => new PackageDelta
{
Purl = purl,
Name = purl, // Use PURL as name for deterministic comparison
FromVersion = "1.0.0",
ToVersion = "1.0.1",
ChangeType = PackageChangeType.Modified,
Explain = PackageChangeExplanation.Unknown,
Evidence = new PackageDeltaEvidence { Confidence = 1.0 }
}).ToImmutableArray();
return CreateTestTrace() with { Deltas = deltas };
}
private static ChangeTrace.Models.ChangeTrace CreateTraceWithSymbols(params string[] names)
{
var symbolDeltas = names.Select(name => new SymbolDelta
{
Name = name,
ChangeType = SymbolChangeType.Modified,
Similarity = 0.9,
Confidence = 0.9
}).ToImmutableArray();
var packageDelta = new PackageDelta
{
Purl = "pkg:test/package",
Name = "package",
FromVersion = "1.0.0",
ToVersion = "1.0.1",
ChangeType = PackageChangeType.Modified,
Explain = PackageChangeExplanation.Unknown,
Evidence = new PackageDeltaEvidence { Confidence = 1.0 },
SymbolDeltas = symbolDeltas
};
return CreateTestTrace() with { Deltas = [packageDelta] };
}
private static ChangeTrace.Models.ChangeTrace CreateTraceWithBytes(params long[] offsets)
{
var byteDeltas = offsets.Select(offset => new ByteDelta
{
Offset = offset,
Size = 256,
FromHash = $"sha256:from{offset}",
ToHash = $"sha256:to{offset}"
}).ToImmutableArray();
var packageDelta = new PackageDelta
{
Purl = "pkg:test/package",
Name = "package",
FromVersion = "1.0.0",
ToVersion = "1.0.1",
ChangeType = PackageChangeType.Modified,
Explain = PackageChangeExplanation.Unknown,
Evidence = new PackageDeltaEvidence { Confidence = 1.0 },
ByteDeltas = byteDeltas
};
return CreateTestTrace() with { Deltas = [packageDelta] };
}
}

View File

@@ -0,0 +1,26 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.ChangeTrace/StellaOps.Scanner.ChangeTrace.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="Golden\*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,240 @@
using FluentAssertions;
using StellaOps.Feedser.BinaryAnalysis.Models;
using StellaOps.Scanner.PatchVerification.Models;
using Xunit;
namespace StellaOps.Scanner.PatchVerification.Tests.Models;
[Trait("Category", "Unit")]
public sealed class PatchVerificationEvidenceTests
{
[Fact]
public void ComputeTrustScore_Verified_ReturnsHighScore()
{
// Arrange
var evidence = CreateEvidence(
status: PatchVerificationStatus.Verified,
similarity: 0.95,
confidence: 0.90,
method: FingerprintMethod.CFGHash);
// Act
var score = evidence.ComputeTrustScore();
// Assert
score.Should().BeGreaterThan(0.5);
score.Should().BeLessThanOrEqualTo(1.0);
}
[Fact]
public void ComputeTrustScore_VerifiedWithAttestation_IncludesBonus()
{
// Arrange
var withoutAttestation = CreateEvidence(
status: PatchVerificationStatus.Verified,
similarity: 0.95,
confidence: 0.90,
method: FingerprintMethod.SectionHash);
var withAttestation = CreateEvidence(
status: PatchVerificationStatus.Verified,
similarity: 0.95,
confidence: 0.90,
method: FingerprintMethod.SectionHash,
attestation: new DsseEnvelopeRef("env-1", "key-1", "vendor-x", DateTimeOffset.UtcNow));
// Act
var scoreWithout = withoutAttestation.ComputeTrustScore();
var scoreWith = withAttestation.ComputeTrustScore();
// Assert
scoreWith.Should().BeGreaterThan(scoreWithout);
(scoreWith - scoreWithout).Should().BeApproximately(0.15, 0.01);
}
[Fact]
public void ComputeTrustScore_FunctionLevelMatch_IncludesBonus()
{
// Arrange
var sectionHash = CreateEvidence(
status: PatchVerificationStatus.Verified,
similarity: 0.95,
confidence: 0.90,
method: FingerprintMethod.SectionHash);
var cfgHash = CreateEvidence(
status: PatchVerificationStatus.Verified,
similarity: 0.95,
confidence: 0.90,
method: FingerprintMethod.CFGHash);
// Act
var sectionScore = sectionHash.ComputeTrustScore();
var cfgScore = cfgHash.ComputeTrustScore();
// Assert
cfgScore.Should().BeGreaterThan(sectionScore);
}
[Fact]
public void ComputeTrustScore_Inconclusive_ReturnsLowScore()
{
// Arrange
var evidence = CreateEvidence(
status: PatchVerificationStatus.Inconclusive,
similarity: 0.5,
confidence: 0.3,
method: FingerprintMethod.TLSH);
// Act
var score = evidence.ComputeTrustScore();
// Assert
score.Should().BeLessThan(0.2);
}
[Fact]
public void ComputeTrustScore_NotPatched_ReturnsZero()
{
// Arrange - use TLSH which gives no method bonus
var evidence = CreateEvidence(
status: PatchVerificationStatus.NotPatched,
similarity: 0.1,
confidence: 0.9,
method: FingerprintMethod.TLSH);
// Act
var score = evidence.ComputeTrustScore();
// Assert
score.Should().Be(0.0);
}
[Fact]
public void ComputeTrustScore_NoPatchData_ReturnsZero()
{
// Arrange
var evidence = CreateEvidence(
status: PatchVerificationStatus.NoPatchData,
similarity: 0.0,
confidence: 0.0,
method: FingerprintMethod.TLSH);
// Act
var score = evidence.ComputeTrustScore();
// Assert
score.Should().Be(0.0);
}
[Fact]
public void ComputeTrustScore_ClampedToOne()
{
// Arrange - max possible bonuses
var evidence = CreateEvidence(
status: PatchVerificationStatus.Verified,
similarity: 1.0,
confidence: 1.0,
method: FingerprintMethod.CFGHash,
attestation: new DsseEnvelopeRef("env-1", "key-1", "vendor-x", DateTimeOffset.UtcNow));
// Act
var score = evidence.ComputeTrustScore();
// Assert
score.Should().BeLessThanOrEqualTo(1.0);
}
[Fact]
public void SupportsFixedStatus_Verified_HighConfidence_ReturnsTrue()
{
// Arrange
var evidence = CreateEvidence(
status: PatchVerificationStatus.Verified,
similarity: 0.95,
confidence: 0.85,
method: FingerprintMethod.CFGHash);
// Act & Assert
evidence.SupportsFixedStatus().Should().BeTrue();
evidence.SupportsFixedStatus(0.90).Should().BeFalse();
}
[Fact]
public void SupportsFixedStatus_PartialMatch_ReturnsFalse()
{
// Arrange
var evidence = CreateEvidence(
status: PatchVerificationStatus.PartialMatch,
similarity: 0.70,
confidence: 0.80,
method: FingerprintMethod.SectionHash);
// Act & Assert
evidence.SupportsFixedStatus().Should().BeFalse();
}
[Fact]
public void RequiresManualReview_Inconclusive_ReturnsTrue()
{
// Arrange
var evidence = CreateEvidence(
status: PatchVerificationStatus.Inconclusive,
similarity: 0.5,
confidence: 0.3,
method: FingerprintMethod.TLSH);
// Act & Assert
evidence.RequiresManualReview.Should().BeTrue();
}
[Fact]
public void RequiresManualReview_PartialMatch_ReturnsTrue()
{
// Arrange
var evidence = CreateEvidence(
status: PatchVerificationStatus.PartialMatch,
similarity: 0.70,
confidence: 0.60,
method: FingerprintMethod.SectionHash);
// Act & Assert
evidence.RequiresManualReview.Should().BeTrue();
}
[Fact]
public void RequiresManualReview_Verified_ReturnsFalse()
{
// Arrange
var evidence = CreateEvidence(
status: PatchVerificationStatus.Verified,
similarity: 0.95,
confidence: 0.90,
method: FingerprintMethod.CFGHash);
// Act & Assert
evidence.RequiresManualReview.Should().BeFalse();
}
private static PatchVerificationEvidence CreateEvidence(
PatchVerificationStatus status,
double similarity,
double confidence,
FingerprintMethod method,
DsseEnvelopeRef? attestation = null)
{
return new PatchVerificationEvidence
{
EvidenceId = "pv:test-evidence-id",
CveId = "CVE-2024-12345",
ArtifactPurl = "pkg:rpm/openssl@1.1.1k",
BinaryPath = "/usr/lib64/libssl.so.1.1",
Status = status,
Similarity = similarity,
Confidence = confidence,
Method = method,
Attestation = attestation,
VerifiedAt = DateTimeOffset.UtcNow
};
}
}

View File

@@ -0,0 +1,228 @@
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Feedser.BinaryAnalysis.Models;
using StellaOps.Scanner.PatchVerification.Models;
using Xunit;
namespace StellaOps.Scanner.PatchVerification.Tests.Models;
[Trait("Category", "Unit")]
public sealed class PatchVerificationResultTests
{
[Fact]
public void TotalCvesProcessed_ReturnsCorrectCount()
{
// Arrange
var result = CreateResult(
patched: ["CVE-2024-001", "CVE-2024-002"],
unpatched: ["CVE-2024-003"],
inconclusive: ["CVE-2024-004"],
noPatchData: ["CVE-2024-005", "CVE-2024-006"]);
// Act & Assert
result.TotalCvesProcessed.Should().Be(6);
}
[Fact]
public void PatchedPercentage_ReturnsCorrectPercentage()
{
// Arrange
var result = CreateResult(
patched: ["CVE-2024-001", "CVE-2024-002"],
unpatched: ["CVE-2024-003"],
inconclusive: ["CVE-2024-004"],
noPatchData: []);
// Act & Assert
result.PatchedPercentage.Should().BeApproximately(0.5, 0.001); // 2 out of 4
}
[Fact]
public void PatchedPercentage_EmptyResult_ReturnsZero()
{
// Arrange
var result = PatchVerificationResult.Empty("scan-1", "1.0.0");
// Act & Assert
result.PatchedPercentage.Should().Be(0.0);
}
[Fact]
public void GetEvidenceForCve_ReturnsMatchingEvidence()
{
// Arrange
var evidence = new[]
{
CreateEvidence("CVE-2024-001", "/lib/a.so", 0.9),
CreateEvidence("CVE-2024-001", "/lib/b.so", 0.8),
CreateEvidence("CVE-2024-002", "/lib/c.so", 0.95)
};
var result = new PatchVerificationResult
{
ScanId = "scan-1",
Evidence = evidence.ToImmutableArray(),
PatchedCves = ImmutableHashSet<string>.Empty,
UnpatchedCves = ImmutableHashSet<string>.Empty,
InconclusiveCves = ImmutableHashSet<string>.Empty,
NoPatchDataCves = ImmutableHashSet<string>.Empty,
VerifiedAt = DateTimeOffset.UtcNow,
VerifierVersion = "1.0.0"
};
// Act
var cve1Evidence = result.GetEvidenceForCve("CVE-2024-001").ToList();
var cve2Evidence = result.GetEvidenceForCve("CVE-2024-002").ToList();
// Assert
cve1Evidence.Should().HaveCount(2);
cve2Evidence.Should().HaveCount(1);
}
[Fact]
public void GetEvidenceForBinary_ReturnsMatchingEvidence()
{
// Arrange
var evidence = new[]
{
CreateEvidence("CVE-2024-001", "/lib/a.so", 0.9),
CreateEvidence("CVE-2024-002", "/lib/a.so", 0.8),
CreateEvidence("CVE-2024-003", "/lib/b.so", 0.95)
};
var result = new PatchVerificationResult
{
ScanId = "scan-1",
Evidence = evidence.ToImmutableArray(),
PatchedCves = ImmutableHashSet<string>.Empty,
UnpatchedCves = ImmutableHashSet<string>.Empty,
InconclusiveCves = ImmutableHashSet<string>.Empty,
NoPatchDataCves = ImmutableHashSet<string>.Empty,
VerifiedAt = DateTimeOffset.UtcNow,
VerifierVersion = "1.0.0"
};
// Act
var libAEvidence = result.GetEvidenceForBinary("/lib/a.so").ToList();
// Assert
libAEvidence.Should().HaveCount(2);
}
[Fact]
public void GetBestEvidencePerCve_ReturnsHighestConfidence()
{
// Arrange
var evidence = new[]
{
CreateEvidence("CVE-2024-001", "/lib/a.so", 0.7),
CreateEvidence("CVE-2024-001", "/lib/b.so", 0.9), // Best for CVE-001
CreateEvidence("CVE-2024-002", "/lib/c.so", 0.95)
};
var result = new PatchVerificationResult
{
ScanId = "scan-1",
Evidence = evidence.ToImmutableArray(),
PatchedCves = ImmutableHashSet<string>.Empty,
UnpatchedCves = ImmutableHashSet<string>.Empty,
InconclusiveCves = ImmutableHashSet<string>.Empty,
NoPatchDataCves = ImmutableHashSet<string>.Empty,
VerifiedAt = DateTimeOffset.UtcNow,
VerifierVersion = "1.0.0"
};
// Act
var bestEvidence = result.GetBestEvidencePerCve();
// Assert
bestEvidence.Should().HaveCount(2);
bestEvidence["CVE-2024-001"].Confidence.Should().Be(0.9);
bestEvidence["CVE-2024-002"].Confidence.Should().Be(0.95);
}
[Fact]
public void ComputeAggregateTrustScore_ReturnsAverageScore()
{
// Arrange - Create evidence with known trust scores
var evidence = new[]
{
CreateEvidence("CVE-2024-001", "/lib/a.so", 0.9, PatchVerificationStatus.Verified),
CreateEvidence("CVE-2024-002", "/lib/b.so", 0.8, PatchVerificationStatus.Verified)
};
var result = new PatchVerificationResult
{
ScanId = "scan-1",
Evidence = evidence.ToImmutableArray(),
PatchedCves = new[] { "CVE-2024-001", "CVE-2024-002" }.ToImmutableHashSet(),
UnpatchedCves = ImmutableHashSet<string>.Empty,
InconclusiveCves = ImmutableHashSet<string>.Empty,
NoPatchDataCves = ImmutableHashSet<string>.Empty,
VerifiedAt = DateTimeOffset.UtcNow,
VerifierVersion = "1.0.0"
};
// Act
var aggregateScore = result.ComputeAggregateTrustScore();
// Assert
aggregateScore.Should().BeGreaterThan(0);
}
[Fact]
public void Empty_CreatesValidEmptyResult()
{
// Act
var result = PatchVerificationResult.Empty("scan-123", "1.0.0");
// Assert
result.ScanId.Should().Be("scan-123");
result.VerifierVersion.Should().Be("1.0.0");
result.Evidence.Should().BeEmpty();
result.PatchedCves.Should().BeEmpty();
result.UnpatchedCves.Should().BeEmpty();
result.InconclusiveCves.Should().BeEmpty();
result.NoPatchDataCves.Should().BeEmpty();
result.TotalCvesProcessed.Should().Be(0);
}
private static PatchVerificationResult CreateResult(
IEnumerable<string> patched,
IEnumerable<string> unpatched,
IEnumerable<string> inconclusive,
IEnumerable<string> noPatchData)
{
return new PatchVerificationResult
{
ScanId = "scan-1",
Evidence = Array.Empty<PatchVerificationEvidence>(),
PatchedCves = patched.ToImmutableHashSet(),
UnpatchedCves = unpatched.ToImmutableHashSet(),
InconclusiveCves = inconclusive.ToImmutableHashSet(),
NoPatchDataCves = noPatchData.ToImmutableHashSet(),
VerifiedAt = DateTimeOffset.UtcNow,
VerifierVersion = "1.0.0"
};
}
private static PatchVerificationEvidence CreateEvidence(
string cveId,
string binaryPath,
double confidence,
PatchVerificationStatus status = PatchVerificationStatus.Verified)
{
return new PatchVerificationEvidence
{
EvidenceId = $"pv:{Guid.NewGuid():N}",
CveId = cveId,
ArtifactPurl = "pkg:rpm/test@1.0.0",
BinaryPath = binaryPath,
Status = status,
Similarity = confidence,
Confidence = confidence,
Method = FingerprintMethod.SectionHash,
VerifiedAt = DateTimeOffset.UtcNow
};
}
}

View File

@@ -0,0 +1,341 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Feedser.BinaryAnalysis;
using StellaOps.Feedser.BinaryAnalysis.Models;
using StellaOps.Scanner.PatchVerification.Models;
using StellaOps.Scanner.PatchVerification.Services;
using Xunit;
namespace StellaOps.Scanner.PatchVerification.Tests;
[Trait("Category", "Unit")]
public sealed class PatchVerificationOrchestratorTests
{
private readonly Mock<IBinaryFingerprinter> _mockFingerprinter;
private readonly InMemoryPatchSignatureStore _signatureStore;
private readonly PatchVerificationOrchestrator _orchestrator;
public PatchVerificationOrchestratorTests()
{
_mockFingerprinter = new Mock<IBinaryFingerprinter>();
_mockFingerprinter.Setup(f => f.Method).Returns(FingerprintMethod.SectionHash);
_signatureStore = new InMemoryPatchSignatureStore();
_orchestrator = new PatchVerificationOrchestrator(
[_mockFingerprinter.Object],
_signatureStore,
TimeProvider.System,
NullLogger<PatchVerificationOrchestrator>.Instance);
}
[Fact]
public async Task VerifyAsync_NoPatchData_ReturnsNoPatchDataStatus()
{
// Arrange
var context = CreateContext(["CVE-2024-001"]);
// Act
var result = await _orchestrator.VerifyAsync(context);
// Assert
result.NoPatchDataCves.Should().Contain("CVE-2024-001");
result.PatchedCves.Should().BeEmpty();
}
[Fact]
public async Task VerifyAsync_WithPatchData_MatchFound_ReturnsVerified()
{
// Arrange
await SetupPatchSignature("CVE-2024-001", "/lib/libtest.so");
_mockFingerprinter
.Setup(f => f.MatchAsync(
It.IsAny<string>(),
It.IsAny<BinaryFingerprint>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new FingerprintMatchResult
{
IsMatch = true,
Similarity = 0.95,
Confidence = 0.90,
Method = FingerprintMethod.SectionHash
});
var context = CreateContext(
["CVE-2024-001"],
new Dictionary<string, string> { ["/lib/libtest.so"] = "/tmp/extracted/lib/libtest.so" });
// Act
var result = await _orchestrator.VerifyAsync(context);
// Assert
result.PatchedCves.Should().Contain("CVE-2024-001");
result.Evidence.Should().HaveCount(1);
result.Evidence[0].Status.Should().Be(PatchVerificationStatus.Verified);
}
[Fact]
public async Task VerifyAsync_WithPatchData_NoMatch_ReturnsNotPatched()
{
// Arrange
await SetupPatchSignature("CVE-2024-001", "/lib/libtest.so");
_mockFingerprinter
.Setup(f => f.MatchAsync(
It.IsAny<string>(),
It.IsAny<BinaryFingerprint>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new FingerprintMatchResult
{
IsMatch = false,
Similarity = 0.20,
Confidence = 0.90,
Method = FingerprintMethod.SectionHash
});
var context = CreateContext(
["CVE-2024-001"],
new Dictionary<string, string> { ["/lib/libtest.so"] = "/tmp/extracted/lib/libtest.so" });
// Act
var result = await _orchestrator.VerifyAsync(context);
// Assert
result.UnpatchedCves.Should().Contain("CVE-2024-001");
result.Evidence[0].Status.Should().Be(PatchVerificationStatus.NotPatched);
}
[Fact]
public async Task VerifyAsync_LowConfidence_ReturnsInconclusive()
{
// Arrange
await SetupPatchSignature("CVE-2024-001", "/lib/libtest.so");
_mockFingerprinter
.Setup(f => f.MatchAsync(
It.IsAny<string>(),
It.IsAny<BinaryFingerprint>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new FingerprintMatchResult
{
IsMatch = true,
Similarity = 0.60, // Below threshold
Confidence = 0.50, // Below threshold
Method = FingerprintMethod.SectionHash
});
var context = CreateContext(
["CVE-2024-001"],
new Dictionary<string, string> { ["/lib/libtest.so"] = "/tmp/extracted/lib/libtest.so" });
// Act
var result = await _orchestrator.VerifyAsync(context);
// Assert
result.InconclusiveCves.Should().Contain("CVE-2024-001");
}
[Fact]
public async Task VerifyAsync_MultipleCves_ProcessesAll()
{
// Arrange
await SetupPatchSignature("CVE-2024-001", "/lib/a.so");
await SetupPatchSignature("CVE-2024-002", "/lib/b.so");
_mockFingerprinter
.Setup(f => f.MatchAsync(
It.IsAny<string>(),
It.IsAny<BinaryFingerprint>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new FingerprintMatchResult
{
IsMatch = true,
Similarity = 0.95,
Confidence = 0.90,
Method = FingerprintMethod.SectionHash
});
var context = CreateContext(
["CVE-2024-001", "CVE-2024-002", "CVE-2024-003"],
new Dictionary<string, string>
{
["/lib/a.so"] = "/tmp/a.so",
["/lib/b.so"] = "/tmp/b.so"
});
// Act
var result = await _orchestrator.VerifyAsync(context);
// Assert
result.PatchedCves.Should().HaveCount(2);
result.NoPatchDataCves.Should().Contain("CVE-2024-003");
result.TotalCvesProcessed.Should().Be(3);
}
[Fact]
public async Task VerifyAsync_ContinueOnError_DoesNotAbort()
{
// Arrange
await SetupPatchSignature("CVE-2024-001", "/lib/a.so");
await SetupPatchSignature("CVE-2024-002", "/lib/b.so");
var callCount = 0;
_mockFingerprinter
.Setup(f => f.MatchAsync(
It.IsAny<string>(),
It.IsAny<BinaryFingerprint>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(() =>
{
callCount++;
if (callCount == 1)
{
throw new InvalidOperationException("Simulated error");
}
return new FingerprintMatchResult
{
IsMatch = true,
Similarity = 0.95,
Confidence = 0.90,
Method = FingerprintMethod.SectionHash
};
});
var context = CreateContext(
["CVE-2024-001", "CVE-2024-002"],
new Dictionary<string, string>
{
["/lib/a.so"] = "/tmp/a.so",
["/lib/b.so"] = "/tmp/b.so"
},
new PatchVerificationOptions { ContinueOnError = true });
// Act
var result = await _orchestrator.VerifyAsync(context);
// Assert
result.InconclusiveCves.Should().Contain("CVE-2024-001");
result.PatchedCves.Should().Contain("CVE-2024-002");
}
[Fact]
public async Task HasPatchDataAsync_ReturnsCorrectValue()
{
// Arrange
await SetupPatchSignature("CVE-2024-001", "/lib/test.so");
// Act & Assert
(await _orchestrator.HasPatchDataAsync("CVE-2024-001")).Should().BeTrue();
(await _orchestrator.HasPatchDataAsync("CVE-2024-999")).Should().BeFalse();
}
[Fact]
public async Task GetCvesWithPatchDataAsync_FiltersCorrectly()
{
// Arrange
await SetupPatchSignature("CVE-2024-001", "/lib/a.so");
await SetupPatchSignature("CVE-2024-003", "/lib/c.so");
// Act
var result = await _orchestrator.GetCvesWithPatchDataAsync(
["CVE-2024-001", "CVE-2024-002", "CVE-2024-003"]);
// Assert
result.Should().HaveCount(2);
result.Should().Contain("CVE-2024-001");
result.Should().Contain("CVE-2024-003");
}
[Fact]
public async Task VerifySingleAsync_NoPatchData_ReturnsNoPatchDataEvidence()
{
// Act
var evidence = await _orchestrator.VerifySingleAsync(
"CVE-2024-999",
"/lib/test.so",
"pkg:rpm/test@1.0.0");
// Assert
evidence.Status.Should().Be(PatchVerificationStatus.NoPatchData);
evidence.CveId.Should().Be("CVE-2024-999");
}
[Fact]
public async Task VerifySingleAsync_WithPatchData_ReturnsVerificationResult()
{
// Arrange
await SetupPatchSignature("CVE-2024-001", "/lib/libtest.so");
_mockFingerprinter
.Setup(f => f.MatchAsync(
It.IsAny<string>(),
It.IsAny<BinaryFingerprint>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new FingerprintMatchResult
{
IsMatch = true,
Similarity = 0.95,
Confidence = 0.90,
Method = FingerprintMethod.SectionHash
});
// Act
var evidence = await _orchestrator.VerifySingleAsync(
"CVE-2024-001",
"/tmp/lib/libtest.so",
"pkg:rpm/test@1.0.0");
// Assert
evidence.Status.Should().Be(PatchVerificationStatus.Verified);
evidence.Similarity.Should().Be(0.95);
evidence.Confidence.Should().Be(0.90);
}
private async Task SetupPatchSignature(string cveId, string binaryPath)
{
await _signatureStore.StoreAsync(new PatchSignatureEntry
{
EntryId = Guid.NewGuid().ToString("N"),
CveId = cveId,
Purl = "pkg:rpm/test@1.0.0",
BinaryPath = binaryPath,
PatchedFingerprint = new BinaryFingerprint
{
FingerprintId = $"fp:test:{Guid.NewGuid():N}",
CveId = cveId,
Method = FingerprintMethod.SectionHash,
FingerprintValue = "abc123",
TargetBinary = binaryPath,
Metadata = new FingerprintMetadata
{
Architecture = "x86_64",
Format = "ELF",
HasDebugSymbols = true
},
ExtractedAt = DateTimeOffset.UtcNow,
ExtractorVersion = "1.0.0"
},
IssuerId = "test-vendor",
CreatedAt = DateTimeOffset.UtcNow
});
}
private static PatchVerificationContext CreateContext(
IEnumerable<string> cveIds,
Dictionary<string, string>? binaryPaths = null,
PatchVerificationOptions? options = null)
{
return new PatchVerificationContext
{
ScanId = "test-scan-001",
TenantId = "test-tenant",
ImageDigest = "sha256:abc123",
ArtifactPurl = "pkg:oci/test@sha256:abc123",
CveIds = cveIds.ToList(),
BinaryPaths = binaryPaths ?? new Dictionary<string, string>(),
Options = options ?? new PatchVerificationOptions()
};
}
}

View File

@@ -0,0 +1,132 @@
using FluentAssertions;
using StellaOps.Scanner.PatchVerification.Services;
using Xunit;
namespace StellaOps.Scanner.PatchVerification.Tests.Services;
[Trait("Category", "Unit")]
public sealed class EvidenceIdGeneratorTests
{
[Fact]
public void Generate_SameInputs_ReturnsSameId()
{
// Arrange
const string cveId = "CVE-2024-12345";
const string binaryDigest = "sha256:abc123def456";
const string scanId = "scan-001";
// Act
var id1 = EvidenceIdGenerator.Generate(cveId, binaryDigest, scanId);
var id2 = EvidenceIdGenerator.Generate(cveId, binaryDigest, scanId);
// Assert
id1.Should().Be(id2);
}
[Fact]
public void Generate_DifferentInputs_ReturnsDifferentIds()
{
// Arrange
const string cveId1 = "CVE-2024-12345";
const string cveId2 = "CVE-2024-12346";
const string binaryDigest = "sha256:abc123def456";
const string scanId = "scan-001";
// Act
var id1 = EvidenceIdGenerator.Generate(cveId1, binaryDigest, scanId);
var id2 = EvidenceIdGenerator.Generate(cveId2, binaryDigest, scanId);
// Assert
id1.Should().NotBe(id2);
}
[Fact]
public void Generate_ReturnsCorrectPrefix()
{
// Arrange & Act
var id = EvidenceIdGenerator.Generate("CVE-2024-001", "sha256:xyz", "scan-1");
// Assert
id.Should().StartWith("pv:");
}
[Fact]
public void Generate_ReturnsValidFormat()
{
// Arrange & Act
var id = EvidenceIdGenerator.Generate("CVE-2024-001", "sha256:xyz", "scan-1");
// Assert
id.Should().MatchRegex(@"^pv:[a-f0-9]{32}$");
}
[Fact]
public void Generate_ThrowsOnNullCveId()
{
// Act & Assert
var act = () => EvidenceIdGenerator.Generate(null!, "sha256:xyz", "scan-1");
act.Should().Throw<ArgumentException>();
}
[Fact]
public void Generate_ThrowsOnEmptyBinaryDigest()
{
// Act & Assert
var act = () => EvidenceIdGenerator.Generate("CVE-2024-001", "", "scan-1");
act.Should().Throw<ArgumentException>();
}
[Fact]
public void Generate_ThrowsOnWhitespaceScanId()
{
// Act & Assert
var act = () => EvidenceIdGenerator.Generate("CVE-2024-001", "sha256:xyz", " ");
act.Should().Throw<ArgumentException>();
}
[Fact]
public void GenerateFromPath_SameInputs_ReturnsSameId()
{
// Arrange
const string cveId = "CVE-2024-12345";
const string binaryPath = "/usr/lib/libssl.so.1.1";
const string scanId = "scan-001";
// Act
var id1 = EvidenceIdGenerator.GenerateFromPath(cveId, binaryPath, scanId);
var id2 = EvidenceIdGenerator.GenerateFromPath(cveId, binaryPath, scanId);
// Assert
id1.Should().Be(id2);
}
[Fact]
public void GenerateFromPath_NormalizesPathSeparators()
{
// Arrange
const string cveId = "CVE-2024-12345";
const string scanId = "scan-001";
// Act - Unix vs Windows path separators
var id1 = EvidenceIdGenerator.GenerateFromPath(cveId, "/usr/lib/libssl.so", scanId);
var id2 = EvidenceIdGenerator.GenerateFromPath(cveId, "\\usr\\lib\\libssl.so", scanId);
// Assert - Should produce same ID after normalization
id1.Should().Be(id2);
}
[Fact]
public void GenerateFromPath_DifferentFromDigestBased()
{
// Arrange
const string cveId = "CVE-2024-12345";
const string scanId = "scan-001";
// Act
var digestId = EvidenceIdGenerator.Generate(cveId, "sha256:abc", scanId);
var pathId = EvidenceIdGenerator.GenerateFromPath(cveId, "/usr/lib/abc", scanId);
// Assert - Different generation methods should produce different IDs
digestId.Should().NotBe(pathId);
}
}

View File

@@ -0,0 +1,201 @@
using FluentAssertions;
using StellaOps.Feedser.BinaryAnalysis.Models;
using StellaOps.Scanner.PatchVerification.Models;
using StellaOps.Scanner.PatchVerification.Services;
using Xunit;
namespace StellaOps.Scanner.PatchVerification.Tests.Services;
[Trait("Category", "Unit")]
public sealed class InMemoryPatchSignatureStoreTests
{
[Fact]
public async Task StoreAsync_ThenGetByCveAsync_ReturnsEntry()
{
// Arrange
var store = new InMemoryPatchSignatureStore();
var entry = CreateEntry("CVE-2024-001");
// Act
await store.StoreAsync(entry);
var result = await store.GetByCveAsync("CVE-2024-001");
// Assert
result.Should().HaveCount(1);
result[0].CveId.Should().Be("CVE-2024-001");
}
[Fact]
public async Task GetByCveAsync_NoEntries_ReturnsEmpty()
{
// Arrange
var store = new InMemoryPatchSignatureStore();
// Act
var result = await store.GetByCveAsync("CVE-2024-999");
// Assert
result.Should().BeEmpty();
}
[Fact]
public async Task StoreAsync_MultipleEntriesSameCve_ReturnsAll()
{
// Arrange
var store = new InMemoryPatchSignatureStore();
var entry1 = CreateEntry("CVE-2024-001", "entry-1", "/lib/a.so");
var entry2 = CreateEntry("CVE-2024-001", "entry-2", "/lib/b.so");
// Act
await store.StoreAsync(entry1);
await store.StoreAsync(entry2);
var result = await store.GetByCveAsync("CVE-2024-001");
// Assert
result.Should().HaveCount(2);
}
[Fact]
public async Task StoreAsync_UpdatesExistingEntry()
{
// Arrange
var store = new InMemoryPatchSignatureStore();
var entry1 = CreateEntry("CVE-2024-001", "entry-1", "/lib/a.so");
var entry2 = CreateEntry("CVE-2024-001", "entry-1", "/lib/b.so"); // Same ID, different path
// Act
await store.StoreAsync(entry1);
await store.StoreAsync(entry2);
var result = await store.GetByCveAsync("CVE-2024-001");
// Assert
result.Should().HaveCount(1);
result[0].BinaryPath.Should().Be("/lib/b.so"); // Updated
}
[Fact]
public async Task ExistsAsync_ReturnsTrue_WhenEntryExists()
{
// Arrange
var store = new InMemoryPatchSignatureStore();
await store.StoreAsync(CreateEntry("CVE-2024-001"));
// Act & Assert
(await store.ExistsAsync("CVE-2024-001")).Should().BeTrue();
(await store.ExistsAsync("CVE-2024-999")).Should().BeFalse();
}
[Fact]
public async Task FilterWithPatchDataAsync_ReturnsMatchingCves()
{
// Arrange
var store = new InMemoryPatchSignatureStore();
await store.StoreAsync(CreateEntry("CVE-2024-001"));
await store.StoreAsync(CreateEntry("CVE-2024-003"));
var cves = new[] { "CVE-2024-001", "CVE-2024-002", "CVE-2024-003" };
// Act
var result = await store.FilterWithPatchDataAsync(cves);
// Assert
result.Should().HaveCount(2);
result.Should().Contain("CVE-2024-001");
result.Should().Contain("CVE-2024-003");
result.Should().NotContain("CVE-2024-002");
}
[Fact]
public async Task GetByCveAndPurlAsync_FiltersCorrectly()
{
// Arrange
var store = new InMemoryPatchSignatureStore();
await store.StoreAsync(CreateEntry("CVE-2024-001", "entry-1", "/lib/a.so", "pkg:rpm/openssl@1.1.1"));
await store.StoreAsync(CreateEntry("CVE-2024-001", "entry-2", "/lib/b.so", "pkg:deb/openssl@1.1.1"));
// Act
var rpmResult = await store.GetByCveAndPurlAsync("CVE-2024-001", "pkg:rpm/*");
var debResult = await store.GetByCveAndPurlAsync("CVE-2024-001", "pkg:deb/*");
// Assert
rpmResult.Should().HaveCount(1);
debResult.Should().HaveCount(1);
}
[Fact]
public async Task GetByCveAndPurlAsync_ExactMatch()
{
// Arrange
var store = new InMemoryPatchSignatureStore();
await store.StoreAsync(CreateEntry("CVE-2024-001", "entry-1", "/lib/a.so", "pkg:rpm/openssl@1.1.1"));
// Act
var exactMatch = await store.GetByCveAndPurlAsync("CVE-2024-001", "pkg:rpm/openssl@1.1.1");
var noMatch = await store.GetByCveAndPurlAsync("CVE-2024-001", "pkg:rpm/openssl@2.0.0");
// Assert
exactMatch.Should().HaveCount(1);
noMatch.Should().BeEmpty();
}
[Fact]
public void Clear_RemovesAllEntries()
{
// Arrange
var store = new InMemoryPatchSignatureStore();
store.StoreAsync(CreateEntry("CVE-2024-001")).Wait();
store.StoreAsync(CreateEntry("CVE-2024-002")).Wait();
// Act
store.Clear();
// Assert
store.Count.Should().Be(0);
}
[Fact]
public async Task Count_ReturnsCorrectTotal()
{
// Arrange
var store = new InMemoryPatchSignatureStore();
await store.StoreAsync(CreateEntry("CVE-2024-001", "entry-1"));
await store.StoreAsync(CreateEntry("CVE-2024-001", "entry-2"));
await store.StoreAsync(CreateEntry("CVE-2024-002", "entry-3"));
// Assert
store.Count.Should().Be(3);
}
private static PatchSignatureEntry CreateEntry(
string cveId,
string? entryId = null,
string binaryPath = "/lib/libtest.so",
string purl = "pkg:rpm/test@1.0.0")
{
return new PatchSignatureEntry
{
EntryId = entryId ?? Guid.NewGuid().ToString("N"),
CveId = cveId,
Purl = purl,
BinaryPath = binaryPath,
PatchedFingerprint = new BinaryFingerprint
{
FingerprintId = "fp:test:abc123",
CveId = cveId,
Method = FingerprintMethod.SectionHash,
FingerprintValue = "abc123def456",
TargetBinary = binaryPath,
Metadata = new FingerprintMetadata
{
Architecture = "x86_64",
Format = "ELF",
HasDebugSymbols = true
},
ExtractedAt = DateTimeOffset.UtcNow,
ExtractorVersion = "1.0.0"
},
IssuerId = "test-issuer",
CreatedAt = DateTimeOffset.UtcNow
};
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Moq" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.PatchVerification/StellaOps.Scanner.PatchVerification.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
using System.Collections.Immutable;
using StellaOps.Scanner.CallGraph;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
using StellaOps.Scanner.ReachabilityDrift;
using StellaOps.Scanner.ReachabilityDrift.Services;
using Xunit;

View File

@@ -12,7 +12,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using Moq;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
using StellaOps.Scanner.ReachabilityDrift.Attestation;
using Xunit;

View File

@@ -1,5 +1,6 @@
using System.Collections.Immutable;
using StellaOps.Scanner.CallGraph;
using StellaOps.Scanner.Contracts;
using StellaOps.Scanner.ReachabilityDrift;
using StellaOps.Scanner.ReachabilityDrift.Services;
using Xunit;
@@ -181,6 +182,6 @@ public sealed class DriftCauseExplainerTests
IsEntrypoint: false,
EntrypointType: null,
IsSink: true,
SinkCategory: Reachability.SinkCategory.CmdExec);
SinkCategory: SinkCategory.CmdExec);
}

View File

@@ -1,6 +1,6 @@
using System.Collections.Immutable;
using StellaOps.Scanner.CallGraph;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
using StellaOps.Scanner.ReachabilityDrift;
using StellaOps.Scanner.ReachabilityDrift.Services;
using Xunit;

View File

@@ -1,6 +1,6 @@
using System.Collections.Immutable;
using StellaOps.Scanner.CallGraph;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
using StellaOps.Scanner.ReachabilityDrift;
using StellaOps.Scanner.ReachabilityDrift.Services;
using Xunit;

View File

@@ -21,6 +21,7 @@
<ItemGroup>
<ProjectReference Include="..\\..\\__Libraries\\StellaOps.Scanner.ReachabilityDrift\\StellaOps.Scanner.ReachabilityDrift.csproj" />
<ProjectReference Include="..\\..\\__Libraries\\StellaOps.Scanner.CallGraph\\StellaOps.Scanner.CallGraph.csproj" />
<ProjectReference Include="..\\..\\__Libraries\\StellaOps.Scanner.Contracts\\StellaOps.Scanner.Contracts.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>