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