release orchestrator v1 draft and build fixes
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
using StellaOps.Scanner.CallGraph.DotNet;
|
||||
using StellaOps.Scanner.Contracts;
|
||||
using Xunit;
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
[
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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] };
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using StellaOps.Scanner.Contracts;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
using Xunit;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user