Add Canonical JSON serialization library with tests and documentation

- Implemented CanonJson class for deterministic JSON serialization and hashing.
- Added unit tests for CanonJson functionality, covering various scenarios including key sorting, handling of nested objects, arrays, and special characters.
- Created project files for the Canonical JSON library and its tests, including necessary package references.
- Added README.md for library usage and API reference.
- Introduced RabbitMqIntegrationFactAttribute for conditional RabbitMQ integration tests.
This commit is contained in:
master
2025-12-19 15:35:00 +02:00
parent 43882078a4
commit 951a38d561
192 changed files with 27550 additions and 2611 deletions

View File

@@ -384,4 +384,150 @@ public class PathWitnessBuilderTests
}
#endregion
#region BuildFromAnalyzerAsync Tests (WIT-008)
/// <summary>
/// WIT-008: Test that BuildFromAnalyzerAsync generates witnesses from pre-computed paths.
/// </summary>
[Fact]
public async Task BuildFromAnalyzerAsync_GeneratesWitnessesFromPaths()
{
// Arrange
var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider);
var paths = new List<AnalyzerPathData>
{
new("entry:001", "sink:001",
System.Collections.Immutable.ImmutableArray.Create("entry:001", "mid:001", "sink:001"))
};
var nodeMetadata = new Dictionary<string, AnalyzerNodeData>
{
["entry:001"] = new("EntryMethod", "src/Entry.cs", 10, "http"),
["mid:001"] = new("MiddleMethod", "src/Middle.cs", 20, null),
["sink:001"] = new("SinkMethod", "src/Sink.cs", 30, null)
};
var request = new AnalyzerWitnessRequest
{
SbomDigest = "sha256:sbom123",
ComponentPurl = "pkg:nuget/Test@1.0.0",
VulnId = "CVE-2024-99999",
VulnSource = "NVD",
AffectedRange = "<=1.0.0",
SinkType = "sql_injection",
GraphDigest = "blake3:graph123",
Paths = paths,
NodeMetadata = nodeMetadata,
BuildId = "build:xyz"
};
// Act
var witnesses = new List<PathWitness>();
await foreach (var witness in builder.BuildFromAnalyzerAsync(request))
{
witnesses.Add(witness);
}
// Assert
Assert.Single(witnesses);
var w = witnesses[0];
Assert.Equal("CVE-2024-99999", w.Vuln.Id);
Assert.Equal("entry:001", w.Entrypoint.SymbolId);
Assert.Equal("sink:001", w.Sink.SymbolId);
Assert.Equal(3, w.Path.Count);
Assert.Equal("EntryMethod", w.Path[0].Symbol);
Assert.Equal("MiddleMethod", w.Path[1].Symbol);
Assert.Equal("SinkMethod", w.Path[2].Symbol);
Assert.NotEmpty(w.WitnessId);
Assert.StartsWith("wit:", w.WitnessId);
}
/// <summary>
/// WIT-008: Test that BuildFromAnalyzerAsync yields empty when no paths provided.
/// </summary>
[Fact]
public async Task BuildFromAnalyzerAsync_YieldsEmpty_WhenNoPaths()
{
// Arrange
var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider);
var request = new AnalyzerWitnessRequest
{
SbomDigest = "sha256:sbom123",
ComponentPurl = "pkg:nuget/Test@1.0.0",
VulnId = "CVE-2024-99999",
VulnSource = "NVD",
AffectedRange = "<=1.0.0",
SinkType = "sql_injection",
GraphDigest = "blake3:graph123",
Paths = new List<AnalyzerPathData>(),
NodeMetadata = new Dictionary<string, AnalyzerNodeData>()
};
// Act
var witnesses = new List<PathWitness>();
await foreach (var witness in builder.BuildFromAnalyzerAsync(request))
{
witnesses.Add(witness);
}
// Assert
Assert.Empty(witnesses);
}
/// <summary>
/// WIT-008: Test that missing node metadata is handled gracefully.
/// </summary>
[Fact]
public async Task BuildFromAnalyzerAsync_HandlesMissingNodeMetadata()
{
// Arrange
var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider);
var paths = new List<AnalyzerPathData>
{
new("entry:001", "sink:001",
System.Collections.Immutable.ImmutableArray.Create("entry:001", "unknown:002", "sink:001"))
};
// Only entry and sink have metadata, unknown:002 doesn't
var nodeMetadata = new Dictionary<string, AnalyzerNodeData>
{
["entry:001"] = new("EntryMethod", "src/Entry.cs", 10, "http"),
["sink:001"] = new("SinkMethod", "src/Sink.cs", 30, null)
};
var request = new AnalyzerWitnessRequest
{
SbomDigest = "sha256:sbom123",
ComponentPurl = "pkg:nuget/Test@1.0.0",
VulnId = "CVE-2024-99999",
VulnSource = "NVD",
AffectedRange = "<=1.0.0",
SinkType = "sql_injection",
GraphDigest = "blake3:graph123",
Paths = paths,
NodeMetadata = nodeMetadata
};
// Act
var witnesses = new List<PathWitness>();
await foreach (var witness in builder.BuildFromAnalyzerAsync(request))
{
witnesses.Add(witness);
}
// Assert
Assert.Single(witnesses);
var w = witnesses[0];
Assert.Equal(3, w.Path.Count);
// Unknown node should use its ID as symbol
Assert.Equal("unknown:002", w.Path[1].Symbol);
Assert.Equal("unknown:002", w.Path[1].SymbolId);
Assert.Null(w.Path[1].File);
}
#endregion
}

