feat: add Attestation Chain and Triage Evidence API clients and models

- Implemented Attestation Chain API client with methods for verifying, fetching, and managing attestation chains.
- Created models for Attestation Chain, including DSSE envelope structures and verification results.
- Developed Triage Evidence API client for fetching finding evidence, including methods for evidence retrieval by CVE and component.
- Added models for Triage Evidence, encapsulating evidence responses, entry points, boundary proofs, and VEX evidence.
- Introduced mock implementations for both API clients to facilitate testing and development.
This commit is contained in:
master
2025-12-18 13:15:13 +02:00
parent 7d5250238c
commit 00d2c99af9
118 changed files with 13463 additions and 151 deletions

View File

@@ -0,0 +1,387 @@
using StellaOps.Cryptography;
using StellaOps.Scanner.Reachability.Gates;
using StellaOps.Scanner.Reachability.Witnesses;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests;
public class PathWitnessBuilderTests
{
private readonly ICryptoHash _cryptoHash;
private readonly TimeProvider _timeProvider;
public PathWitnessBuilderTests()
{
_cryptoHash = DefaultCryptoHash.CreateForTests();
_timeProvider = TimeProvider.System;
}
[Fact]
public async Task BuildAsync_ReturnsNull_WhenNoPathExists()
{
// Arrange
var graph = CreateSimpleGraph();
var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider);
var request = new PathWitnessRequest
{
SbomDigest = "sha256:abc123",
ComponentPurl = "pkg:nuget/Newtonsoft.Json@12.0.3",
VulnId = "CVE-2024-12345",
VulnSource = "NVD",
AffectedRange = "<=12.0.3",
EntrypointSymbolId = "sym:entry1",
EntrypointKind = "http",
EntrypointName = "GET /api/test",
SinkSymbolId = "sym:unreachable", // Not in graph
SinkType = "deserialization",
CallGraph = graph,
CallgraphDigest = "blake3:abc123"
};
// Act
var result = await builder.BuildAsync(request);
// Assert
Assert.Null(result);
}
[Fact]
public async Task BuildAsync_ReturnsWitness_WhenPathExists()
{
// Arrange
var graph = CreateSimpleGraph();
var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider);
var request = new PathWitnessRequest
{
SbomDigest = "sha256:abc123",
ComponentPurl = "pkg:nuget/Newtonsoft.Json@12.0.3",
VulnId = "CVE-2024-12345",
VulnSource = "NVD",
AffectedRange = "<=12.0.3",
EntrypointSymbolId = "sym:entry1",
EntrypointKind = "http",
EntrypointName = "GET /api/test",
SinkSymbolId = "sym:sink1",
SinkType = "deserialization",
CallGraph = graph,
CallgraphDigest = "blake3:abc123"
};
// Act
var result = await builder.BuildAsync(request);
// Assert
Assert.NotNull(result);
Assert.Equal(WitnessSchema.Version, result.WitnessSchema);
Assert.StartsWith(WitnessSchema.WitnessIdPrefix, result.WitnessId);
Assert.Equal("CVE-2024-12345", result.Vuln.Id);
Assert.Equal("sym:entry1", result.Entrypoint.SymbolId);
Assert.Equal("sym:sink1", result.Sink.SymbolId);
Assert.NotEmpty(result.Path);
}
[Fact]
public async Task BuildAsync_GeneratesContentAddressedWitnessId()
{
// Arrange
var graph = CreateSimpleGraph();
var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider);
var request = new PathWitnessRequest
{
SbomDigest = "sha256:abc123",
ComponentPurl = "pkg:nuget/Newtonsoft.Json@12.0.3",
VulnId = "CVE-2024-12345",
VulnSource = "NVD",
AffectedRange = "<=12.0.3",
EntrypointSymbolId = "sym:entry1",
EntrypointKind = "http",
EntrypointName = "GET /api/test",
SinkSymbolId = "sym:sink1",
SinkType = "deserialization",
CallGraph = graph,
CallgraphDigest = "blake3:abc123"
};
// Act
var result1 = await builder.BuildAsync(request);
var result2 = await builder.BuildAsync(request);
// Assert
Assert.NotNull(result1);
Assert.NotNull(result2);
// The witness ID should be deterministic (same input = same hash)
// Note: ObservedAt differs, but witness ID is computed without it
Assert.Equal(result1.WitnessId, result2.WitnessId);
}
[Fact]
public async Task BuildAsync_PopulatesArtifactInfo()
{
// Arrange
var graph = CreateSimpleGraph();
var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider);
var request = new PathWitnessRequest
{
SbomDigest = "sha256:sbom123",
ComponentPurl = "pkg:npm/lodash@4.17.21",
VulnId = "CVE-2024-99999",
VulnSource = "GHSA",
AffectedRange = "<4.17.21",
EntrypointSymbolId = "sym:entry1",
EntrypointKind = "grpc",
EntrypointName = "UserService.GetUser",
SinkSymbolId = "sym:sink1",
SinkType = "prototype_pollution",
CallGraph = graph,
CallgraphDigest = "blake3:graph456"
};
// Act
var result = await builder.BuildAsync(request);
// Assert
Assert.NotNull(result);
Assert.Equal("sha256:sbom123", result.Artifact.SbomDigest);
Assert.Equal("pkg:npm/lodash@4.17.21", result.Artifact.ComponentPurl);
}
[Fact]
public async Task BuildAsync_PopulatesEvidenceInfo()
{
// Arrange
var graph = CreateSimpleGraph();
var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider);
var request = new PathWitnessRequest
{
SbomDigest = "sha256:abc123",
ComponentPurl = "pkg:nuget/Test@1.0.0",
VulnId = "CVE-2024-12345",
VulnSource = "NVD",
AffectedRange = "<=1.0.0",
EntrypointSymbolId = "sym:entry1",
EntrypointKind = "http",
EntrypointName = "TestController.Get",
SinkSymbolId = "sym:sink1",
SinkType = "sql_injection",
CallGraph = graph,
CallgraphDigest = "blake3:callgraph789",
SurfaceDigest = "sha256:surface123",
AnalysisConfigDigest = "sha256:config456",
BuildId = "build:xyz789"
};
// Act
var result = await builder.BuildAsync(request);
// Assert
Assert.NotNull(result);
Assert.Equal("blake3:callgraph789", result.Evidence.CallgraphDigest);
Assert.Equal("sha256:surface123", result.Evidence.SurfaceDigest);
Assert.Equal("sha256:config456", result.Evidence.AnalysisConfigDigest);
Assert.Equal("build:xyz789", result.Evidence.BuildId);
}
[Fact]
public async Task BuildAsync_FindsShortestPath()
{
// Arrange - graph with multiple paths
var graph = CreateGraphWithMultiplePaths();
var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider);
var request = new PathWitnessRequest
{
SbomDigest = "sha256:abc123",
ComponentPurl = "pkg:nuget/Test@1.0.0",
VulnId = "CVE-2024-12345",
VulnSource = "NVD",
AffectedRange = "<=1.0.0",
EntrypointSymbolId = "sym:start",
EntrypointKind = "http",
EntrypointName = "Start",
SinkSymbolId = "sym:end",
SinkType = "deserialization",
CallGraph = graph,
CallgraphDigest = "blake3:abc123"
};
// Act
var result = await builder.BuildAsync(request);
// Assert
Assert.NotNull(result);
// Short path: start -> direct -> end (3 steps)
// Long path: start -> long1 -> long2 -> long3 -> end (5 steps)
Assert.Equal(3, result.Path.Count);
Assert.Equal("sym:start", result.Path[0].SymbolId);
Assert.Equal("sym:direct", result.Path[1].SymbolId);
Assert.Equal("sym:end", result.Path[2].SymbolId);
}
[Fact]
public async Task BuildAllAsync_YieldsMultipleWitnesses_WhenMultipleRootsReachSink()
{
// Arrange
var graph = CreateGraphWithMultipleRoots();
var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider);
var request = new BatchWitnessRequest
{
SbomDigest = "sha256:abc123",
ComponentPurl = "pkg:nuget/Test@1.0.0",
VulnId = "CVE-2024-12345",
VulnSource = "NVD",
AffectedRange = "<=1.0.0",
SinkSymbolId = "sym:sink",
SinkType = "deserialization",
CallGraph = graph,
CallgraphDigest = "blake3:abc123",
MaxWitnesses = 10
};
// Act
var witnesses = new List<PathWitness>();
await foreach (var witness in builder.BuildAllAsync(request))
{
witnesses.Add(witness);
}
// Assert
Assert.Equal(2, witnesses.Count);
Assert.Contains(witnesses, w => w.Entrypoint.SymbolId == "sym:root1");
Assert.Contains(witnesses, w => w.Entrypoint.SymbolId == "sym:root2");
}
[Fact]
public async Task BuildAllAsync_RespectsMaxWitnesses()
{
// Arrange
var graph = CreateGraphWithMultipleRoots();
var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider);
var request = new BatchWitnessRequest
{
SbomDigest = "sha256:abc123",
ComponentPurl = "pkg:nuget/Test@1.0.0",
VulnId = "CVE-2024-12345",
VulnSource = "NVD",
AffectedRange = "<=1.0.0",
SinkSymbolId = "sym:sink",
SinkType = "deserialization",
CallGraph = graph,
CallgraphDigest = "blake3:abc123",
MaxWitnesses = 1 // Limit to 1
};
// Act
var witnesses = new List<PathWitness>();
await foreach (var witness in builder.BuildAllAsync(request))
{
witnesses.Add(witness);
}
// Assert
Assert.Single(witnesses);
}
#region Test Helpers
private static RichGraph CreateSimpleGraph()
{
var nodes = new List<RichGraphNode>
{
new("n1", "sym:entry1", null, null, "dotnet", "method", "Entry1", null, null, null, null),
new("n2", "sym:middle1", null, null, "dotnet", "method", "Middle1", null, null, null, null),
new("n3", "sym:sink1", null, null, "dotnet", "method", "Sink1", 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 RichGraph CreateGraphWithMultiplePaths()
{
var nodes = new List<RichGraphNode>
{
new("n0", "sym:start", null, null, "dotnet", "method", "Start", null, null, null, null),
new("n1", "sym:direct", null, null, "dotnet", "method", "Direct", null, null, null, null),
new("n2", "sym:long1", null, null, "dotnet", "method", "Long1", null, null, null, null),
new("n3", "sym:long2", null, null, "dotnet", "method", "Long2", null, null, null, null),
new("n4", "sym:long3", null, null, "dotnet", "method", "Long3", null, null, null, null),
new("n5", "sym:end", null, null, "dotnet", "method", "End", null, null, null, null)
};
var edges = new List<RichGraphEdge>
{
// Short path: start -> direct -> end
new("n0", "n1", "call", null, null, null, 1.0, null),
new("n1", "n5", "call", null, null, null, 1.0, null),
// Long path: start -> long1 -> long2 -> long3 -> end
new("n0", "n2", "call", null, null, null, 1.0, null),
new("n2", "n3", "call", null, null, null, 1.0, null),
new("n3", "n4", "call", null, null, null, 1.0, null),
new("n4", "n5", "call", null, null, null, 1.0, null)
};
var roots = new List<RichGraphRoot>
{
new("n0", "http", "/api/start")
};
return new RichGraph(
nodes,
edges,
roots,
new RichGraphAnalyzer("test", "1.0.0", null));
}
private static RichGraph CreateGraphWithMultipleRoots()
{
var nodes = new List<RichGraphNode>
{
new("n1", "sym:root1", null, null, "dotnet", "method", "Root1", null, null, null, null),
new("n2", "sym:root2", null, null, "dotnet", "method", "Root2", null, null, null, null),
new("n3", "sym:middle", null, null, "dotnet", "method", "Middle", null, null, null, null),
new("n4", "sym:sink", null, null, "dotnet", "method", "Sink", null, null, null, null)
};
var edges = new List<RichGraphEdge>
{
new("n1", "n3", "call", null, null, null, 1.0, null),
new("n2", "n3", "call", null, null, null, 1.0, null),
new("n3", "n4", "call", null, null, null, 1.0, null)
};
var roots = new List<RichGraphRoot>
{
new("n1", "http", "/api/root1"),
new("n2", "http", "/api/root2")
};
return new RichGraph(
nodes,
edges,
roots,
new RichGraphAnalyzer("test", "1.0.0", null));
}
#endregion
}

View File

@@ -0,0 +1,320 @@
using System.Text.Json;
using StellaOps.Cryptography;
using StellaOps.Scanner.Reachability.Attestation;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests;
/// <summary>
/// Unit tests for <see cref="ReachabilityWitnessDsseBuilder"/>.
/// Sprint: SPRINT_3620_0001_0001
/// Task: RWD-011
/// </summary>
public sealed class ReachabilityWitnessDsseBuilderTests
{
private readonly ReachabilityWitnessDsseBuilder _builder;
private readonly FakeTimeProvider _timeProvider;
public ReachabilityWitnessDsseBuilderTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 12, 18, 10, 0, 0, TimeSpan.Zero));
_builder = new ReachabilityWitnessDsseBuilder(
CryptoHashFactory.CreateDefault(),
_timeProvider);
}
#region BuildStatement Tests
[Fact]
public void BuildStatement_CreatesValidStatement()
{
var graph = CreateTestGraph();
var statement = _builder.BuildStatement(
graph,
graphHash: "blake3:abc123",
subjectDigest: "sha256:def456");
Assert.NotNull(statement);
Assert.Equal("https://in-toto.io/Statement/v1", statement.Type);
Assert.Equal("https://stella.ops/reachabilityWitness/v1", statement.PredicateType);
Assert.Single(statement.Subject);
}
[Fact]
public void BuildStatement_SetsSubjectCorrectly()
{
var graph = CreateTestGraph();
var statement = _builder.BuildStatement(
graph,
graphHash: "blake3:abc123",
subjectDigest: "sha256:imageabc123");
var subject = statement.Subject[0];
Assert.Equal("sha256:imageabc123", subject.Name);
Assert.Equal("imageabc123", subject.Digest["sha256"]);
}
[Fact]
public void BuildStatement_ExtractsPredicateCorrectly()
{
var graph = CreateTestGraph();
var statement = _builder.BuildStatement(
graph,
graphHash: "blake3:abc123",
subjectDigest: "sha256:def456",
graphCasUri: "cas://local/blake3:abc123",
policyHash: "sha256:policy123",
sourceCommit: "abc123def456");
var predicate = statement.Predicate as ReachabilityWitnessStatement;
Assert.NotNull(predicate);
Assert.Equal("stella.ops/reachabilityWitness@v1", predicate.Schema);
Assert.Equal("blake3:abc123", predicate.GraphHash);
Assert.Equal("cas://local/blake3:abc123", predicate.GraphCasUri);
Assert.Equal("sha256:def456", predicate.SubjectDigest);
Assert.Equal("sha256:policy123", predicate.PolicyHash);
Assert.Equal("abc123def456", predicate.SourceCommit);
}
[Fact]
public void BuildStatement_CountsNodesAndEdges()
{
var graph = CreateTestGraph();
var statement = _builder.BuildStatement(
graph,
graphHash: "blake3:abc123",
subjectDigest: "sha256:def456");
var predicate = statement.Predicate as ReachabilityWitnessStatement;
Assert.NotNull(predicate);
Assert.Equal(3, predicate.NodeCount);
Assert.Equal(2, predicate.EdgeCount);
}
[Fact]
public void BuildStatement_CountsEntrypoints()
{
var graph = CreateTestGraphWithRoots();
var statement = _builder.BuildStatement(
graph,
graphHash: "blake3:abc123",
subjectDigest: "sha256:def456");
var predicate = statement.Predicate as ReachabilityWitnessStatement;
Assert.NotNull(predicate);
Assert.Equal(2, predicate.EntrypointCount);
}
[Fact]
public void BuildStatement_UsesProvidedTimestamp()
{
var graph = CreateTestGraph();
var statement = _builder.BuildStatement(
graph,
graphHash: "blake3:abc123",
subjectDigest: "sha256:def456");
var predicate = statement.Predicate as ReachabilityWitnessStatement;
Assert.NotNull(predicate);
Assert.Equal(_timeProvider.GetUtcNow(), predicate.GeneratedAt);
}
[Fact]
public void BuildStatement_ExtractsAnalyzerVersion()
{
var graph = CreateTestGraph();
var statement = _builder.BuildStatement(
graph,
graphHash: "blake3:abc123",
subjectDigest: "sha256:def456");
var predicate = statement.Predicate as ReachabilityWitnessStatement;
Assert.NotNull(predicate);
Assert.Equal("1.0.0", predicate.AnalyzerVersion);
}
#endregion
#region SerializeStatement Tests
[Fact]
public void SerializeStatement_ProducesValidJson()
{
var graph = CreateTestGraph();
var statement = _builder.BuildStatement(
graph,
graphHash: "blake3:abc123",
subjectDigest: "sha256:def456");
var bytes = _builder.SerializeStatement(statement);
Assert.NotEmpty(bytes);
var json = System.Text.Encoding.UTF8.GetString(bytes);
Assert.Contains("\"_type\":\"https://in-toto.io/Statement/v1\"", json);
Assert.Contains("\"predicateType\":\"https://stella.ops/reachabilityWitness/v1\"", json);
}
[Fact]
public void SerializeStatement_IsDeterministic()
{
var graph = CreateTestGraph();
var statement = _builder.BuildStatement(
graph,
graphHash: "blake3:abc123",
subjectDigest: "sha256:def456");
var bytes1 = _builder.SerializeStatement(statement);
var bytes2 = _builder.SerializeStatement(statement);
Assert.Equal(bytes1, bytes2);
}
#endregion
#region ComputeStatementHash Tests
[Fact]
public void ComputeStatementHash_ReturnsBlake3Hash()
{
var graph = CreateTestGraph();
var statement = _builder.BuildStatement(
graph,
graphHash: "blake3:abc123",
subjectDigest: "sha256:def456");
var bytes = _builder.SerializeStatement(statement);
var hash = _builder.ComputeStatementHash(bytes);
Assert.StartsWith("blake3:", hash);
Assert.Equal(64 + 7, hash.Length); // "blake3:" + 64 hex chars
}
[Fact]
public void ComputeStatementHash_IsDeterministic()
{
var graph = CreateTestGraph();
var statement = _builder.BuildStatement(
graph,
graphHash: "blake3:abc123",
subjectDigest: "sha256:def456");
var bytes = _builder.SerializeStatement(statement);
var hash1 = _builder.ComputeStatementHash(bytes);
var hash2 = _builder.ComputeStatementHash(bytes);
Assert.Equal(hash1, hash2);
}
#endregion
#region Edge Cases
[Fact]
public void BuildStatement_ThrowsForNullGraph()
{
Assert.Throws<ArgumentNullException>(() =>
_builder.BuildStatement(null!, "blake3:abc", "sha256:def"));
}
[Fact]
public void BuildStatement_ThrowsForEmptyGraphHash()
{
var graph = CreateTestGraph();
Assert.Throws<ArgumentException>(() =>
_builder.BuildStatement(graph, "", "sha256:def"));
}
[Fact]
public void BuildStatement_ThrowsForEmptySubjectDigest()
{
var graph = CreateTestGraph();
Assert.Throws<ArgumentException>(() =>
_builder.BuildStatement(graph, "blake3:abc", ""));
}
[Fact]
public void BuildStatement_HandlesEmptyGraph()
{
var graph = new RichGraph(
Schema: "richgraph-v1",
Analyzer: new RichGraphAnalyzer("test", "1.0.0", null),
Nodes: Array.Empty<RichGraphNode>(),
Edges: Array.Empty<RichGraphEdge>(),
Roots: null);
var statement = _builder.BuildStatement(
graph,
graphHash: "blake3:abc123",
subjectDigest: "sha256:def456");
var predicate = statement.Predicate as ReachabilityWitnessStatement;
Assert.NotNull(predicate);
Assert.Equal(0, predicate.NodeCount);
Assert.Equal(0, predicate.EdgeCount);
Assert.Equal("unknown", predicate.Language);
}
#endregion
#region Test Helpers
private static RichGraph CreateTestGraph()
{
return new RichGraph(
Schema: "richgraph-v1",
Analyzer: new RichGraphAnalyzer("test-analyzer", "1.0.0", null),
Nodes: new[]
{
new RichGraphNode("n1", "sym:dotnet:A", null, null, "dotnet", "method", "A", null, null, null, null),
new RichGraphNode("n2", "sym:dotnet:B", null, null, "dotnet", "method", "B", null, null, null, null),
new RichGraphNode("n3", "sym:dotnet:C", null, null, "dotnet", "sink", "C", null, null, null, null)
},
Edges: new[]
{
new RichGraphEdge("n1", "n2", "call", null, null, null, 0.9, null),
new RichGraphEdge("n2", "n3", "call", null, null, null, 0.9, null)
},
Roots: null);
}
private static RichGraph CreateTestGraphWithRoots()
{
return new RichGraph(
Schema: "richgraph-v1",
Analyzer: new RichGraphAnalyzer("test-analyzer", "1.0.0", null),
Nodes: new[]
{
new RichGraphNode("n1", "sym:dotnet:A", null, null, "dotnet", "method", "A", null, null, null, null),
new RichGraphNode("n2", "sym:dotnet:B", null, null, "dotnet", "method", "B", null, null, null, null),
new RichGraphNode("n3", "sym:dotnet:C", null, null, "dotnet", "sink", "C", null, null, null, null)
},
Edges: new[]
{
new RichGraphEdge("n1", "n2", "call", null, null, null, 0.9, null),
new RichGraphEdge("n2", "n3", "call", null, null, null, 0.9, null)
},
Roots: new[]
{
new RichGraphRoot("n1", "http", "GET /api"),
new RichGraphRoot("n2", "grpc", "Service.Method")
});
}
private sealed class FakeTimeProvider : TimeProvider
{
private readonly DateTimeOffset _fixedTime;
public FakeTimeProvider(DateTimeOffset fixedTime) => _fixedTime = fixedTime;
public override DateTimeOffset GetUtcNow() => _fixedTime;
}
#endregion
}

