311 lines
11 KiB
C#
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)}";
|
|
}
|
|
}
|