View File

@@ -0,0 +1,348 @@
// -----------------------------------------------------------------------------
// ReachabilityCacheTests.cs
// Sprint: SPRINT_3700_0006_0001_incremental_cache (CACHE-016, CACHE-017)
// Description: Unit tests for reachability cache components.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.Reachability.Cache;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests;
public sealed class GraphDeltaComputerTests
{
private readonly GraphDeltaComputer _computer;
public GraphDeltaComputerTests()
{
_computer = new GraphDeltaComputer(NullLogger<GraphDeltaComputer>.Instance);
}
[Fact]
public async Task ComputeDeltaAsync_SameHash_ReturnsEmpty()
{
// Arrange
var graph1 = new TestGraphSnapshot("hash1", new[] { "A", "B" }, new[] { ("A", "B") });
var graph2 = new TestGraphSnapshot("hash1", new[] { "A", "B" }, new[] { ("A", "B") });
// Act
var delta = await _computer.ComputeDeltaAsync(graph1, graph2);
// Assert
delta.HasChanges.Should().BeFalse();
}
[Fact]
public async Task ComputeDeltaAsync_AddedNode_ReturnsCorrectDelta()
{
// Arrange
var graph1 = new TestGraphSnapshot("hash1", new[] { "A", "B" }, new[] { ("A", "B") });
var graph2 = new TestGraphSnapshot("hash2", new[] { "A", "B", "C" }, new[] { ("A", "B"), ("B", "C") });
// Act
var delta = await _computer.ComputeDeltaAsync(graph1, graph2);
// Assert
delta.HasChanges.Should().BeTrue();
delta.AddedNodes.Should().Contain("C");
delta.RemovedNodes.Should().BeEmpty();
delta.AddedEdges.Should().ContainSingle(e => e.CallerKey == "B" && e.CalleeKey == "C");
delta.AffectedMethodKeys.Should().Contain("C");
}
[Fact]
public async Task ComputeDeltaAsync_RemovedNode_ReturnsCorrectDelta()
{
// Arrange
var graph1 = new TestGraphSnapshot("hash1", new[] { "A", "B", "C" }, new[] { ("A", "B"), ("B", "C") });
var graph2 = new TestGraphSnapshot("hash2", new[] { "A", "B" }, new[] { ("A", "B") });
// Act
var delta = await _computer.ComputeDeltaAsync(graph1, graph2);
// Assert
delta.HasChanges.Should().BeTrue();
delta.RemovedNodes.Should().Contain("C");
delta.AddedNodes.Should().BeEmpty();
delta.RemovedEdges.Should().ContainSingle(e => e.CallerKey == "B" && e.CalleeKey == "C");
}
[Fact]
public async Task ComputeDeltaAsync_EdgeChange_DetectsAffectedMethods()
{
// Arrange
var graph1 = new TestGraphSnapshot("hash1", new[] { "A", "B", "C" }, new[] { ("A", "B") });
var graph2 = new TestGraphSnapshot("hash2", new[] { "A", "B", "C" }, new[] { ("A", "C") });
// Act
var delta = await _computer.ComputeDeltaAsync(graph1, graph2);
// Assert
delta.HasChanges.Should().BeTrue();
delta.AddedEdges.Should().ContainSingle(e => e.CallerKey == "A" && e.CalleeKey == "C");
delta.RemovedEdges.Should().ContainSingle(e => e.CallerKey == "A" && e.CalleeKey == "B");
delta.AffectedMethodKeys.Should().Contain(new[] { "A", "B", "C" });
}
private sealed class TestGraphSnapshot : IGraphSnapshot
{
public string Hash { get; }
public IReadOnlySet<string> NodeKeys { get; }
public IReadOnlyList<GraphEdge> Edges { get; }
public IReadOnlySet<string> EntryPoints { get; }
public TestGraphSnapshot(string hash, string[] nodes, (string, string)[] edges, string[]? entryPoints = null)
{
Hash = hash;
NodeKeys = nodes.ToHashSet();
Edges = edges.Select(e => new GraphEdge(e.Item1, e.Item2)).ToList();
EntryPoints = (entryPoints ?? nodes.Take(1).ToArray()).ToHashSet();
}
}
}
public sealed class ImpactSetCalculatorTests
{
private readonly ImpactSetCalculator _calculator;
public ImpactSetCalculatorTests()
{
_calculator = new ImpactSetCalculator(NullLogger<ImpactSetCalculator>.Instance);
}
[Fact]
public async Task CalculateImpactAsync_NoDelta_ReturnsEmpty()
{
// Arrange
var delta = GraphDelta.Empty;
var graph = new TestGraphSnapshot("hash1", new[] { "Entry", "A", "B" }, new[] { ("Entry", "A"), ("A", "B") });
// Act
var impact = await _calculator.CalculateImpactAsync(delta, graph);
// Assert
impact.RequiresFullRecompute.Should().BeFalse();
impact.AffectedEntryPoints.Should().BeEmpty();
impact.SavingsRatio.Should().Be(1.0);
}
[Fact]
public async Task CalculateImpactAsync_ChangeInPath_IdentifiesAffectedEntry()
{
// Arrange
var delta = new GraphDelta
{
AddedNodes = new HashSet<string> { "C" },
AddedEdges = new List<GraphEdge> { new("B", "C") },
AffectedMethodKeys = new HashSet<string> { "B", "C" }
};
var graph = new TestGraphSnapshot(
"hash2",
new[] { "Entry", "A", "B", "C" },
new[] { ("Entry", "A"), ("A", "B"), ("B", "C") },
new[] { "Entry" });
// Act
var impact = await _calculator.CalculateImpactAsync(delta, graph);
// Assert
impact.RequiresFullRecompute.Should().BeFalse();
impact.AffectedEntryPoints.Should().Contain("Entry");
}
[Fact]
public async Task CalculateImpactAsync_ManyAffected_TriggersFullRecompute()
{
// Arrange - More than 30% affected
var delta = new GraphDelta
{
AffectedMethodKeys = new HashSet<string> { "Entry1", "Entry2", "Entry3", "Entry4" }
};
var graph = new TestGraphSnapshot(
"hash2",
new[] { "Entry1", "Entry2", "Entry3", "Entry4", "Sink" },
new[] { ("Entry1", "Sink"), ("Entry2", "Sink"), ("Entry3", "Sink"), ("Entry4", "Sink") },
new[] { "Entry1", "Entry2", "Entry3", "Entry4" });
// Act
var impact = await _calculator.CalculateImpactAsync(delta, graph);
// Assert - All 4 entries affected = 100% > 30% threshold
impact.RequiresFullRecompute.Should().BeTrue();
}
private sealed class TestGraphSnapshot : IGraphSnapshot
{
public string Hash { get; }
public IReadOnlySet<string> NodeKeys { get; }
public IReadOnlyList<GraphEdge> Edges { get; }
public IReadOnlySet<string> EntryPoints { get; }
public TestGraphSnapshot(string hash, string[] nodes, (string, string)[] edges, string[]? entryPoints = null)
{
Hash = hash;
NodeKeys = nodes.ToHashSet();
Edges = edges.Select(e => new GraphEdge(e.Item1, e.Item2)).ToList();
EntryPoints = (entryPoints ?? nodes.Take(1).ToArray()).ToHashSet();
}
}
}
public sealed class StateFlipDetectorTests
{
private readonly StateFlipDetector _detector;
public StateFlipDetectorTests()
{
_detector = new StateFlipDetector(NullLogger<StateFlipDetector>.Instance);
}
[Fact]
public async Task DetectFlipsAsync_NoChanges_ReturnsEmpty()
{
// Arrange
var previous = new List<ReachablePairResult>
{
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
};
var current = new List<ReachablePairResult>
{
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
};
// Act
var result = await _detector.DetectFlipsAsync(previous, current);
// Assert
result.HasFlips.Should().BeFalse();
result.NewRiskCount.Should().Be(0);
result.MitigatedCount.Should().Be(0);
}
[Fact]
public async Task DetectFlipsAsync_BecameReachable_ReturnsNewRisk()
{
// Arrange
var previous = new List<ReachablePairResult>
{
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = false, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
};
var current = new List<ReachablePairResult>
{
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
};
// Act
var result = await _detector.DetectFlipsAsync(previous, current);
// Assert
result.HasFlips.Should().BeTrue();
result.NewRiskCount.Should().Be(1);
result.MitigatedCount.Should().Be(0);
result.NewlyReachable.Should().ContainSingle()
.Which.FlipType.Should().Be(StateFlipType.BecameReachable);
result.ShouldBlockPr.Should().BeTrue();
}
[Fact]
public async Task DetectFlipsAsync_BecameUnreachable_ReturnsMitigated()
{
// Arrange
var previous = new List<ReachablePairResult>
{
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
};
var current = new List<ReachablePairResult>
{
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = false, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
};
// Act
var result = await _detector.DetectFlipsAsync(previous, current);
// Assert
result.HasFlips.Should().BeTrue();
result.NewRiskCount.Should().Be(0);
result.MitigatedCount.Should().Be(1);
result.NewlyUnreachable.Should().ContainSingle()
.Which.FlipType.Should().Be(StateFlipType.BecameUnreachable);
result.ShouldBlockPr.Should().BeFalse();
}
[Fact]
public async Task DetectFlipsAsync_NewReachablePair_ReturnsNewRisk()
{
// Arrange
var previous = new List<ReachablePairResult>();
var current = new List<ReachablePairResult>
{
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
};
// Act
var result = await _detector.DetectFlipsAsync(previous, current);
// Assert
result.HasFlips.Should().BeTrue();
result.NewRiskCount.Should().Be(1);
result.ShouldBlockPr.Should().BeTrue();
}
[Fact]
public async Task DetectFlipsAsync_RemovedReachablePair_ReturnsMitigated()
{
// Arrange
var previous = new List<ReachablePairResult>
{
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
};
var current = new List<ReachablePairResult>();
// Act
var result = await _detector.DetectFlipsAsync(previous, current);
// Assert
result.HasFlips.Should().BeTrue();
result.MitigatedCount.Should().Be(1);
result.ShouldBlockPr.Should().BeFalse();
}
[Fact]
public async Task DetectFlipsAsync_NetChange_CalculatesCorrectly()
{
// Arrange
var previous = new List<ReachablePairResult>
{
new() { EntryMethodKey = "E1", SinkMethodKey = "S1", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow },
new() { EntryMethodKey = "E2", SinkMethodKey = "S2", IsReachable = false, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
};
var current = new List<ReachablePairResult>
{
new() { EntryMethodKey = "E1", SinkMethodKey = "S1", IsReachable = false, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow },
new() { EntryMethodKey = "E2", SinkMethodKey = "S2", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow },
new() { EntryMethodKey = "E3", SinkMethodKey = "S3", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
};
// Act
var result = await _detector.DetectFlipsAsync(previous, current);
// Assert
result.NewRiskCount.Should().Be(2); // E2->S2 became reachable, E3->S3 new
result.MitigatedCount.Should().Be(1); // E1->S1 became unreachable
result.NetChange.Should().Be(1); // +2 - 1 = 1
}
}

View File

@@ -0,0 +1,251 @@
using Org.BouncyCastle.Crypto.Generators;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Security;
using StellaOps.Attestor.Envelope;
using StellaOps.Cryptography;
using StellaOps.Scanner.Reachability.Witnesses;
using System.Collections.Immutable;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests;
/// <summary>
/// Tests for <see cref="SignedWitnessGenerator"/>.
/// Sprint: SPRINT_3700_0001_0001 (WIT-009)
/// </summary>
public class SignedWitnessGeneratorTests
{
private readonly IPathWitnessBuilder _builder;
private readonly IWitnessDsseSigner _signer;
private readonly SignedWitnessGenerator _generator;
private readonly EnvelopeKey _testKey;
public SignedWitnessGeneratorTests()
{
var cryptoHash = DefaultCryptoHash.CreateForTests();
_builder = new PathWitnessBuilder(cryptoHash, TimeProvider.System);
_signer = new WitnessDsseSigner();
_generator = new SignedWitnessGenerator(_builder, _signer);
_testKey = CreateTestKey();
}
[Fact]
public async Task GenerateSignedWitnessAsync_ReturnsNull_WhenNoPathExists()
{
// Arrange - Request with no valid path (unreachable sink)
var graph = CreateSimpleGraph();
var request = new PathWitnessRequest
{
SbomDigest = "sha256:sbom123",
ComponentPurl = "pkg:nuget/Test@1.0.0",
VulnId = "CVE-2024-12345",
VulnSource = "NVD",
AffectedRange = "<=1.0.0",
EntrypointSymbolId = "sym:entry",
EntrypointKind = "http",
EntrypointName = "GET /api/test",
SinkSymbolId = "sym:unreachable", // Not in graph
SinkType = "deserialization",
CallGraph = graph,
CallgraphDigest = "blake3:graph123"
};
// Act
var result = await _generator.GenerateSignedWitnessAsync(request, _testKey);
// Assert
Assert.Null(result);
}
[Fact]
public async Task GenerateSignedWitnessAsync_ReturnsSignedResult_WhenPathExists()
{
// Arrange
var graph = CreateSimpleGraph();
var request = new PathWitnessRequest
{
SbomDigest = "sha256:sbom123",
ComponentPurl = "pkg:nuget/Test@1.0.0",
VulnId = "CVE-2024-12345",
VulnSource = "NVD",
AffectedRange = "<=1.0.0",
EntrypointSymbolId = "sym:entry",
EntrypointKind = "http",
EntrypointName = "GET /api/test",
SinkSymbolId = "sym:sink",
SinkType = "deserialization",
CallGraph = graph,
CallgraphDigest = "blake3:graph123"
};
// Act
var result = await _generator.GenerateSignedWitnessAsync(request, _testKey);
// Assert
Assert.NotNull(result);
Assert.True(result.IsSuccess);
Assert.NotNull(result.Witness);
Assert.NotNull(result.Envelope);
Assert.NotEmpty(result.PayloadBytes!);
Assert.Equal(WitnessSchema.DssePayloadType, result.Envelope.PayloadType);
}
[Fact]
public async Task GenerateSignedWitnessesFromAnalyzerAsync_GeneratesSignedEnvelopes()
{
// Arrange
var paths = new List<AnalyzerPathData>
{
new("entry:001", "sink:001",
ImmutableArray.Create("entry:001", "mid:001", "sink:001")),
new("entry:002", "sink:002",
ImmutableArray.Create("entry:002", "sink:002"))
};
var nodeMetadata = new Dictionary<string, AnalyzerNodeData>
{
["entry:001"] = new("EntryMethod1", "src/Entry.cs", 10, "http"),
["mid:001"] = new("MiddleMethod", "src/Middle.cs", 20, null),
["sink:001"] = new("SinkMethod1", "src/Sink.cs", 30, null),
["entry:002"] = new("EntryMethod2", "src/Entry2.cs", 40, "grpc"),
["sink:002"] = new("SinkMethod2", "src/Sink2.cs", 50, null)
};
var request = new AnalyzerWitnessRequest
{
SbomDigest = "sha256:sbom123",
ComponentPurl = "pkg:nuget/Test@1.0.0",
VulnId = "CVE-2024-0000",
VulnSource = "NVD",
AffectedRange = "<=1.0.0",
SinkType = "deserialization",
GraphDigest = "blake3:graph123",
Paths = paths,
NodeMetadata = nodeMetadata
};
// Act
var results = new List<SignedWitnessResult>();
await foreach (var result in _generator.GenerateSignedWitnessesFromAnalyzerAsync(request, _testKey))
{
results.Add(result);
}
// Assert
Assert.Equal(2, results.Count);
Assert.All(results, r => Assert.True(r.IsSuccess));
Assert.All(results, r => Assert.NotNull(r.Envelope));
Assert.Equal("entry:001", results[0].Witness!.Entrypoint.SymbolId);
Assert.Equal("entry:002", results[1].Witness!.Entrypoint.SymbolId);
}
[Fact]
public async Task GeneratedEnvelope_CanBeVerified()
{
// Arrange
var graph = CreateSimpleGraph();
var request = new PathWitnessRequest
{
SbomDigest = "sha256:sbom123",
ComponentPurl = "pkg:nuget/Test@1.0.0",
VulnId = "CVE-2024-12345",
VulnSource = "NVD",
AffectedRange = "<=1.0.0",
EntrypointSymbolId = "sym:entry",
EntrypointKind = "http",
EntrypointName = "GET /api/test",
SinkSymbolId = "sym:sink",
SinkType = "deserialization",
CallGraph = graph,
CallgraphDigest = "blake3:graph123"
};
var (_, publicKey) = GetTestKeyPair();
var verifyKey = EnvelopeKey.CreateEd25519Verifier(publicKey);
// Act
var result = await _generator.GenerateSignedWitnessAsync(request, _testKey);
// Assert - Verify the envelope
Assert.NotNull(result);
Assert.True(result.IsSuccess);
var verifyResult = _signer.VerifyWitness(result.Envelope!, verifyKey);
Assert.True(verifyResult.IsSuccess);
Assert.Equal(result.Witness!.WitnessId, verifyResult.Witness!.WitnessId);
}
private static RichGraph CreateSimpleGraph()
{
var nodes = new List<RichGraphNode>
{
new("n1", "sym:entry", null, null, "dotnet", "method", "Entry", null, null, null, null),
new("n2", "sym:middle", null, null, "dotnet", "method", "Middle", null, null, null, null),
new("n3", "sym:sink", null, null, "dotnet", "method", "Sink", null, null, null, null)
};
var edges = new List<RichGraphEdge>
{
new("n1", "n2", "call", null, null, null, 1.0, null),
new("n2", "n3", "call", null, null, null, 1.0, null)
};
var roots = new List<RichGraphRoot>
{
new("n1", "http", "/api/test")
};
return new RichGraph(
nodes,
edges,
roots,
new RichGraphAnalyzer("test", "1.0.0", null));
}
private static EnvelopeKey CreateTestKey()
{
var (privateKey, publicKey) = GetTestKeyPair();
return EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
}
private static (byte[] privateKey, byte[] publicKey) GetTestKeyPair()
{
var generator = new Ed25519KeyPairGenerator();
generator.Init(new Ed25519KeyGenerationParameters(new SecureRandom(new FixedRandomGenerator())));
var keyPair = generator.GenerateKeyPair();
var privateParams = (Ed25519PrivateKeyParameters)keyPair.Private;
var publicParams = (Ed25519PublicKeyParameters)keyPair.Public;
var privateKey = new byte[64];
privateParams.Encode(privateKey, 0);
var publicKey = publicParams.GetEncoded();
Array.Copy(publicKey, 0, privateKey, 32, 32);
return (privateKey, publicKey);
}
private sealed class FixedRandomGenerator : Org.BouncyCastle.Crypto.Prng.IRandomGenerator
{
private byte _value = 0x42;
public void AddSeedMaterial(byte[] seed) { }
public void AddSeedMaterial(ReadOnlySpan<byte> seed) { }
public void AddSeedMaterial(long seed) { }
public void NextBytes(byte[] bytes) => NextBytes(bytes, 0, bytes.Length);
public void NextBytes(byte[] bytes, int start, int len)
{
for (int i = start; i < start + len; i++)
{
bytes[i] = _value++;
}
}
public void NextBytes(Span<byte> bytes)
{
for (int i = 0; i < bytes.Length; i++)
{
bytes[i] = _value++;
}
}
}
}

View File

@@ -0,0 +1,282 @@
// -----------------------------------------------------------------------------
// SurfaceQueryServiceTests.cs
// Sprint: SPRINT_3700_0004_0001_reachability_integration (REACH-012)
// Description: Unit tests for SurfaceQueryService.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.Reachability.Surfaces;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests;
public sealed class SurfaceQueryServiceTests : IDisposable
{
private readonly FakeSurfaceRepository _repository;
private readonly IMemoryCache _cache;
private readonly ILogger<SurfaceQueryService> _logger;
private readonly SurfaceQueryService _service;
public SurfaceQueryServiceTests()
{
_repository = new FakeSurfaceRepository();
_cache = new MemoryCache(new MemoryCacheOptions());
_logger = NullLogger<SurfaceQueryService>.Instance;
_service = new SurfaceQueryService(
_repository,
_cache,
_logger,
new SurfaceQueryOptions { EnableCaching = true });
}
public void Dispose()
{
_cache.Dispose();
}
[Fact]
public async Task QueryAsync_WhenSurfaceFound_ReturnsFoundResult()
{
// Arrange
var surfaceId = Guid.NewGuid();
var cveId = "CVE-2023-1234";
var packageName = "Newtonsoft.Json";
var version = "12.0.1";
var computedAt = DateTimeOffset.UtcNow.AddHours(-1);
_repository.AddSurface(new SurfaceInfo
{
Id = surfaceId,
CveId = cveId,
Ecosystem = "nuget",
PackageName = packageName,
VulnVersion = version,
FixedVersion = "12.0.2",
ComputedAt = computedAt,
ChangedMethodCount = 3,
TriggerCount = 5
});
_repository.AddTriggers(surfaceId, new List<TriggerMethodInfo>
{
new()
{
MethodKey = "Newtonsoft.Json.JsonConvert::DeserializeObject",
MethodName = "DeserializeObject",
DeclaringType = "JsonConvert",
SinkCount = 2,
ShortestPathLength = 1
}
});
_repository.AddSinks(surfaceId, new List<string> { "Newtonsoft.Json.Internal::Vulnerable" });
var request = new SurfaceQueryRequest
{
CveId = cveId,
Ecosystem = "nuget",
PackageName = packageName,
Version = version
};
// Act
var result = await _service.QueryAsync(request);
// Assert
result.SurfaceFound.Should().BeTrue();
result.Source.Should().Be(SinkSource.Surface);
result.SurfaceId.Should().Be(surfaceId);
result.Triggers.Should().HaveCount(1);
result.Triggers[0].MethodName.Should().Be("DeserializeObject");
result.ComputedAt.Should().Be(computedAt);
}
[Fact]
public async Task QueryAsync_WhenSurfaceNotFound_ReturnsFallbackResult()
{
// Arrange
var request = new SurfaceQueryRequest
{
CveId = "CVE-2023-9999",
Ecosystem = "npm",
PackageName = "unknown-package",
Version = "1.0.0"
};
// Act
var result = await _service.QueryAsync(request);
// Assert
result.SurfaceFound.Should().BeFalse();
result.Source.Should().Be(SinkSource.FallbackAll);
result.SurfaceId.Should().BeNull();
result.Triggers.Should().BeEmpty();
}
[Fact]
public async Task QueryAsync_CachesResult_ReturnsFromCacheOnSecondCall()
{
// Arrange
var surfaceId = Guid.NewGuid();
_repository.AddSurface(new SurfaceInfo
{
Id = surfaceId,
CveId = "CVE-2023-1234",
Ecosystem = "nuget",
PackageName = "Test.Package",
VulnVersion = "1.0.0",
ComputedAt = DateTimeOffset.UtcNow
});
var request = new SurfaceQueryRequest
{
CveId = "CVE-2023-1234",
Ecosystem = "nuget",
PackageName = "Test.Package",
Version = "1.0.0"
};
// Act
var result1 = await _service.QueryAsync(request);
var result2 = await _service.QueryAsync(request);
// Assert
result1.SurfaceFound.Should().BeTrue();
result2.SurfaceFound.Should().BeTrue();
// Repository should only be called once due to caching
_repository.GetSurfaceCallCount.Should().Be(1);
}
[Fact]
public async Task QueryBulkAsync_QueriesMultipleVulnerabilities()
{
// Arrange
var surfaceId1 = Guid.NewGuid();
_repository.AddSurface(new SurfaceInfo
{
Id = surfaceId1,
CveId = "CVE-2023-0001",
Ecosystem = "nuget",
PackageName = "Package1",
VulnVersion = "1.0.0",
ComputedAt = DateTimeOffset.UtcNow
});
var requests = new List<SurfaceQueryRequest>
{
new() { CveId = "CVE-2023-0001", Ecosystem = "nuget", PackageName = "Package1", Version = "1.0.0" },
new() { CveId = "CVE-2023-0002", Ecosystem = "nuget", PackageName = "Package2", Version = "2.0.0" }
};
// Act
var results = await _service.QueryBulkAsync(requests);
// Assert
results.Should().HaveCount(2);
var key1 = "CVE-2023-0001|nuget|Package1|1.0.0";
var key2 = "CVE-2023-0002|nuget|Package2|2.0.0";
results[key1].SurfaceFound.Should().BeTrue();
results[key2].SurfaceFound.Should().BeFalse();
}
[Fact]
public async Task ExistsAsync_ReturnsTrueWhenSurfaceExists()
{
// Arrange
_repository.AddSurface(new SurfaceInfo
{
Id = Guid.NewGuid(),
CveId = "CVE-2023-1234",
Ecosystem = "nuget",
PackageName = "Package",
VulnVersion = "1.0.0",
ComputedAt = DateTimeOffset.UtcNow
});
// Act
var exists = await _service.ExistsAsync("CVE-2023-1234", "nuget", "Package", "1.0.0");
// Assert
exists.Should().BeTrue();
}
[Fact]
public async Task ExistsAsync_ReturnsFalseWhenSurfaceDoesNotExist()
{
// Act
var exists = await _service.ExistsAsync("CVE-2023-9999", "npm", "unknown", "1.0.0");
// Assert
exists.Should().BeFalse();
}
/// <summary>
/// Fake implementation of ISurfaceRepository for testing.
/// </summary>
private sealed class FakeSurfaceRepository : ISurfaceRepository
{
private readonly Dictionary<string, SurfaceInfo> _surfaces = new();
private readonly Dictionary<Guid, List<TriggerMethodInfo>> _triggers = new();
private readonly Dictionary<Guid, List<string>> _sinks = new();
public int GetSurfaceCallCount { get; private set; }
public void AddSurface(SurfaceInfo surface)
{
var key = $"{surface.CveId}|{surface.Ecosystem}|{surface.PackageName}|{surface.VulnVersion}";
_surfaces[key] = surface;
}
public void AddTriggers(Guid surfaceId, List<TriggerMethodInfo> triggers)
{
_triggers[surfaceId] = triggers;
}
public void AddSinks(Guid surfaceId, List<string> sinks)
{
_sinks[surfaceId] = sinks;
}
public Task<SurfaceInfo?> GetSurfaceAsync(string cveId, string ecosystem, string packageName, string version, CancellationToken cancellationToken = default)
{
GetSurfaceCallCount++;
var key = $"{cveId}|{ecosystem}|{packageName}|{version}";
_surfaces.TryGetValue(key, out var surface);
return Task.FromResult(surface);
}
public Task<IReadOnlyList<TriggerMethodInfo>> GetTriggersAsync(Guid surfaceId, int maxCount, CancellationToken cancellationToken = default)
{
if (_triggers.TryGetValue(surfaceId, out var triggers))
{
return Task.FromResult<IReadOnlyList<TriggerMethodInfo>>(triggers);
}
return Task.FromResult<IReadOnlyList<TriggerMethodInfo>>(new List<TriggerMethodInfo>());
}
public Task<IReadOnlyList<string>> GetSinksAsync(Guid surfaceId, CancellationToken cancellationToken = default)
{
if (_sinks.TryGetValue(surfaceId, out var sinks))
{
return Task.FromResult<IReadOnlyList<string>>(sinks);
}
return Task.FromResult<IReadOnlyList<string>>(new List<string>());
}
public Task<bool> ExistsAsync(string cveId, string ecosystem, string packageName, string version, CancellationToken cancellationToken = default)
{
var key = $"{cveId}|{ecosystem}|{packageName}|{version}";
return Task.FromResult(_surfaces.ContainsKey(key));
}
}
}

View File

@@ -0,0 +1,278 @@
using Org.BouncyCastle.Crypto.Generators;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Security;
using StellaOps.Attestor.Envelope;
using StellaOps.Scanner.Reachability.Witnesses;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests;
/// <summary>
/// Tests for <see cref="WitnessDsseSigner"/>.
/// Sprint: SPRINT_3700_0001_0001 (WIT-007D)
/// Golden fixture tests for DSSE sign/verify.
/// </summary>
public class WitnessDsseSignerTests
{
/// <summary>
/// Creates a deterministic Ed25519 key pair for testing.
/// </summary>
private static (byte[] privateKey, byte[] publicKey) CreateTestKeyPair()
{
// Use a fixed seed for deterministic tests
var generator = new Ed25519KeyPairGenerator();
generator.Init(new Ed25519KeyGenerationParameters(new SecureRandom(new FixedRandomGenerator())));
var keyPair = generator.GenerateKeyPair();
var privateParams = (Ed25519PrivateKeyParameters)keyPair.Private;
var publicParams = (Ed25519PublicKeyParameters)keyPair.Public;
// Ed25519 private key = 32-byte seed + 32-byte public key
var privateKey = new byte[64];
privateParams.Encode(privateKey, 0);
var publicKey = publicParams.GetEncoded();
// Append public key to make 64-byte expanded form
Array.Copy(publicKey, 0, privateKey, 32, 32);
return (privateKey, publicKey);
}
[Fact]
public void SignWitness_WithValidKey_ReturnsSuccess()
{
// Arrange
var witness = CreateTestWitness();
var (privateKey, publicKey) = CreateTestKeyPair();
var key = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
var signer = new WitnessDsseSigner();
// Act
var result = signer.SignWitness(witness, key);
// Assert
Assert.True(result.IsSuccess, result.Error);
Assert.NotNull(result.Envelope);
Assert.Equal(WitnessSchema.DssePayloadType, result.Envelope.PayloadType);
Assert.Single(result.Envelope.Signatures);
Assert.NotEmpty(result.PayloadBytes!);
}
[Fact]
public void VerifyWitness_WithValidSignature_ReturnsSuccess()
{
// Arrange
var witness = CreateTestWitness();
var (privateKey, publicKey) = CreateTestKeyPair();
var signingKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
var signer = new WitnessDsseSigner();
// Sign the witness
var signResult = signer.SignWitness(witness, signingKey);
Assert.True(signResult.IsSuccess, signResult.Error);
// Create public key for verification
var verifyKey = EnvelopeKey.CreateEd25519Verifier(publicKey);
// Act
var verifyResult = signer.VerifyWitness(signResult.Envelope!, verifyKey);
// Assert
Assert.True(verifyResult.IsSuccess, verifyResult.Error);
Assert.NotNull(verifyResult.Witness);
Assert.Equal(witness.WitnessId, verifyResult.Witness.WitnessId);
Assert.Equal(witness.Vuln.Id, verifyResult.Witness.Vuln.Id);
}
[Fact]
public void VerifyWitness_WithWrongKey_ReturnsFails()
{
// Arrange
var witness = CreateTestWitness();
var (privateKey, publicKey) = CreateTestKeyPair();
var signingKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
var signer = new WitnessDsseSigner();
// Sign the witness
var signResult = signer.SignWitness(witness, signingKey);
Assert.True(signResult.IsSuccess, signResult.Error);
// Create a different key for verification (different keyId)
var generator = new Ed25519KeyPairGenerator();
generator.Init(new Ed25519KeyGenerationParameters(new SecureRandom()));
var wrongKeyPair = generator.GenerateKeyPair();
var wrongPublicKey = ((Ed25519PublicKeyParameters)wrongKeyPair.Public).GetEncoded();
var wrongKey = EnvelopeKey.CreateEd25519Verifier(wrongPublicKey);
// Act - verify with wrong key (keyId won't match)
var verifyResult = signer.VerifyWitness(signResult.Envelope!, wrongKey);
// Assert
Assert.False(verifyResult.IsSuccess);
Assert.Contains("No signature found for key ID", verifyResult.Error);
}
[Fact]
public void SignWitness_ProducesDeterministicPayload()
{
// Arrange
var witness = CreateTestWitness();
var (privateKey, publicKey) = CreateTestKeyPair();
var key = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
var signer = new WitnessDsseSigner();
// Act
var result1 = signer.SignWitness(witness, key);
var result2 = signer.SignWitness(witness, key);
// Assert: payloads should be identical (deterministic serialization)
Assert.True(result1.IsSuccess);
Assert.True(result2.IsSuccess);
Assert.Equal(result1.PayloadBytes, result2.PayloadBytes);
}
[Fact]
public void VerifyWitness_WithInvalidPayloadType_ReturnsFails()
{
// Arrange
var witness = CreateTestWitness();
var (privateKey, publicKey) = CreateTestKeyPair();
var signingKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
var signer = new WitnessDsseSigner();
var signResult = signer.SignWitness(witness, signingKey);
Assert.True(signResult.IsSuccess);
// Create envelope with wrong payload type
var wrongEnvelope = new DsseEnvelope(
payloadType: "application/wrong-type",
payload: signResult.Envelope!.Payload,
signatures: signResult.Envelope.Signatures);
var verifyKey = EnvelopeKey.CreateEd25519Verifier(publicKey);
// Act
var verifyResult = signer.VerifyWitness(wrongEnvelope, verifyKey);
// Assert
Assert.False(verifyResult.IsSuccess);
Assert.Contains("Invalid payload type", verifyResult.Error);
}
[Fact]
public void RoundTrip_PreservesAllWitnessFields()
{
// Arrange
var witness = CreateTestWitness();
var (privateKey, publicKey) = CreateTestKeyPair();
var signingKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
var verifyKey = EnvelopeKey.CreateEd25519Verifier(publicKey);
var signer = new WitnessDsseSigner();
// Act
var signResult = signer.SignWitness(witness, signingKey);
var verifyResult = signer.VerifyWitness(signResult.Envelope!, verifyKey);
// Assert
Assert.True(signResult.IsSuccess);
Assert.True(verifyResult.IsSuccess);
var roundTripped = verifyResult.Witness!;
Assert.Equal(witness.WitnessSchema, roundTripped.WitnessSchema);
Assert.Equal(witness.WitnessId, roundTripped.WitnessId);
Assert.Equal(witness.Artifact.SbomDigest, roundTripped.Artifact.SbomDigest);
Assert.Equal(witness.Artifact.ComponentPurl, roundTripped.Artifact.ComponentPurl);
Assert.Equal(witness.Vuln.Id, roundTripped.Vuln.Id);
Assert.Equal(witness.Vuln.Source, roundTripped.Vuln.Source);
Assert.Equal(witness.Entrypoint.Kind, roundTripped.Entrypoint.Kind);
Assert.Equal(witness.Entrypoint.Name, roundTripped.Entrypoint.Name);
Assert.Equal(witness.Entrypoint.SymbolId, roundTripped.Entrypoint.SymbolId);
Assert.Equal(witness.Sink.Symbol, roundTripped.Sink.Symbol);
Assert.Equal(witness.Sink.SymbolId, roundTripped.Sink.SymbolId);
Assert.Equal(witness.Sink.SinkType, roundTripped.Sink.SinkType);
Assert.Equal(witness.Path.Count, roundTripped.Path.Count);
Assert.Equal(witness.Evidence.CallgraphDigest, roundTripped.Evidence.CallgraphDigest);
}
private static PathWitness CreateTestWitness()
{
return new PathWitness
{
WitnessId = "wit:sha256:abc123def456",
Artifact = new WitnessArtifact
{
SbomDigest = "sha256:sbom123456",
ComponentPurl = "pkg:nuget/Newtonsoft.Json@12.0.3"
},
Vuln = new WitnessVuln
{
Id = "CVE-2024-12345",
Source = "NVD",
AffectedRange = "<=12.0.3"
},
Entrypoint = new WitnessEntrypoint
{
Kind = "http",
Name = "GET /api/users",
SymbolId = "sym:entry:001"
},
Path = new List<PathStep>
{
new PathStep
{
Symbol = "UserController.GetUsers",
SymbolId = "sym:step:001",
File = "Controllers/UserController.cs",
Line = 42
},
new PathStep
{
Symbol = "JsonConvert.DeserializeObject",
SymbolId = "sym:step:002",
File = null,
Line = null
}
},
Sink = new WitnessSink
{
Symbol = "JsonConvert.DeserializeObject<T>",
SymbolId = "sym:sink:001",
SinkType = "deserialization"
},
Evidence = new WitnessEvidence
{
CallgraphDigest = "blake3:graph123456",
SurfaceDigest = "sha256:surface789",
BuildId = "build:xyz123"
},
ObservedAt = new DateTimeOffset(2025, 12, 19, 12, 0, 0, TimeSpan.Zero)
};
}
/// <summary>
/// Fixed random generator for deterministic key generation in tests.
/// </summary>
private sealed class FixedRandomGenerator : Org.BouncyCastle.Crypto.Prng.IRandomGenerator
{
private byte _value = 0x42;
public void AddSeedMaterial(byte[] seed) { }
public void AddSeedMaterial(ReadOnlySpan<byte> seed) { }
public void AddSeedMaterial(long seed) { }
public void NextBytes(byte[] bytes) => NextBytes(bytes, 0, bytes.Length);
public void NextBytes(byte[] bytes, int start, int len)
{
for (int i = start; i < start + len; i++)
{
bytes[i] = _value++;
}
}
public void NextBytes(Span<byte> bytes)
{
for (int i = 0; i < bytes.Length; i++)
{
bytes[i] = _value++;
}
}
}
}