- Implemented comprehensive tests for verdict artifact generation to ensure deterministic outputs across various scenarios, including identical inputs, parallel execution, and change ordering. - Created helper methods for generating sample verdict inputs and computing canonical hashes. - Added tests to validate the stability of canonical hashes, proof spine ordering, and summary statistics. - Introduced a new PowerShell script to update SHA256 sums for files, ensuring accurate hash generation and file integrity checks.
1166 lines
38 KiB
C#
1166 lines
38 KiB
C#
// -----------------------------------------------------------------------------
|
|
// ReachabilityEvidenceDeterminismTests.cs
|
|
// Sprint: SPRINT_5100_0009_0001 - Scanner Module Test Implementation
|
|
// Task: SCANNER-5100-008 - Expand determinism tests: reachability evidence hash stable
|
|
// Description: Tests to validate reachability evidence generation determinism
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using System.Text;
|
|
using FluentAssertions;
|
|
using StellaOps.Canonical.Json;
|
|
using StellaOps.Scanner.Evidence.Models;
|
|
using StellaOps.Scanner.Reachability;
|
|
using StellaOps.Scanner.Reachability.Attestation;
|
|
using StellaOps.Scanner.Reachability.Ordering;
|
|
using StellaOps.Scanner.Reachability.Witnesses;
|
|
using StellaOps.Testing.Determinism;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.Integration.Determinism;
|
|
|
|
/// <summary>
|
|
/// Determinism validation tests for reachability evidence generation.
|
|
/// Ensures identical inputs produce identical reachability evidence across:
|
|
/// - Multiple runs with frozen time
|
|
/// - Parallel execution
|
|
/// - Path ordering
|
|
/// - Confidence calculation
|
|
/// </summary>
|
|
public class ReachabilityEvidenceDeterminismTests
|
|
{
|
|
#region Basic Determinism Tests
|
|
|
|
[Fact]
|
|
public void ReachabilityEvidence_WithIdenticalInput_ProducesDeterministicOutput()
|
|
{
|
|
// Arrange
|
|
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
|
|
var input = CreateSampleReachabilityInput();
|
|
|
|
// Act - Generate evidence multiple times
|
|
var evidence1 = GenerateReachabilityEvidence(input, frozenTime);
|
|
var evidence2 = GenerateReachabilityEvidence(input, frozenTime);
|
|
var evidence3 = GenerateReachabilityEvidence(input, frozenTime);
|
|
|
|
// Serialize to canonical JSON
|
|
var json1 = CanonJson.Serialize(evidence1);
|
|
var json2 = CanonJson.Serialize(evidence2);
|
|
var json3 = CanonJson.Serialize(evidence3);
|
|
|
|
// Assert - All outputs should be identical
|
|
json1.Should().Be(json2);
|
|
json2.Should().Be(json3);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReachabilityEvidence_CanonicalHash_IsStable()
|
|
{
|
|
// Arrange
|
|
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
|
|
var input = CreateSampleReachabilityInput();
|
|
|
|
// Act - Generate evidence and compute canonical hash twice
|
|
var evidence1 = GenerateReachabilityEvidence(input, frozenTime);
|
|
var hash1 = ComputeCanonicalHash(evidence1);
|
|
|
|
var evidence2 = GenerateReachabilityEvidence(input, frozenTime);
|
|
var hash2 = ComputeCanonicalHash(evidence2);
|
|
|
|
// Assert
|
|
hash1.Should().Be(hash2, "Same input should produce same canonical hash");
|
|
hash1.Should().MatchRegex("^[0-9a-f]{64}$");
|
|
}
|
|
|
|
[Fact]
|
|
public void ReachabilityEvidence_DeterminismManifest_CanBeCreated()
|
|
{
|
|
// Arrange
|
|
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
|
|
var input = CreateSampleReachabilityInput();
|
|
var evidence = GenerateReachabilityEvidence(input, frozenTime);
|
|
var evidenceBytes = Encoding.UTF8.GetBytes(CanonJson.Serialize(evidence));
|
|
|
|
var artifactInfo = new ArtifactInfo
|
|
{
|
|
Type = "reachability-evidence",
|
|
Name = "test-finding-reachability",
|
|
Version = "1.0.0",
|
|
Format = "reachability-evidence@1.0"
|
|
};
|
|
|
|
var toolchain = new ToolchainInfo
|
|
{
|
|
Platform = ".NET 10.0",
|
|
Components = new[]
|
|
{
|
|
new ComponentInfo { Name = "StellaOps.Scanner.Reachability", Version = "1.0.0" }
|
|
}
|
|
};
|
|
|
|
// Act - Create determinism manifest
|
|
var manifest = DeterminismManifestWriter.CreateManifest(
|
|
evidenceBytes,
|
|
artifactInfo,
|
|
toolchain);
|
|
|
|
// Assert
|
|
manifest.SchemaVersion.Should().Be("1.0");
|
|
manifest.Artifact.Format.Should().Be("reachability-evidence@1.0");
|
|
manifest.CanonicalHash.Algorithm.Should().Be("SHA-256");
|
|
manifest.CanonicalHash.Value.Should().MatchRegex("^[0-9a-f]{64}$");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReachabilityEvidence_ParallelGeneration_ProducesDeterministicOutput()
|
|
{
|
|
// Arrange
|
|
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
|
|
var input = CreateSampleReachabilityInput();
|
|
|
|
// Act - Generate in parallel 20 times
|
|
var tasks = Enumerable.Range(0, 20)
|
|
.Select(_ => Task.Run(() => CanonJson.Serialize(GenerateReachabilityEvidence(input, frozenTime))))
|
|
.ToArray();
|
|
|
|
var evidences = await Task.WhenAll(tasks);
|
|
|
|
// Assert - All outputs should be identical
|
|
evidences.Should().AllBe(evidences[0]);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Path Ordering Tests
|
|
|
|
[Fact]
|
|
public void ReachabilityEvidence_PathsAreDeterministicallyOrdered()
|
|
{
|
|
// Arrange - Create input with multiple paths in random order
|
|
var paths = new[]
|
|
{
|
|
CreatePath("path-c", "moduleC.dll"),
|
|
CreatePath("path-a", "moduleA.dll"),
|
|
CreatePath("path-b", "moduleB.dll")
|
|
};
|
|
|
|
var input = new ReachabilityInput
|
|
{
|
|
Result = "reachable",
|
|
Confidence = 0.95,
|
|
Paths = paths,
|
|
GraphDigest = "sha256:testgraph123"
|
|
};
|
|
|
|
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
|
|
|
|
// Act
|
|
var evidence1 = GenerateReachabilityEvidence(input, frozenTime);
|
|
var evidence2 = GenerateReachabilityEvidence(input, frozenTime);
|
|
|
|
// Assert - Paths should be in deterministic order
|
|
var json1 = CanonJson.Serialize(evidence1);
|
|
var json2 = CanonJson.Serialize(evidence2);
|
|
json1.Should().Be(json2);
|
|
|
|
// Verify paths are sorted by PathId
|
|
for (int i = 1; i < evidence1.Paths.Count; i++)
|
|
{
|
|
string.CompareOrdinal(evidence1.Paths[i - 1].PathId, evidence1.Paths[i].PathId)
|
|
.Should().BeLessOrEqualTo(0, "Paths should be sorted by PathId");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ReachabilityEvidence_StepsWithinPathsAreDeterministicallyOrdered()
|
|
{
|
|
// Arrange - Create path with steps
|
|
var steps = new[]
|
|
{
|
|
CreateStep("step-3", "file3.cs", 30),
|
|
CreateStep("step-1", "file1.cs", 10),
|
|
CreateStep("step-2", "file2.cs", 20)
|
|
};
|
|
|
|
var input = new ReachabilityInput
|
|
{
|
|
Result = "reachable",
|
|
Confidence = 0.85,
|
|
Paths = new[] { new PathInput { PathId = "path-1", Steps = steps } },
|
|
GraphDigest = "sha256:testgraph456"
|
|
};
|
|
|
|
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
|
|
|
|
// Act
|
|
var evidence1 = GenerateReachabilityEvidence(input, frozenTime);
|
|
var evidence2 = GenerateReachabilityEvidence(input, frozenTime);
|
|
|
|
// Assert
|
|
var json1 = CanonJson.Serialize(evidence1);
|
|
var json2 = CanonJson.Serialize(evidence2);
|
|
json1.Should().Be(json2);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Confidence Calculation Tests
|
|
|
|
[Fact]
|
|
public void ReachabilityEvidence_ConfidenceIsStable()
|
|
{
|
|
// Arrange
|
|
var input = CreateSampleReachabilityInput();
|
|
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
|
|
|
|
// Act - Generate multiple times
|
|
var confidences = Enumerable.Range(0, 10)
|
|
.Select(_ => GenerateReachabilityEvidence(input, frozenTime).Confidence)
|
|
.ToList();
|
|
|
|
// Assert - All confidence values should be identical
|
|
confidences.Should().AllSatisfy(c => c.Should().Be(confidences[0]));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0.0)]
|
|
[InlineData(0.5)]
|
|
[InlineData(1.0)]
|
|
[InlineData(0.123456789)]
|
|
public void ReachabilityEvidence_ConfidencePreservesFullPrecision(double confidence)
|
|
{
|
|
// Arrange
|
|
var input = new ReachabilityInput
|
|
{
|
|
Result = "reachable",
|
|
Confidence = confidence,
|
|
Paths = Array.Empty<PathInput>(),
|
|
GraphDigest = "sha256:test"
|
|
};
|
|
|
|
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
|
|
|
|
// Act
|
|
var evidence = GenerateReachabilityEvidence(input, frozenTime);
|
|
|
|
// Assert
|
|
evidence.Confidence.Should().Be(confidence);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Graph Digest Tests
|
|
|
|
[Fact]
|
|
public void ReachabilityEvidence_GraphDigestIsPreserved()
|
|
{
|
|
// Arrange
|
|
var expectedDigest = "sha256:abc123def456789";
|
|
var input = new ReachabilityInput
|
|
{
|
|
Result = "reachable",
|
|
Confidence = 0.9,
|
|
Paths = Array.Empty<PathInput>(),
|
|
GraphDigest = expectedDigest
|
|
};
|
|
|
|
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
|
|
|
|
// Act
|
|
var evidence = GenerateReachabilityEvidence(input, frozenTime);
|
|
|
|
// Assert
|
|
evidence.GraphDigest.Should().Be(expectedDigest);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReachabilityEvidence_DifferentGraphDigest_ProducesDifferentHash()
|
|
{
|
|
// Arrange
|
|
var input1 = CreateSampleReachabilityInput() with { GraphDigest = "sha256:graph1" };
|
|
var input2 = CreateSampleReachabilityInput() with { GraphDigest = "sha256:graph2" };
|
|
|
|
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
|
|
|
|
// Act
|
|
var hash1 = ComputeCanonicalHash(GenerateReachabilityEvidence(input1, frozenTime));
|
|
var hash2 = ComputeCanonicalHash(GenerateReachabilityEvidence(input2, frozenTime));
|
|
|
|
// Assert
|
|
hash1.Should().NotBe(hash2);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Result Status Tests
|
|
|
|
[Theory]
|
|
[InlineData("reachable")]
|
|
[InlineData("unreachable")]
|
|
[InlineData("unknown")]
|
|
[InlineData("partial")]
|
|
public void ReachabilityEvidence_ResultStatusIsPreserved(string result)
|
|
{
|
|
// Arrange
|
|
var input = new ReachabilityInput
|
|
{
|
|
Result = result,
|
|
Confidence = 0.8,
|
|
Paths = Array.Empty<PathInput>(),
|
|
GraphDigest = "sha256:test"
|
|
};
|
|
|
|
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
|
|
|
|
// Act
|
|
var evidence = GenerateReachabilityEvidence(input, frozenTime);
|
|
|
|
// Assert
|
|
evidence.Result.Should().Be(result);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Empty/Edge Case Tests
|
|
|
|
[Fact]
|
|
public void ReachabilityEvidence_EmptyPaths_ProducesDeterministicOutput()
|
|
{
|
|
// Arrange
|
|
var input = new ReachabilityInput
|
|
{
|
|
Result = "unreachable",
|
|
Confidence = 1.0,
|
|
Paths = Array.Empty<PathInput>(),
|
|
GraphDigest = "sha256:emptygraph"
|
|
};
|
|
|
|
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
|
|
|
|
// Act
|
|
var hash1 = ComputeCanonicalHash(GenerateReachabilityEvidence(input, frozenTime));
|
|
var hash2 = ComputeCanonicalHash(GenerateReachabilityEvidence(input, frozenTime));
|
|
|
|
// Assert
|
|
hash1.Should().Be(hash2);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReachabilityEvidence_ManyPaths_ProducesDeterministicOutput()
|
|
{
|
|
// Arrange - Create 100 paths
|
|
var paths = Enumerable.Range(0, 100)
|
|
.Select(i => CreatePath($"path-{i:D3}", $"module{i}.dll"))
|
|
.ToArray();
|
|
|
|
var input = new ReachabilityInput
|
|
{
|
|
Result = "reachable",
|
|
Confidence = 0.75,
|
|
Paths = paths,
|
|
GraphDigest = "sha256:largegraph"
|
|
};
|
|
|
|
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
|
|
|
|
// Act
|
|
var hash1 = ComputeCanonicalHash(GenerateReachabilityEvidence(input, frozenTime));
|
|
var hash2 = ComputeCanonicalHash(GenerateReachabilityEvidence(input, frozenTime));
|
|
|
|
// Assert
|
|
hash1.Should().Be(hash2);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Helper Methods
|
|
|
|
private static ReachabilityInput CreateSampleReachabilityInput()
|
|
{
|
|
return new ReachabilityInput
|
|
{
|
|
Result = "reachable",
|
|
Confidence = 0.92,
|
|
Paths = new[]
|
|
{
|
|
CreatePath("path-main", "MainModule.dll"),
|
|
CreatePath("path-helper", "HelperModule.dll")
|
|
},
|
|
GraphDigest = "sha256:sampledigest123456789"
|
|
};
|
|
}
|
|
|
|
private static PathInput CreatePath(string pathId, string moduleName)
|
|
{
|
|
return new PathInput
|
|
{
|
|
PathId = pathId,
|
|
Steps = new[]
|
|
{
|
|
CreateStep($"{pathId}-entry", $"{moduleName}/entry.cs", 10),
|
|
CreateStep($"{pathId}-middle", $"{moduleName}/middle.cs", 50),
|
|
CreateStep($"{pathId}-sink", $"{moduleName}/sink.cs", 100)
|
|
}
|
|
};
|
|
}
|
|
|
|
private static StepInput CreateStep(string node, string file, int line)
|
|
{
|
|
return new StepInput
|
|
{
|
|
Node = node,
|
|
FileHash = $"sha256:{node}hash",
|
|
Lines = new[] { line, line + 5 }
|
|
};
|
|
}
|
|
|
|
private static ReachabilityEvidence GenerateReachabilityEvidence(ReachabilityInput input, DateTimeOffset timestamp)
|
|
{
|
|
// Sort paths deterministically by PathId
|
|
var sortedPaths = input.Paths
|
|
.OrderBy(p => p.PathId, StringComparer.Ordinal)
|
|
.Select(p => new ReachabilityPath
|
|
{
|
|
PathId = p.PathId,
|
|
Steps = p.Steps
|
|
.OrderBy(s => s.Node, StringComparer.Ordinal)
|
|
.Select(s => new ReachabilityStep
|
|
{
|
|
Node = s.Node,
|
|
FileHash = s.FileHash,
|
|
Lines = s.Lines
|
|
})
|
|
.ToList()
|
|
})
|
|
.ToList();
|
|
|
|
return new ReachabilityEvidence
|
|
{
|
|
Result = input.Result,
|
|
Confidence = input.Confidence,
|
|
Paths = sortedPaths,
|
|
GraphDigest = input.GraphDigest
|
|
};
|
|
}
|
|
|
|
private static string ComputeCanonicalHash(ReachabilityEvidence evidence)
|
|
{
|
|
var json = CanonJson.Serialize(evidence);
|
|
return CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(json));
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region CanonicalGraph Determinism Tests (Real Types)
|
|
|
|
[Fact]
|
|
public void CanonicalGraph_WithIdenticalInput_ProducesDeterministicOutput()
|
|
{
|
|
// Arrange
|
|
var graph = CreateSampleRichGraph();
|
|
var orderer = new DeterministicGraphOrderer();
|
|
|
|
// Act - Canonicalize multiple times
|
|
var canonical1 = orderer.Canonicalize(graph, GraphOrderingStrategy.TopologicalLexicographic);
|
|
var canonical2 = orderer.Canonicalize(graph, GraphOrderingStrategy.TopologicalLexicographic);
|
|
var canonical3 = orderer.Canonicalize(graph, GraphOrderingStrategy.TopologicalLexicographic);
|
|
|
|
// Assert - All content hashes should be identical
|
|
canonical1.ContentHash.Should().Be(canonical2.ContentHash);
|
|
canonical2.ContentHash.Should().Be(canonical3.ContentHash);
|
|
}
|
|
|
|
[Fact]
|
|
public void CanonicalGraph_ContentHash_IsStable()
|
|
{
|
|
// Arrange
|
|
var graph = CreateSampleRichGraph();
|
|
var orderer = new DeterministicGraphOrderer();
|
|
|
|
// Act
|
|
var canonical1 = orderer.Canonicalize(graph);
|
|
var canonical2 = orderer.Canonicalize(graph);
|
|
|
|
// Assert
|
|
canonical1.ContentHash.Should().Be(canonical2.ContentHash);
|
|
canonical1.ContentHash.Should().StartWith("sha256:");
|
|
canonical1.ContentHash.Should().MatchRegex(@"^sha256:[0-9a-f]{64}$");
|
|
}
|
|
|
|
[Fact]
|
|
public void CanonicalGraph_NodeOrdering_IsDeterministic()
|
|
{
|
|
// Arrange - Graph with nodes added in different order
|
|
var nodes1 = new[]
|
|
{
|
|
CreateRichGraphNode("node-c", "method", "C#"),
|
|
CreateRichGraphNode("node-a", "method", "C#"),
|
|
CreateRichGraphNode("node-b", "method", "C#"),
|
|
};
|
|
var nodes2 = new[]
|
|
{
|
|
CreateRichGraphNode("node-b", "method", "C#"),
|
|
CreateRichGraphNode("node-c", "method", "C#"),
|
|
CreateRichGraphNode("node-a", "method", "C#"),
|
|
};
|
|
|
|
var edges = Array.Empty<RichGraphEdge>();
|
|
var roots = Array.Empty<RichGraphRoot>();
|
|
var analyzer = new RichGraphAnalyzer("test", "1.0.0", null);
|
|
|
|
var graph1 = new RichGraph(nodes1, edges, roots, analyzer);
|
|
var graph2 = new RichGraph(nodes2, edges, roots, analyzer);
|
|
var orderer = new DeterministicGraphOrderer();
|
|
|
|
// Act
|
|
var canonical1 = orderer.Canonicalize(graph1, GraphOrderingStrategy.Lexicographic);
|
|
var canonical2 = orderer.Canonicalize(graph2, GraphOrderingStrategy.Lexicographic);
|
|
|
|
// Assert - Node order should be deterministic regardless of input order
|
|
canonical1.Nodes.Select(n => n.Id).Should().Equal(canonical2.Nodes.Select(n => n.Id));
|
|
canonical1.ContentHash.Should().Be(canonical2.ContentHash);
|
|
}
|
|
|
|
[Fact]
|
|
public void CanonicalGraph_EdgeOrdering_IsDeterministic()
|
|
{
|
|
// Arrange - Graph with edges added in different order
|
|
var nodes = new[]
|
|
{
|
|
CreateRichGraphNode("node-a", "method", "C#"),
|
|
CreateRichGraphNode("node-b", "method", "C#"),
|
|
CreateRichGraphNode("node-c", "method", "C#"),
|
|
};
|
|
|
|
var edges1 = new[]
|
|
{
|
|
CreateRichGraphEdge("node-c", "node-b"),
|
|
CreateRichGraphEdge("node-a", "node-b"),
|
|
CreateRichGraphEdge("node-b", "node-c"),
|
|
};
|
|
var edges2 = new[]
|
|
{
|
|
CreateRichGraphEdge("node-a", "node-b"),
|
|
CreateRichGraphEdge("node-b", "node-c"),
|
|
CreateRichGraphEdge("node-c", "node-b"),
|
|
};
|
|
|
|
var roots = Array.Empty<RichGraphRoot>();
|
|
var analyzer = new RichGraphAnalyzer("test", "1.0.0", null);
|
|
|
|
var graph1 = new RichGraph(nodes, edges1, roots, analyzer);
|
|
var graph2 = new RichGraph(nodes, edges2, roots, analyzer);
|
|
var orderer = new DeterministicGraphOrderer();
|
|
|
|
// Act
|
|
var canonical1 = orderer.Canonicalize(graph1);
|
|
var canonical2 = orderer.Canonicalize(graph2);
|
|
|
|
// Assert - Edge order should be deterministic regardless of input order
|
|
var edgeIds1 = canonical1.Edges.Select(e => $"{e.SourceIndex}->{e.TargetIndex}").ToList();
|
|
var edgeIds2 = canonical2.Edges.Select(e => $"{e.SourceIndex}->{e.TargetIndex}").ToList();
|
|
edgeIds1.Should().Equal(edgeIds2);
|
|
canonical1.ContentHash.Should().Be(canonical2.ContentHash);
|
|
}
|
|
|
|
[Fact]
|
|
public void CanonicalGraph_AllStrategies_ProduceConsistentHashesForSameStrategy()
|
|
{
|
|
// Arrange
|
|
var graph = CreateSampleRichGraph();
|
|
var orderer = new DeterministicGraphOrderer();
|
|
|
|
// Act - Test each strategy produces consistent results
|
|
var strategies = new[]
|
|
{
|
|
GraphOrderingStrategy.TopologicalLexicographic,
|
|
GraphOrderingStrategy.BreadthFirstLexicographic,
|
|
GraphOrderingStrategy.DepthFirstLexicographic,
|
|
GraphOrderingStrategy.Lexicographic
|
|
};
|
|
|
|
foreach (var strategy in strategies)
|
|
{
|
|
var canonical1 = orderer.Canonicalize(graph, strategy);
|
|
var canonical2 = orderer.Canonicalize(graph, strategy);
|
|
|
|
// Assert - Same strategy should always produce same hash
|
|
canonical1.ContentHash.Should().Be(canonical2.ContentHash,
|
|
$"Strategy {strategy} should produce consistent hashes");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CanonicalGraph_ParallelCanonicalization_ProducesDeterministicOutput()
|
|
{
|
|
// Arrange
|
|
var graph = CreateSampleRichGraph();
|
|
var orderer = new DeterministicGraphOrderer();
|
|
|
|
// Act - Canonicalize in parallel 20 times
|
|
var tasks = Enumerable.Range(0, 20)
|
|
.Select(_ => Task.Run(() => orderer.Canonicalize(graph)))
|
|
.ToArray();
|
|
|
|
var results = await Task.WhenAll(tasks);
|
|
|
|
// Assert - All content hashes should be identical
|
|
var hashes = results.Select(r => r.ContentHash).ToList();
|
|
hashes.Should().AllBe(hashes[0]);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region ReachabilityWitnessStatement Determinism Tests
|
|
|
|
[Fact]
|
|
public void ReachabilityWitnessStatement_WithIdenticalInput_ProducesDeterministicOutput()
|
|
{
|
|
// Arrange
|
|
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
|
|
|
|
// Act - Create statements multiple times
|
|
var statement1 = CreateWitnessStatement(frozenTime);
|
|
var statement2 = CreateWitnessStatement(frozenTime);
|
|
var statement3 = CreateWitnessStatement(frozenTime);
|
|
|
|
// Assert
|
|
var json1 = SerializeWitnessStatement(statement1);
|
|
var json2 = SerializeWitnessStatement(statement2);
|
|
var json3 = SerializeWitnessStatement(statement3);
|
|
|
|
json1.Should().Be(json2);
|
|
json2.Should().Be(json3);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReachabilityWitnessStatement_CanonicalHash_IsStable()
|
|
{
|
|
// Arrange
|
|
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
|
|
|
|
// Act
|
|
var statement1 = CreateWitnessStatement(frozenTime);
|
|
var json1 = SerializeWitnessStatement(statement1);
|
|
var hash1 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(json1));
|
|
|
|
var statement2 = CreateWitnessStatement(frozenTime);
|
|
var json2 = SerializeWitnessStatement(statement2);
|
|
var hash2 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(json2));
|
|
|
|
// Assert
|
|
hash1.Should().Be(hash2, "Same input should produce same canonical hash");
|
|
hash1.Should().MatchRegex("^[0-9a-f]{64}$");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReachabilityWitnessStatement_ParallelGeneration_ProducesDeterministicOutput()
|
|
{
|
|
// Arrange
|
|
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
|
|
|
|
// Act - Generate in parallel 20 times
|
|
var tasks = Enumerable.Range(0, 20)
|
|
.Select(_ => Task.Run(() =>
|
|
{
|
|
var statement = CreateWitnessStatement(frozenTime);
|
|
return SerializeWitnessStatement(statement);
|
|
}))
|
|
.ToArray();
|
|
|
|
var results = await Task.WhenAll(tasks);
|
|
|
|
// Assert - All outputs should be identical
|
|
results.Should().AllBe(results[0]);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region PathWitness Determinism Tests
|
|
|
|
[Fact]
|
|
public void PathWitness_WithIdenticalInput_ProducesDeterministicOutput()
|
|
{
|
|
// Arrange
|
|
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
|
|
|
|
// Act - Create witnesses multiple times
|
|
var witness1 = CreatePathWitness(frozenTime);
|
|
var witness2 = CreatePathWitness(frozenTime);
|
|
var witness3 = CreatePathWitness(frozenTime);
|
|
|
|
// Assert
|
|
var json1 = SerializePathWitness(witness1);
|
|
var json2 = SerializePathWitness(witness2);
|
|
var json3 = SerializePathWitness(witness3);
|
|
|
|
json1.Should().Be(json2);
|
|
json2.Should().Be(json3);
|
|
}
|
|
|
|
[Fact]
|
|
public void PathWitness_CanonicalHash_IsStable()
|
|
{
|
|
// Arrange
|
|
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
|
|
|
|
// Act
|
|
var witness1 = CreatePathWitness(frozenTime);
|
|
var json1 = SerializePathWitness(witness1);
|
|
var hash1 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(json1));
|
|
|
|
var witness2 = CreatePathWitness(frozenTime);
|
|
var json2 = SerializePathWitness(witness2);
|
|
var hash2 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(json2));
|
|
|
|
// Assert
|
|
hash1.Should().Be(hash2, "Same input should produce same canonical hash");
|
|
hash1.Should().MatchRegex("^[0-9a-f]{64}$");
|
|
}
|
|
|
|
[Fact]
|
|
public void PathWitness_PathStepOrdering_IsDeterministic()
|
|
{
|
|
// Arrange
|
|
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
|
|
|
|
// Act - Create witness with same path steps
|
|
var witness1 = CreatePathWitness(frozenTime);
|
|
var witness2 = CreatePathWitness(frozenTime);
|
|
|
|
// Assert - Path steps should be in identical order
|
|
witness1.Path.Select(s => s.SymbolId).Should().Equal(witness2.Path.Select(s => s.SymbolId));
|
|
}
|
|
|
|
[Fact]
|
|
public void PathWitness_GateOrdering_IsDeterministic()
|
|
{
|
|
// Arrange
|
|
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
|
|
|
|
// Act
|
|
var witness1 = CreatePathWitnessWithGates(frozenTime);
|
|
var witness2 = CreatePathWitnessWithGates(frozenTime);
|
|
|
|
// Assert - Gates should be in identical order
|
|
var gates1 = witness1.Gates?.Select(g => $"{g.Type}:{g.GuardSymbol}").ToList() ?? new List<string>();
|
|
var gates2 = witness2.Gates?.Select(g => $"{g.Type}:{g.GuardSymbol}").ToList() ?? new List<string>();
|
|
gates1.Should().Equal(gates2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PathWitness_ParallelGeneration_ProducesDeterministicOutput()
|
|
{
|
|
// Arrange
|
|
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
|
|
|
|
// Act - Generate in parallel 20 times
|
|
var tasks = Enumerable.Range(0, 20)
|
|
.Select(_ => Task.Run(() =>
|
|
{
|
|
var witness = CreatePathWitness(frozenTime);
|
|
return SerializePathWitness(witness);
|
|
}))
|
|
.ToArray();
|
|
|
|
var results = await Task.WhenAll(tasks);
|
|
|
|
// Assert - All outputs should be identical
|
|
results.Should().AllBe(results[0]);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region RichGraph.Trimmed Determinism Tests
|
|
|
|
[Fact]
|
|
public void RichGraph_Trimmed_ProducesDeterministicOutput()
|
|
{
|
|
// Arrange
|
|
var graph = CreateSampleRichGraph();
|
|
|
|
// Act - Trim multiple times
|
|
var trimmed1 = graph.Trimmed();
|
|
var trimmed2 = graph.Trimmed();
|
|
var trimmed3 = graph.Trimmed();
|
|
|
|
// Assert - Trimming is idempotent and deterministic
|
|
var orderer = new DeterministicGraphOrderer();
|
|
var canonical1 = orderer.Canonicalize(trimmed1);
|
|
var canonical2 = orderer.Canonicalize(trimmed2);
|
|
var canonical3 = orderer.Canonicalize(trimmed3);
|
|
|
|
canonical1.ContentHash.Should().Be(canonical2.ContentHash);
|
|
canonical2.ContentHash.Should().Be(canonical3.ContentHash);
|
|
}
|
|
|
|
[Fact]
|
|
public void RichGraph_Trimmed_IsIdempotent()
|
|
{
|
|
// Arrange
|
|
var graph = CreateSampleRichGraph();
|
|
|
|
// Act - Trim once, then trim again
|
|
var trimmed1 = graph.Trimmed();
|
|
var trimmed2 = trimmed1.Trimmed();
|
|
var trimmed3 = trimmed2.Trimmed();
|
|
|
|
// Assert - Multiple trims produce same result
|
|
var orderer = new DeterministicGraphOrderer();
|
|
var canonical1 = orderer.Canonicalize(trimmed1);
|
|
var canonical2 = orderer.Canonicalize(trimmed2);
|
|
var canonical3 = orderer.Canonicalize(trimmed3);
|
|
|
|
canonical1.ContentHash.Should().Be(canonical2.ContentHash);
|
|
canonical2.ContentHash.Should().Be(canonical3.ContentHash);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region End-to-End Reachability Evidence Determinism
|
|
|
|
[Fact]
|
|
public void ReachabilityEvidence_EndToEnd_ProducesDeterministicHash()
|
|
{
|
|
// Arrange
|
|
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
|
|
var graph = CreateSampleRichGraph();
|
|
var orderer = new DeterministicGraphOrderer();
|
|
|
|
// Act - Generate complete evidence bundle twice
|
|
var canonical1 = orderer.Canonicalize(graph);
|
|
var statement1 = CreateWitnessStatement(frozenTime, canonical1.ContentHash);
|
|
var witness1 = CreatePathWitness(frozenTime);
|
|
|
|
var canonical2 = orderer.Canonicalize(graph);
|
|
var statement2 = CreateWitnessStatement(frozenTime, canonical2.ContentHash);
|
|
var witness2 = CreatePathWitness(frozenTime);
|
|
|
|
// Assert - All components should produce identical outputs
|
|
canonical1.ContentHash.Should().Be(canonical2.ContentHash);
|
|
|
|
var statementJson1 = SerializeWitnessStatement(statement1);
|
|
var statementJson2 = SerializeWitnessStatement(statement2);
|
|
statementJson1.Should().Be(statementJson2);
|
|
|
|
var witnessJson1 = SerializePathWitness(witness1);
|
|
var witnessJson2 = SerializePathWitness(witness2);
|
|
witnessJson1.Should().Be(witnessJson2);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(1)]
|
|
[InlineData(10)]
|
|
[InlineData(50)]
|
|
[InlineData(100)]
|
|
public void ReachabilityEvidence_MultipleIterations_ProducesStableHash(int iterations)
|
|
{
|
|
// Arrange
|
|
var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
|
|
var graph = CreateSampleRichGraph();
|
|
var orderer = new DeterministicGraphOrderer();
|
|
|
|
// Act - Generate hash multiple times
|
|
var hashes = Enumerable.Range(0, iterations)
|
|
.Select(_ =>
|
|
{
|
|
var canonical = orderer.Canonicalize(graph);
|
|
return canonical.ContentHash;
|
|
})
|
|
.ToList();
|
|
|
|
// Assert - All hashes should be identical
|
|
hashes.Should().AllBe(hashes[0]);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Real Types Helper Methods
|
|
|
|
private static RichGraph CreateSampleRichGraph()
|
|
{
|
|
var nodes = new[]
|
|
{
|
|
CreateRichGraphNode("entrypoint-main", "entrypoint", "C#", new Dictionary<string, string>
|
|
{
|
|
["is_entrypoint"] = "true"
|
|
}),
|
|
CreateRichGraphNode("method-process", "method", "C#"),
|
|
CreateRichGraphNode("method-validate", "method", "C#"),
|
|
CreateRichGraphNode("sink-deserialize", "sink", "C#"),
|
|
};
|
|
|
|
var edges = new[]
|
|
{
|
|
CreateRichGraphEdge("entrypoint-main", "method-process"),
|
|
CreateRichGraphEdge("method-process", "method-validate"),
|
|
CreateRichGraphEdge("method-validate", "sink-deserialize"),
|
|
};
|
|
|
|
var roots = new[]
|
|
{
|
|
new RichGraphRoot("entrypoint-main", "runtime", "http")
|
|
};
|
|
|
|
var analyzer = new RichGraphAnalyzer("stellaops.scanner.reachability", "1.0.0", null);
|
|
|
|
return new RichGraph(nodes, edges, roots, analyzer);
|
|
}
|
|
|
|
private static RichGraphNode CreateRichGraphNode(
|
|
string id,
|
|
string kind,
|
|
string lang,
|
|
IReadOnlyDictionary<string, string>? attributes = null)
|
|
{
|
|
return new RichGraphNode(
|
|
Id: id,
|
|
SymbolId: id,
|
|
CodeId: $"code:{id}",
|
|
Purl: $"pkg:test/{id}@1.0.0",
|
|
Lang: lang,
|
|
Kind: kind,
|
|
Display: id,
|
|
BuildId: null,
|
|
Evidence: null,
|
|
Attributes: attributes,
|
|
SymbolDigest: null,
|
|
Symbol: null,
|
|
CodeBlockHash: null);
|
|
}
|
|
|
|
private static RichGraphEdge CreateRichGraphEdge(string from, string to)
|
|
{
|
|
return new RichGraphEdge(
|
|
From: from,
|
|
To: to,
|
|
Kind: "call",
|
|
Purl: null,
|
|
SymbolDigest: null,
|
|
Evidence: null,
|
|
Confidence: 0.9,
|
|
Candidates: null);
|
|
}
|
|
|
|
private static ReachabilityWitnessStatement CreateWitnessStatement(
|
|
DateTimeOffset timestamp,
|
|
string? graphHash = null)
|
|
{
|
|
return new ReachabilityWitnessStatement
|
|
{
|
|
GraphHash = graphHash ?? "sha256:abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234",
|
|
GraphCasUri = "cas://richgraph/sha256:abcd1234",
|
|
GeneratedAt = timestamp,
|
|
Language = "C#",
|
|
NodeCount = 4,
|
|
EdgeCount = 3,
|
|
EntrypointCount = 1,
|
|
SinkCount = 1,
|
|
ReachableSinkCount = 1,
|
|
PolicyHash = "sha256:policy123",
|
|
AnalyzerVersion = "1.0.0",
|
|
SourceCommit = "abc123def",
|
|
SubjectDigest = "sha256:image-digest-here"
|
|
};
|
|
}
|
|
|
|
private static PathWitness CreatePathWitness(DateTimeOffset timestamp)
|
|
{
|
|
var witnessId = ComputeWitnessId("CVE-2024-1234", "pkg:test/component@1.0.0");
|
|
|
|
return new PathWitness
|
|
{
|
|
WitnessId = witnessId,
|
|
Artifact = new WitnessArtifact
|
|
{
|
|
SbomDigest = "sha256:sbom-digest-here",
|
|
ComponentPurl = "pkg:test/component@1.0.0"
|
|
},
|
|
Vuln = new WitnessVuln
|
|
{
|
|
Id = "CVE-2024-1234",
|
|
Source = "NVD",
|
|
AffectedRange = ">=1.0.0 <2.0.0"
|
|
},
|
|
Entrypoint = new WitnessEntrypoint
|
|
{
|
|
Kind = "http",
|
|
Name = "GET /api/process",
|
|
SymbolId = "entrypoint-main"
|
|
},
|
|
Path = new[]
|
|
{
|
|
new PathStep
|
|
{
|
|
Symbol = "Main",
|
|
SymbolId = "entrypoint-main",
|
|
File = "Program.cs",
|
|
Line = 10,
|
|
Column = 5
|
|
},
|
|
new PathStep
|
|
{
|
|
Symbol = "Process",
|
|
SymbolId = "method-process",
|
|
File = "Services/Processor.cs",
|
|
Line = 25,
|
|
Column = 9
|
|
},
|
|
new PathStep
|
|
{
|
|
Symbol = "Validate",
|
|
SymbolId = "method-validate",
|
|
File = "Services/Validator.cs",
|
|
Line = 42,
|
|
Column = 13
|
|
}
|
|
},
|
|
Sink = new WitnessSink
|
|
{
|
|
Symbol = "Deserialize",
|
|
SymbolId = "sink-deserialize",
|
|
SinkType = "deserialization"
|
|
},
|
|
Gates = null,
|
|
Evidence = new WitnessEvidence
|
|
{
|
|
CallgraphDigest = "blake3:callgraph-digest-here",
|
|
SurfaceDigest = "sha256:surface-digest-here",
|
|
AnalysisConfigDigest = "sha256:config-digest-here",
|
|
BuildId = "build-123"
|
|
},
|
|
ObservedAt = timestamp
|
|
};
|
|
}
|
|
|
|
private static PathWitness CreatePathWitnessWithGates(DateTimeOffset timestamp)
|
|
{
|
|
var witnessId = ComputeWitnessId("CVE-2024-5678", "pkg:test/gated-component@1.0.0");
|
|
|
|
return new PathWitness
|
|
{
|
|
WitnessId = witnessId,
|
|
Artifact = new WitnessArtifact
|
|
{
|
|
SbomDigest = "sha256:sbom-digest-gated",
|
|
ComponentPurl = "pkg:test/gated-component@1.0.0"
|
|
},
|
|
Vuln = new WitnessVuln
|
|
{
|
|
Id = "CVE-2024-5678",
|
|
Source = "GHSA",
|
|
AffectedRange = ">=1.0.0 <1.5.0"
|
|
},
|
|
Entrypoint = new WitnessEntrypoint
|
|
{
|
|
Kind = "http",
|
|
Name = "POST /api/admin",
|
|
SymbolId = "admin-entrypoint"
|
|
},
|
|
Path = new[]
|
|
{
|
|
new PathStep
|
|
{
|
|
Symbol = "AdminHandler",
|
|
SymbolId = "admin-entrypoint",
|
|
File = "Controllers/AdminController.cs",
|
|
Line = 15,
|
|
Column = 5
|
|
},
|
|
new PathStep
|
|
{
|
|
Symbol = "ExecuteCommand",
|
|
SymbolId = "method-execute",
|
|
File = "Services/CommandService.cs",
|
|
Line = 30,
|
|
Column = 9
|
|
}
|
|
},
|
|
Sink = new WitnessSink
|
|
{
|
|
Symbol = "RunShell",
|
|
SymbolId = "sink-shell",
|
|
SinkType = "command_injection"
|
|
},
|
|
Gates = new[]
|
|
{
|
|
new DetectedGate
|
|
{
|
|
Type = "authRequired",
|
|
GuardSymbol = "AuthorizeAttribute",
|
|
Confidence = 0.95,
|
|
Detail = "[Authorize(Roles = \"Admin\")]"
|
|
},
|
|
new DetectedGate
|
|
{
|
|
Type = "inputValidation",
|
|
GuardSymbol = "ValidateInput",
|
|
Confidence = 0.80,
|
|
Detail = "Command whitelist check"
|
|
}
|
|
},
|
|
Evidence = new WitnessEvidence
|
|
{
|
|
CallgraphDigest = "blake3:callgraph-gated",
|
|
SurfaceDigest = "sha256:surface-gated",
|
|
AnalysisConfigDigest = "sha256:config-gated",
|
|
BuildId = "build-456"
|
|
},
|
|
ObservedAt = timestamp
|
|
};
|
|
}
|
|
|
|
private static string ComputeWitnessId(string vulnId, string componentPurl)
|
|
{
|
|
var seed = $"{vulnId}:{componentPurl}";
|
|
var hash = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(seed));
|
|
return $"wit:sha256:{hash}";
|
|
}
|
|
|
|
private static string SerializeWitnessStatement(ReachabilityWitnessStatement statement)
|
|
{
|
|
return System.Text.Json.JsonSerializer.Serialize(statement, new System.Text.Json.JsonSerializerOptions
|
|
{
|
|
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
|
WriteIndented = false,
|
|
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
|
});
|
|
}
|
|
|
|
private static string SerializePathWitness(PathWitness witness)
|
|
{
|
|
return System.Text.Json.JsonSerializer.Serialize(witness, new System.Text.Json.JsonSerializerOptions
|
|
{
|
|
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
|
WriteIndented = false,
|
|
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
|
});
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region DTOs
|
|
|
|
private sealed record ReachabilityInput
|
|
{
|
|
public required string Result { get; init; }
|
|
public required double Confidence { get; init; }
|
|
public required PathInput[] Paths { get; init; }
|
|
public required string GraphDigest { get; init; }
|
|
}
|
|
|
|
private sealed record PathInput
|
|
{
|
|
public required string PathId { get; init; }
|
|
public required StepInput[] Steps { get; init; }
|
|
}
|
|
|
|
private sealed record StepInput
|
|
{
|
|
public required string Node { get; init; }
|
|
public required string FileHash { get; init; }
|
|
public required int[] Lines { get; init; }
|
|
}
|
|
|
|
#endregion
|
|
}
|