Files
git.stella-ops.org/src/Scanner/__Tests/StellaOps.Scanner.Reachability.Tests/AttestingRichGraphWriterTests.cs

311 lines
11 KiB
C#

// -----------------------------------------------------------------------------
// AttestingRichGraphWriterTests.cs
// Sprint: SPRINT_3620_0001_0001_reachability_witness_dsse
// Description: Tests for AttestingRichGraphWriter integration.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Scanner.Reachability.Attestation;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Scanner.Reachability.Tests;
public class AttestingRichGraphWriterTests : IAsyncLifetime
{
private DirectoryInfo _tempDir = null!;
public Task InitializeAsync()
{
_tempDir = Directory.CreateTempSubdirectory("attesting-writer-test-");
return Task.CompletedTask;
}
public Task DisposeAsync()
{
try
{
if (_tempDir.Exists)
{
_tempDir.Delete(recursive: true);
}
}
catch
{
// Ignore cleanup errors
}
return Task.CompletedTask;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task WriteWithAttestationAsync_WhenEnabled_ProducesAttestationFile()
{
// Arrange
var cryptoHash = new TestCryptoHash();
var graphWriter = new RichGraphWriter(cryptoHash);
var witnessOptions = Options.Create(new ReachabilityWitnessOptions
{
Enabled = true,
StoreInCas = false,
PublishToRekor = false
});
var witnessPublisher = new ReachabilityWitnessPublisher(
witnessOptions,
cryptoHash,
NullLogger<ReachabilityWitnessPublisher>.Instance);
var writer = new AttestingRichGraphWriter(
graphWriter,
witnessPublisher,
witnessOptions,
NullLogger<AttestingRichGraphWriter>.Instance);
var graph = CreateTestGraph();
// Act
var result = await writer.WriteWithAttestationAsync(
graph,
_tempDir.FullName,
"test-analysis",
"sha256:abc123");
// Assert
Assert.NotNull(result);
Assert.True(File.Exists(result.GraphPath));
Assert.True(File.Exists(result.MetaPath));
Assert.NotNull(result.AttestationPath);
Assert.True(File.Exists(result.AttestationPath));
Assert.NotNull(result.WitnessResult);
Assert.NotEmpty(result.WitnessResult.StatementHash);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task WriteWithAttestationAsync_WhenDisabled_NoAttestationFile()
{
// Arrange
var cryptoHash = new TestCryptoHash();
var graphWriter = new RichGraphWriter(cryptoHash);
var witnessOptions = Options.Create(new ReachabilityWitnessOptions
{
Enabled = false
});
var witnessPublisher = new ReachabilityWitnessPublisher(
witnessOptions,
cryptoHash,
NullLogger<ReachabilityWitnessPublisher>.Instance);
var writer = new AttestingRichGraphWriter(
graphWriter,
witnessPublisher,
witnessOptions,
NullLogger<AttestingRichGraphWriter>.Instance);
var graph = CreateTestGraph();
// Act
var result = await writer.WriteWithAttestationAsync(
graph,
_tempDir.FullName,
"test-analysis",
"sha256:abc123");
// Assert
Assert.NotNull(result);
Assert.True(File.Exists(result.GraphPath));
Assert.True(File.Exists(result.MetaPath));
Assert.Null(result.AttestationPath);
Assert.Null(result.WitnessResult);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task WriteWithAttestationAsync_AttestationContainsValidDsse()
{
// Arrange
var cryptoHash = new TestCryptoHash();
var graphWriter = new RichGraphWriter(cryptoHash);
var witnessOptions = Options.Create(new ReachabilityWitnessOptions
{
Enabled = true,
StoreInCas = false,
PublishToRekor = false
});
var witnessPublisher = new ReachabilityWitnessPublisher(
witnessOptions,
cryptoHash,
NullLogger<ReachabilityWitnessPublisher>.Instance);
var writer = new AttestingRichGraphWriter(
graphWriter,
witnessPublisher,
witnessOptions,
NullLogger<AttestingRichGraphWriter>.Instance);
var graph = CreateTestGraph();
// Act
var result = await writer.WriteWithAttestationAsync(
graph,
_tempDir.FullName,
"test-analysis",
"sha256:abc123");
// Assert
Assert.NotNull(result.AttestationPath);
var dsseJson = await File.ReadAllTextAsync(result.AttestationPath);
Assert.Contains("payloadType", dsseJson);
// Note: + may be encoded as \u002B in JSON
Assert.True(dsseJson.Contains("application/vnd.in-toto+json") || dsseJson.Contains("application/vnd.in-toto\\u002Bjson"));
Assert.Contains("payload", dsseJson);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task WriteWithAttestationAsync_GraphHashIsDeterministic()
{
// Arrange
var cryptoHash = new TestCryptoHash();
var graphWriter = new RichGraphWriter(cryptoHash);
var witnessOptions = Options.Create(new ReachabilityWitnessOptions
{
Enabled = true,
StoreInCas = false,
PublishToRekor = false
});
var witnessPublisher = new ReachabilityWitnessPublisher(
witnessOptions,
cryptoHash,
NullLogger<ReachabilityWitnessPublisher>.Instance);
var writer = new AttestingRichGraphWriter(
graphWriter,
witnessPublisher,
witnessOptions,
NullLogger<AttestingRichGraphWriter>.Instance);
var graph = CreateTestGraph();
// Act - write twice with same input
var result1 = await writer.WriteWithAttestationAsync(
graph,
_tempDir.FullName,
"analysis-1",
"sha256:abc123");
var result2 = await writer.WriteWithAttestationAsync(
graph,
_tempDir.FullName,
"analysis-2",
"sha256:abc123");
// Assert - same graph should produce same hash
Assert.Equal(result1.GraphHash, result2.GraphHash);
}
private static RichGraph CreateTestGraph()
{
return new RichGraph(
Nodes: new[]
{
new RichGraphNode(
Id: "entry-1",
SymbolId: "Handler.handle",
CodeId: null,
Purl: "pkg:maven/com.example/handler@1.0.0",
Lang: "java",
Kind: "http_handler",
Display: "GET /api/users",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: "sha256:entry1digest"),
new RichGraphNode(
Id: "sink-1",
SymbolId: "DB.executeQuery",
CodeId: null,
Purl: "pkg:maven/org.database/driver@2.0.0",
Lang: "java",
Kind: "sql_sink",
Display: "executeQuery(String)",
BuildId: null,
Evidence: null,
Attributes: new Dictionary<string, string> { ["is_sink"] = "true" },
SymbolDigest: "sha256:sink1digest")
},
Edges: new[]
{
new RichGraphEdge(
From: "entry-1",
To: "sink-1",
Kind: "call",
Purl: null,
SymbolDigest: null,
Evidence: null,
Confidence: 1.0,
Candidates: null)
},
Roots: new[]
{
new RichGraphRoot("entry-1", "runtime", null)
},
Analyzer: new RichGraphAnalyzer("stellaops.scanner.reachability", "1.0.0", null),
Schema: "richgraph-v1"
);
}
/// <summary>
/// Test crypto hash implementation.
/// </summary>
private sealed class TestCryptoHash : ICryptoHash
{
public byte[] ComputeHash(ReadOnlySpan<byte> data, string? algorithmId = null)
=> System.Security.Cryptography.SHA256.HashData(data);
public string ComputeHashHex(ReadOnlySpan<byte> data, string? algorithmId = null)
=> Convert.ToHexString(ComputeHash(data, algorithmId)).ToLowerInvariant();
public string ComputeHashBase64(ReadOnlySpan<byte> data, string? algorithmId = null)
=> Convert.ToBase64String(ComputeHash(data, algorithmId));
public async ValueTask<byte[]> ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
{
using var buffer = new MemoryStream();
await stream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
return System.Security.Cryptography.SHA256.HashData(buffer.ToArray());
}
public async ValueTask<string> ComputeHashHexAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
{
var hash = await ComputeHashAsync(stream, algorithmId, cancellationToken).ConfigureAwait(false);
return Convert.ToHexString(hash).ToLowerInvariant();
}
public byte[] ComputeHashForPurpose(ReadOnlySpan<byte> data, string purpose)
=> ComputeHash(data);
public string ComputeHashHexForPurpose(ReadOnlySpan<byte> data, string purpose)
=> ComputeHashHex(data);
public string ComputeHashBase64ForPurpose(ReadOnlySpan<byte> data, string purpose)
=> ComputeHashBase64(data);
public async ValueTask<byte[]> ComputeHashForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
=> await ComputeHashAsync(stream, null, cancellationToken).ConfigureAwait(false);
public async ValueTask<string> ComputeHashHexForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
=> await ComputeHashHexAsync(stream, null, cancellationToken).ConfigureAwait(false);
public string GetAlgorithmForPurpose(string purpose) => "blake3";
public string GetHashPrefix(string purpose) => "blake3:";
public string ComputePrefixedHashForPurpose(ReadOnlySpan<byte> data, string purpose)
=> $"blake3:{ComputeHashHex(data)}";
}
}