View File

@@ -108,4 +108,30 @@ public class RichGraphWriterTests
Assert.Contains("\"type\":\"authRequired\"", json);
Assert.Contains("\"guard_symbol\":\"sym:dotnet:B\"", json);
}
[Fact]
public async Task UsesBlake3HashForDefaultProfile()
{
// WIT-013: Verify BLAKE3 is used for graph hashing
var writer = new RichGraphWriter(CryptoHashFactory.CreateDefault());
using var temp = new TempDir();
var union = new ReachabilityUnionGraph(
Nodes: new[]
{
new ReachabilityUnionNode("sym:dotnet:A", "dotnet", "method", "A")
},
Edges: Array.Empty<ReachabilityUnionEdge>());
var rich = RichGraphBuilder.FromUnion(union, "test-analyzer", "1.0.0");
var result = await writer.WriteAsync(rich, temp.Path, "analysis-blake3");
// Default profile (world) uses BLAKE3
Assert.StartsWith("blake3:", result.GraphHash);
Assert.Equal(64 + 7, result.GraphHash.Length); // "blake3:" (7) + 64 hex chars
// Verify meta.json also contains the blake3-prefixed hash
var metaJson = await File.ReadAllTextAsync(result.MetaPath);
Assert.Contains("\"graph_hash\":\"blake3:", metaJson);
}
}