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,281 @@
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Xunit;
namespace StellaOps.Scanner.Analyzers.Native.Index.Tests;
/// <summary>
/// Unit tests for <see cref="OfflineBuildIdIndex"/>.
/// </summary>
public sealed class OfflineBuildIdIndexTests : IDisposable
{
private readonly string _tempDir;
public OfflineBuildIdIndexTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), $"buildid-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir);
}
public void Dispose()
{
if (Directory.Exists(_tempDir))
{
Directory.Delete(_tempDir, recursive: true);
}
}
#region Loading Tests
[Fact]
public async Task LoadAsync_EmptyIndex_WhenNoPathConfigured()
{
var options = Options.Create(new BuildIdIndexOptions { IndexPath = null });
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
await index.LoadAsync();
Assert.True(index.IsLoaded);
Assert.Equal(0, index.Count);
}
[Fact]
public async Task LoadAsync_EmptyIndex_WhenFileNotFound()
{
var options = Options.Create(new BuildIdIndexOptions { IndexPath = "/nonexistent/file.ndjson" });
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
await index.LoadAsync();
Assert.True(index.IsLoaded);
Assert.Equal(0, index.Count);
}
[Fact]
public async Task LoadAsync_ParsesNdjsonEntries()
{
var indexPath = Path.Combine(_tempDir, "index.ndjson");
await File.WriteAllTextAsync(indexPath, """
{"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31","distro":"debian","confidence":"exact","indexed_at":"2025-01-15T10:00:00Z"}
{"build_id":"pe-cv:12345678-1234-1234-1234-123456789012-1","purl":"pkg:nuget/System.Text.Json@8.0.0","confidence":"inferred"}
{"build_id":"macho-uuid:fedcba9876543210fedcba9876543210","purl":"pkg:brew/openssl@3.0.0","distro":"macos","confidence":"exact"}
""");
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
await index.LoadAsync();
Assert.True(index.IsLoaded);
Assert.Equal(3, index.Count);
}
[Fact]
public async Task LoadAsync_SkipsEmptyLines()
{
var indexPath = Path.Combine(_tempDir, "index-empty-lines.ndjson");
await File.WriteAllTextAsync(indexPath, """
{"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"}
{"build_id":"gnu-build-id:def456","purl":"pkg:deb/debian/libssl@1.1"}
""");
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
await index.LoadAsync();
Assert.Equal(2, index.Count);
}
[Fact]
public async Task LoadAsync_SkipsCommentLines()
{
var indexPath = Path.Combine(_tempDir, "index-comments.ndjson");
await File.WriteAllTextAsync(indexPath, """
# This is a comment
{"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"}
// Another comment style
{"build_id":"gnu-build-id:def456","purl":"pkg:deb/debian/libssl@1.1"}
""");
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
await index.LoadAsync();
Assert.Equal(2, index.Count);
}
[Fact]
public async Task LoadAsync_SkipsInvalidJsonLines()
{
var indexPath = Path.Combine(_tempDir, "index-invalid.ndjson");
await File.WriteAllTextAsync(indexPath, """
{"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"}
not valid json at all
{"build_id":"gnu-build-id:def456","purl":"pkg:deb/debian/libssl@1.1"}
""");
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
await index.LoadAsync();
Assert.Equal(2, index.Count);
}
#endregion
#region Lookup Tests
[Fact]
public async Task LookupAsync_ReturnsNull_WhenNotFound()
{
var indexPath = Path.Combine(_tempDir, "index.ndjson");
await File.WriteAllTextAsync(indexPath, """
{"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"}
""");
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
await index.LoadAsync();
var result = await index.LookupAsync("gnu-build-id:notfound");
Assert.Null(result);
}
[Fact]
public async Task LookupAsync_ReturnsNull_ForNullOrEmpty()
{
var indexPath = Path.Combine(_tempDir, "index.ndjson");
await File.WriteAllTextAsync(indexPath, """
{"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"}
""");
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
await index.LoadAsync();
Assert.Null(await index.LookupAsync(null!));
Assert.Null(await index.LookupAsync(""));
Assert.Null(await index.LookupAsync(" "));
}
[Fact]
public async Task LookupAsync_FindsExactMatch()
{
var indexPath = Path.Combine(_tempDir, "index.ndjson");
await File.WriteAllTextAsync(indexPath, """
{"build_id":"gnu-build-id:abc123def456","purl":"pkg:deb/debian/libc6@2.31","version":"2.31","distro":"debian","confidence":"exact","indexed_at":"2025-01-15T10:00:00Z"}
""");
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
await index.LoadAsync();
var result = await index.LookupAsync("gnu-build-id:abc123def456");
Assert.NotNull(result);
Assert.Equal("gnu-build-id:abc123def456", result.BuildId);
Assert.Equal("pkg:deb/debian/libc6@2.31", result.Purl);
Assert.Equal("2.31", result.Version);
Assert.Equal("debian", result.SourceDistro);
Assert.Equal(BuildIdConfidence.Exact, result.Confidence);
}
[Fact]
public async Task LookupAsync_CaseInsensitive()
{
var indexPath = Path.Combine(_tempDir, "index.ndjson");
await File.WriteAllTextAsync(indexPath, """
{"build_id":"gnu-build-id:ABC123DEF456","purl":"pkg:deb/debian/libc6@2.31"}
""");
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
await index.LoadAsync();
// Query with lowercase
var result = await index.LookupAsync("gnu-build-id:abc123def456");
Assert.NotNull(result);
Assert.Equal("pkg:deb/debian/libc6@2.31", result.Purl);
}
#endregion
#region Batch Lookup Tests
[Fact]
public async Task BatchLookupAsync_ReturnsFoundEntries()
{
var indexPath = Path.Combine(_tempDir, "index.ndjson");
await File.WriteAllTextAsync(indexPath, """
{"build_id":"gnu-build-id:aaa","purl":"pkg:deb/debian/liba@1.0"}
{"build_id":"gnu-build-id:bbb","purl":"pkg:deb/debian/libb@1.0"}
{"build_id":"gnu-build-id:ccc","purl":"pkg:deb/debian/libc@1.0"}
""");
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
await index.LoadAsync();
var results = await index.BatchLookupAsync(["gnu-build-id:aaa", "gnu-build-id:notfound", "gnu-build-id:ccc"]);
Assert.Equal(2, results.Count);
Assert.Contains(results, r => r.Purl == "pkg:deb/debian/liba@1.0");
Assert.Contains(results, r => r.Purl == "pkg:deb/debian/libc@1.0");
}
[Fact]
public async Task BatchLookupAsync_SkipsNullAndEmpty()
{
var indexPath = Path.Combine(_tempDir, "index.ndjson");
await File.WriteAllTextAsync(indexPath, """
{"build_id":"gnu-build-id:aaa","purl":"pkg:deb/debian/liba@1.0"}
""");
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
await index.LoadAsync();
var results = await index.BatchLookupAsync([null!, "", " ", "gnu-build-id:aaa"]);
Assert.Single(results);
Assert.Equal("pkg:deb/debian/liba@1.0", results[0].Purl);
}
#endregion
#region Confidence Parsing Tests
[Theory]
[InlineData("exact", BuildIdConfidence.Exact)]
[InlineData("EXACT", BuildIdConfidence.Exact)]
[InlineData("inferred", BuildIdConfidence.Inferred)]
[InlineData("Inferred", BuildIdConfidence.Inferred)]
[InlineData("heuristic", BuildIdConfidence.Heuristic)]
[InlineData("unknown", BuildIdConfidence.Heuristic)] // Defaults to heuristic
[InlineData("", BuildIdConfidence.Heuristic)]
public async Task LoadAsync_ParsesConfidenceLevels(string confidenceValue, BuildIdConfidence expected)
{
var indexPath = Path.Combine(_tempDir, "index.ndjson");
var entry = new { build_id = "gnu-build-id:test", purl = "pkg:test/test@1.0", confidence = confidenceValue };
await File.WriteAllTextAsync(indexPath, JsonSerializer.Serialize(entry));
var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false });
var index = new OfflineBuildIdIndex(options, NullLogger<OfflineBuildIdIndex>.Instance);
await index.LoadAsync();
var result = await index.LookupAsync("gnu-build-id:test");
Assert.NotNull(result);
Assert.Equal(expected, result.Confidence);
}
#endregion
}

View File

@@ -0,0 +1,425 @@
using System.Buffers.Binary;
using System.Text;
using Xunit;
namespace StellaOps.Scanner.Analyzers.Native.Tests;
/// <summary>
/// Unit tests for <see cref="MachOReader"/>.
/// </summary>
public sealed class MachOReaderTests
{
#region Test Data Builders
/// <summary>
/// Builds a minimal 64-bit Mach-O binary for testing.
/// </summary>
private static byte[] BuildMachO64(
int cpuType = 0x0100000C, // arm64
int cpuSubtype = 0,
byte[]? uuid = null,
MachOPlatform platform = MachOPlatform.MacOS,
uint minOs = 0x000E0000, // 14.0
uint sdk = 0x000E0000)
{
var loadCommands = new List<byte[]>();
// Add LC_UUID if provided
if (uuid is { Length: 16 })
{
var uuidCmd = new byte[24];
BinaryPrimitives.WriteUInt32LittleEndian(uuidCmd, 0x1B); // LC_UUID
BinaryPrimitives.WriteUInt32LittleEndian(uuidCmd.AsSpan(4), 24); // cmdsize
Array.Copy(uuid, 0, uuidCmd, 8, 16);
loadCommands.Add(uuidCmd);
}
// Add LC_BUILD_VERSION
var buildVersionCmd = new byte[24];
BinaryPrimitives.WriteUInt32LittleEndian(buildVersionCmd, 0x32); // LC_BUILD_VERSION
BinaryPrimitives.WriteUInt32LittleEndian(buildVersionCmd.AsSpan(4), 24); // cmdsize
BinaryPrimitives.WriteUInt32LittleEndian(buildVersionCmd.AsSpan(8), (uint)platform);
BinaryPrimitives.WriteUInt32LittleEndian(buildVersionCmd.AsSpan(12), minOs);
BinaryPrimitives.WriteUInt32LittleEndian(buildVersionCmd.AsSpan(16), sdk);
BinaryPrimitives.WriteUInt32LittleEndian(buildVersionCmd.AsSpan(20), 0); // ntools
loadCommands.Add(buildVersionCmd);
var sizeOfCmds = loadCommands.Sum(c => c.Length);
// Build header (32 bytes for 64-bit)
var header = new byte[32];
BinaryPrimitives.WriteUInt32LittleEndian(header, 0xFEEDFACF); // MH_MAGIC_64
BinaryPrimitives.WriteInt32LittleEndian(header.AsSpan(4), cpuType);
BinaryPrimitives.WriteInt32LittleEndian(header.AsSpan(8), cpuSubtype);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(12), 2); // MH_EXECUTE
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(16), (uint)loadCommands.Count);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(20), (uint)sizeOfCmds);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(24), 0); // flags
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(28), 0); // reserved
// Combine
var result = new byte[32 + sizeOfCmds];
Array.Copy(header, result, 32);
var offset = 32;
foreach (var cmd in loadCommands)
{
Array.Copy(cmd, 0, result, offset, cmd.Length);
offset += cmd.Length;
}
return result;
}
/// <summary>
/// Builds a minimal 32-bit Mach-O binary for testing.
/// </summary>
private static byte[] BuildMachO32(
int cpuType = 7, // x86
int cpuSubtype = 0,
byte[]? uuid = null)
{
var loadCommands = new List<byte[]>();
// Add LC_UUID if provided
if (uuid is { Length: 16 })
{
var uuidCmd = new byte[24];
BinaryPrimitives.WriteUInt32LittleEndian(uuidCmd, 0x1B); // LC_UUID
BinaryPrimitives.WriteUInt32LittleEndian(uuidCmd.AsSpan(4), 24); // cmdsize
Array.Copy(uuid, 0, uuidCmd, 8, 16);
loadCommands.Add(uuidCmd);
}
var sizeOfCmds = loadCommands.Sum(c => c.Length);
// Build header (28 bytes for 32-bit)
var header = new byte[28];
BinaryPrimitives.WriteUInt32LittleEndian(header, 0xFEEDFACE); // MH_MAGIC
BinaryPrimitives.WriteInt32LittleEndian(header.AsSpan(4), cpuType);
BinaryPrimitives.WriteInt32LittleEndian(header.AsSpan(8), cpuSubtype);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(12), 2); // MH_EXECUTE
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(16), (uint)loadCommands.Count);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(20), (uint)sizeOfCmds);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(24), 0); // flags
// Combine
var result = new byte[28 + sizeOfCmds];
Array.Copy(header, result, 28);
var offset = 28;
foreach (var cmd in loadCommands)
{
Array.Copy(cmd, 0, result, offset, cmd.Length);
offset += cmd.Length;
}
return result;
}
/// <summary>
/// Builds a fat (universal) binary containing multiple slices.
/// </summary>
private static byte[] BuildFatBinary(params byte[][] slices)
{
// Fat header: magic (4) + nfat_arch (4)
// Fat arch entries: 20 bytes each (cputype, cpusubtype, offset, size, align)
var headerSize = 8 + (slices.Length * 20);
var alignedHeaderSize = (headerSize + 0xFFF) & ~0xFFF; // 4KB alignment
var totalSize = alignedHeaderSize + slices.Sum(s => ((s.Length + 0xFFF) & ~0xFFF));
var result = new byte[totalSize];
// Write fat header (big-endian)
BinaryPrimitives.WriteUInt32BigEndian(result, 0xCAFEBABE); // FAT_MAGIC
BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(4), (uint)slices.Length);
var currentOffset = alignedHeaderSize;
for (var i = 0; i < slices.Length; i++)
{
var slice = slices[i];
var archOffset = 8 + (i * 20);
// Read CPU type from slice header
var cpuType = BinaryPrimitives.ReadUInt32LittleEndian(slice.AsSpan(4));
// Write fat_arch entry (big-endian)
BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(archOffset), cpuType);
BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(archOffset + 4), 0); // cpusubtype
BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(archOffset + 8), (uint)currentOffset);
BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(archOffset + 12), (uint)slice.Length);
BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(archOffset + 16), 12); // align = 2^12 = 4096
// Copy slice
Array.Copy(slice, 0, result, currentOffset, slice.Length);
currentOffset += (slice.Length + 0xFFF) & ~0xFFF; // Align to 4KB
}
return result;
}
#endregion
#region Magic Detection Tests
[Fact]
public void Parse_Returns_Null_For_Empty_Stream()
{
using var stream = new MemoryStream([]);
var result = MachOReader.Parse(stream, "/test/empty");
Assert.Null(result);
}
[Fact]
public void Parse_Returns_Null_For_Invalid_Magic()
{
var data = new byte[] { 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77 };
using var stream = new MemoryStream(data);
var result = MachOReader.Parse(stream, "/test/invalid");
Assert.Null(result);
}
[Fact]
public void Parse_Detects_64Bit_LittleEndian_MachO()
{
var data = BuildMachO64();
using var stream = new MemoryStream(data);
var result = MachOReader.Parse(stream, "/test/arm64");
Assert.NotNull(result);
Assert.Single(result.Identities);
Assert.Equal("arm64", result.Identities[0].CpuType);
Assert.False(result.Identities[0].IsFatBinary);
}
[Fact]
public void Parse_Detects_32Bit_MachO()
{
var data = BuildMachO32(cpuType: 7); // x86
using var stream = new MemoryStream(data);
var result = MachOReader.Parse(stream, "/test/i386");
Assert.NotNull(result);
Assert.Single(result.Identities);
Assert.Equal("i386", result.Identities[0].CpuType);
}
#endregion
#region LC_UUID Tests
[Fact]
public void Parse_Extracts_LC_UUID()
{
var uuid = new byte[] { 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, 0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10 };
var data = BuildMachO64(uuid: uuid);
using var stream = new MemoryStream(data);
var result = MachOReader.Parse(stream, "/test/with-uuid");
Assert.NotNull(result);
Assert.Single(result.Identities);
Assert.Equal("0123456789abcdeffedcba9876543210", result.Identities[0].Uuid);
}
[Fact]
public void Parse_Returns_Null_Uuid_When_Not_Present()
{
var data = BuildMachO64(uuid: null);
using var stream = new MemoryStream(data);
var result = MachOReader.Parse(stream, "/test/no-uuid");
Assert.NotNull(result);
Assert.Single(result.Identities);
Assert.Null(result.Identities[0].Uuid);
}
[Fact]
public void Parse_UUID_Is_Lowercase_Hex_No_Dashes()
{
var uuid = new byte[] { 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, 0x12, 0x34, 0x56, 0x78, 0x9A };
var data = BuildMachO64(uuid: uuid);
using var stream = new MemoryStream(data);
var result = MachOReader.Parse(stream, "/test/uuid-format");
Assert.NotNull(result);
var uuidString = result.Identities[0].Uuid;
Assert.NotNull(uuidString);
Assert.Equal(32, uuidString.Length);
Assert.DoesNotContain("-", uuidString);
Assert.Equal(uuidString.ToLowerInvariant(), uuidString);
}
#endregion
#region Platform Detection Tests
[Theory]
[InlineData(MachOPlatform.MacOS)]
[InlineData(MachOPlatform.iOS)]
[InlineData(MachOPlatform.TvOS)]
[InlineData(MachOPlatform.WatchOS)]
[InlineData(MachOPlatform.MacCatalyst)]
[InlineData(MachOPlatform.VisionOS)]
public void Parse_Extracts_Platform_From_LC_BUILD_VERSION(MachOPlatform expectedPlatform)
{
var data = BuildMachO64(platform: expectedPlatform);
using var stream = new MemoryStream(data);
var result = MachOReader.Parse(stream, "/test/platform");
Assert.NotNull(result);
Assert.Single(result.Identities);
Assert.Equal(expectedPlatform, result.Identities[0].Platform);
}
[Fact]
public void Parse_Extracts_MinOs_Version()
{
var data = BuildMachO64(minOs: 0x000E0500); // 14.5.0
using var stream = new MemoryStream(data);
var result = MachOReader.Parse(stream, "/test/min-os");
Assert.NotNull(result);
Assert.Equal("14.5", result.Identities[0].MinOsVersion);
}
[Fact]
public void Parse_Extracts_SDK_Version()
{
var data = BuildMachO64(sdk: 0x000F0000); // 15.0.0
using var stream = new MemoryStream(data);
var result = MachOReader.Parse(stream, "/test/sdk");
Assert.NotNull(result);
Assert.Equal("15.0", result.Identities[0].SdkVersion);
}
[Fact]
public void Parse_Version_With_Patch()
{
var data = BuildMachO64(minOs: 0x000E0501); // 14.5.1
using var stream = new MemoryStream(data);
var result = MachOReader.Parse(stream, "/test/version-patch");
Assert.NotNull(result);
Assert.Equal("14.5.1", result.Identities[0].MinOsVersion);
}
#endregion
#region CPU Type Tests
[Theory]
[InlineData(0x00000007, "i386")] // CPU_TYPE_X86
[InlineData(0x01000007, "x86_64")] // CPU_TYPE_X86_64
[InlineData(0x0000000C, "arm")] // CPU_TYPE_ARM
[InlineData(0x0100000C, "arm64")] // CPU_TYPE_ARM64
public void Parse_Maps_CpuType_Correctly(int cpuType, string expectedName)
{
var data = BuildMachO64(cpuType: cpuType);
using var stream = new MemoryStream(data);
var result = MachOReader.Parse(stream, "/test/cpu");
Assert.NotNull(result);
Assert.Equal(expectedName, result.Identities[0].CpuType);
}
#endregion
#region Fat Binary Tests
[Fact]
public void Parse_Handles_Fat_Binary()
{
var arm64Slice = BuildMachO64(cpuType: 0x0100000C, uuid: new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10 });
var x64Slice = BuildMachO64(cpuType: 0x01000007, uuid: new byte[] { 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20 });
var fatData = BuildFatBinary(arm64Slice, x64Slice);
using var stream = new MemoryStream(fatData);
var result = MachOReader.Parse(stream, "/test/universal");
Assert.NotNull(result);
Assert.Equal(2, result.Identities.Count);
// Both slices should be marked as fat binary slices
Assert.True(result.Identities[0].IsFatBinary);
Assert.True(result.Identities[1].IsFatBinary);
// Check UUIDs are different
Assert.NotEqual(result.Identities[0].Uuid, result.Identities[1].Uuid);
}
[Fact]
public void ParseFatBinary_Returns_Multiple_Identities()
{
var arm64Slice = BuildMachO64(cpuType: 0x0100000C);
var x64Slice = BuildMachO64(cpuType: 0x01000007);
var fatData = BuildFatBinary(arm64Slice, x64Slice);
using var stream = new MemoryStream(fatData);
var identities = MachOReader.ParseFatBinary(stream);
Assert.Equal(2, identities.Count);
}
#endregion
#region TryExtractIdentity Tests
[Fact]
public void TryExtractIdentity_Returns_True_For_Valid_MachO()
{
var data = BuildMachO64();
using var stream = new MemoryStream(data);
var success = MachOReader.TryExtractIdentity(stream, out var identity);
Assert.True(success);
Assert.NotNull(identity);
Assert.Equal("arm64", identity.CpuType);
}
[Fact]
public void TryExtractIdentity_Returns_False_For_Invalid_Data()
{
var data = new byte[] { 0x00, 0x00, 0x00, 0x00 };
using var stream = new MemoryStream(data);
var success = MachOReader.TryExtractIdentity(stream, out var identity);
Assert.False(success);
Assert.Null(identity);
}
[Fact]
public void TryExtractIdentity_Returns_First_Slice_For_Fat_Binary()
{
var arm64Slice = BuildMachO64(cpuType: 0x0100000C);
var x64Slice = BuildMachO64(cpuType: 0x01000007);
var fatData = BuildFatBinary(arm64Slice, x64Slice);
using var stream = new MemoryStream(fatData);
var success = MachOReader.TryExtractIdentity(stream, out var identity);
Assert.True(success);
Assert.NotNull(identity);
// Should get first slice
Assert.Equal("arm64", identity.CpuType);
}
#endregion
#region Path and LayerDigest Tests
[Fact]
public void Parse_Preserves_Path_And_LayerDigest()
{
var data = BuildMachO64();
using var stream = new MemoryStream(data);
var result = MachOReader.Parse(stream, "/usr/bin/myapp", "sha256:abc123");
Assert.NotNull(result);
Assert.Equal("/usr/bin/myapp", result.Path);
Assert.Equal("sha256:abc123", result.LayerDigest);
}
#endregion
}

View File

@@ -0,0 +1,361 @@
using FluentAssertions;
using StellaOps.Scanner.Analyzers.Native;
using StellaOps.Scanner.Analyzers.Native.Tests.Fixtures;
using StellaOps.Scanner.Analyzers.Native.Tests.TestUtilities;
namespace StellaOps.Scanner.Analyzers.Native.Tests;
/// <summary>
/// Unit tests for PeReader full PE parsing including CodeView GUID, Rich header, and version resources.
/// </summary>
public class PeReaderTests : NativeTestBase
{
#region Basic Parsing
[Fact]
public void TryExtractIdentity_InvalidData_ReturnsFalse()
{
// Arrange
var invalidData = new byte[] { 0x00, 0x01, 0x02, 0x03 };
// Act
var result = PeReader.TryExtractIdentity(invalidData, out var identity);
// Assert
result.Should().BeFalse();
identity.Should().BeNull();
}
[Fact]
public void TryExtractIdentity_TooShort_ReturnsFalse()
{
// Arrange
var shortData = new byte[0x20];
// Act
var result = PeReader.TryExtractIdentity(shortData, out var identity);
// Assert
result.Should().BeFalse();
}
[Fact]
public void TryExtractIdentity_MissingMzSignature_ReturnsFalse()
{
// Arrange
var data = new byte[0x100];
data[0] = (byte)'X';
data[1] = (byte)'Y';
// Act
var result = PeReader.TryExtractIdentity(data, out var identity);
// Assert
result.Should().BeFalse();
}
[Fact]
public void TryExtractIdentity_ValidMinimalPe64_ReturnsTrue()
{
// Arrange
var pe = PeBuilder.Console64().Build();
// Act
var result = PeReader.TryExtractIdentity(pe, out var identity);
// Assert
result.Should().BeTrue();
identity.Should().NotBeNull();
identity!.Is64Bit.Should().BeTrue();
identity.Machine.Should().Be("x86_64");
identity.Subsystem.Should().Be(PeSubsystem.WindowsConsole);
}
[Fact]
public void TryExtractIdentity_ValidMinimalPe32_ReturnsTrue()
{
// Arrange
var pe = new PeBuilder()
.Is64Bit(false)
.WithSubsystem(PeSubsystem.WindowsConsole)
.Build();
// Act
var result = PeReader.TryExtractIdentity(pe, out var identity);
// Assert
result.Should().BeTrue();
identity.Should().NotBeNull();
identity!.Is64Bit.Should().BeFalse();
identity.Machine.Should().Be("x86");
}
[Fact]
public void TryExtractIdentity_GuiSubsystem_ParsesCorrectly()
{
// Arrange
var pe = new PeBuilder()
.Is64Bit(true)
.WithSubsystem(PeSubsystem.WindowsGui)
.Build();
// Act
var result = PeReader.TryExtractIdentity(pe, out var identity);
// Assert
result.Should().BeTrue();
identity.Should().NotBeNull();
identity!.Subsystem.Should().Be(PeSubsystem.WindowsGui);
}
#endregion
#region Parse Method
[Fact]
public void Parse_ValidPeStream_ReturnsPeParseResult()
{
// Arrange
var pe = PeBuilder.Console64().Build();
using var stream = new MemoryStream(pe);
// Act
var result = PeReader.Parse(stream, "test.exe");
// Assert
result.Should().NotBeNull();
result!.Identity.Should().NotBeNull();
result.Identity.Is64Bit.Should().BeTrue();
}
[Fact]
public void Parse_InvalidStream_ReturnsNull()
{
// Arrange
var invalidData = new byte[] { 0x00, 0x01, 0x02, 0x03 };
using var stream = new MemoryStream(invalidData);
// Act
var result = PeReader.Parse(stream, "invalid.exe");
// Assert
result.Should().BeNull();
}
[Fact]
public void Parse_ThrowsOnNullStream()
{
// Act & Assert
var action = () => PeReader.Parse(null!, "test.exe");
action.Should().Throw<ArgumentNullException>();
}
#endregion
#region Machine Architecture
[Theory]
[InlineData(PeMachine.I386, "x86", false)]
[InlineData(PeMachine.Amd64, "x86_64", true)]
[InlineData(PeMachine.Arm64, "arm64", true)]
public void TryExtractIdentity_MachineTypes_MapCorrectly(PeMachine machine, string expectedArch, bool is64Bit)
{
// Arrange
var pe = new PeBuilder()
.Is64Bit(is64Bit)
.WithMachine(machine)
.Build();
// Act
var result = PeReader.TryExtractIdentity(pe, out var identity);
// Assert
result.Should().BeTrue();
identity.Should().NotBeNull();
identity!.Machine.Should().Be(expectedArch);
}
#endregion
#region Exports
[Fact]
public void TryExtractIdentity_NoExports_ReturnsEmptyList()
{
// Arrange - standard console app has no exports
var pe = PeBuilder.Console64().Build();
// Act
var result = PeReader.TryExtractIdentity(pe, out var identity);
// Assert
result.Should().BeTrue();
identity.Should().NotBeNull();
identity!.Exports.Should().BeEmpty();
}
#endregion
#region Compiler Hints (Rich Header)
[Fact]
public void TryExtractIdentity_NoRichHeader_ReturnsEmptyHints()
{
// Arrange - builder-generated PEs don't have rich header
var pe = PeBuilder.Console64().Build();
// Act
var result = PeReader.TryExtractIdentity(pe, out var identity);
// Assert
result.Should().BeTrue();
identity.Should().NotBeNull();
identity!.CompilerHints.Should().BeEmpty();
identity.RichHeaderHash.Should().BeNull();
}
#endregion
#region CodeView Debug Info
[Fact]
public void TryExtractIdentity_NoDebugDirectory_ReturnsNullCodeView()
{
// Arrange - builder-generated PEs don't have debug directory
var pe = PeBuilder.Console64().Build();
// Act
var result = PeReader.TryExtractIdentity(pe, out var identity);
// Assert
result.Should().BeTrue();
identity.Should().NotBeNull();
identity!.CodeViewGuid.Should().BeNull();
identity.CodeViewAge.Should().BeNull();
identity.PdbPath.Should().BeNull();
}
#endregion
#region Version Resources
[Fact]
public void TryExtractIdentity_NoVersionResource_ReturnsNullVersions()
{
// Arrange - builder-generated PEs don't have version resources
var pe = PeBuilder.Console64().Build();
// Act
var result = PeReader.TryExtractIdentity(pe, out var identity);
// Assert
result.Should().BeTrue();
identity.Should().NotBeNull();
identity!.ProductVersion.Should().BeNull();
identity.FileVersion.Should().BeNull();
identity.CompanyName.Should().BeNull();
identity.ProductName.Should().BeNull();
identity.OriginalFilename.Should().BeNull();
}
#endregion
#region Determinism
[Fact]
public void TryExtractIdentity_SameInput_ReturnsSameOutput()
{
// Arrange
var pe = PeBuilder.Console64().Build();
// Act
PeReader.TryExtractIdentity(pe, out var identity1);
PeReader.TryExtractIdentity(pe, out var identity2);
// Assert
identity1.Should().BeEquivalentTo(identity2);
}
[Fact]
public void TryExtractIdentity_DifferentInputs_ReturnsDifferentOutput()
{
// Arrange
var pe64 = PeBuilder.Console64().Build();
var pe32 = new PeBuilder().Is64Bit(false).Build();
// Act
PeReader.TryExtractIdentity(pe64, out var identity64);
PeReader.TryExtractIdentity(pe32, out var identity32);
// Assert
identity64!.Is64Bit.Should().NotBe(identity32!.Is64Bit);
}
#endregion
#region Edge Cases
[Fact]
public void TryExtractIdentity_InvalidPeOffset_ReturnsFalse()
{
// Arrange - Create data with MZ signature but invalid PE offset
var data = new byte[0x100];
data[0] = (byte)'M';
data[1] = (byte)'Z';
// Set PE offset beyond file bounds
data[0x3C] = 0xFF;
data[0x3D] = 0xFF;
data[0x3E] = 0x00;
data[0x3F] = 0x00;
// Act
var result = PeReader.TryExtractIdentity(data, out var identity);
// Assert
result.Should().BeFalse();
}
[Fact]
public void TryExtractIdentity_MissingPeSignature_ReturnsFalse()
{
// Arrange - Create data with MZ but missing PE signature
var data = new byte[0x100];
data[0] = (byte)'M';
data[1] = (byte)'Z';
data[0x3C] = 0x80; // PE offset at 0x80
// No PE signature at offset 0x80
// Act
var result = PeReader.TryExtractIdentity(data, out var identity);
// Assert
result.Should().BeFalse();
}
[Fact]
public void TryExtractIdentity_InvalidMagic_ReturnsFalse()
{
// Arrange - Create data with PE signature but invalid magic
var data = new byte[0x200];
data[0] = (byte)'M';
data[1] = (byte)'Z';
data[0x3C] = 0x80; // PE offset at 0x80
// PE signature
data[0x80] = (byte)'P';
data[0x81] = (byte)'E';
data[0x82] = 0;
data[0x83] = 0;
// Invalid COFF header with size 0
data[0x80 + 16] = 0; // SizeOfOptionalHeader = 0
// Act
var result = PeReader.TryExtractIdentity(data, out var identity);
// Assert
result.Should().BeFalse();
}
#endregion
}

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);
}
}

View File

@@ -0,0 +1,293 @@
// -----------------------------------------------------------------------------
// FindingEvidenceContractsTests.cs
// Sprint: SPRINT_3800_0001_0001_evidence_api_models
// Description: Unit tests for JSON serialization of evidence API contracts.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Text.Json;
using StellaOps.Scanner.WebService.Contracts;
using Xunit;
namespace StellaOps.Scanner.WebService.Tests;
public class FindingEvidenceContractsTests
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = false
};
[Fact]
public void FindingEvidenceResponse_SerializesToSnakeCase()
{
var response = new FindingEvidenceResponse
{
FindingId = "finding-123",
Cve = "CVE-2021-44228",
Component = new ComponentRef
{
Purl = "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
Name = "log4j-core",
Version = "2.14.1",
Type = "maven"
},
ReachablePath = new[] { "com.example.App.main", "org.apache.log4j.Logger.log" },
LastSeen = new DateTimeOffset(2025, 12, 18, 12, 0, 0, TimeSpan.Zero)
};
var json = JsonSerializer.Serialize(response, SerializerOptions);
Assert.Contains("\"finding_id\":\"finding-123\"", json);
Assert.Contains("\"cve\":\"CVE-2021-44228\"", json);
Assert.Contains("\"reachable_path\":", json);
Assert.Contains("\"last_seen\":", json);
}
[Fact]
public void FindingEvidenceResponse_RoundTripsCorrectly()
{
var original = new FindingEvidenceResponse
{
FindingId = "finding-456",
Cve = "CVE-2023-12345",
Component = new ComponentRef
{
Purl = "pkg:npm/lodash@4.17.20",
Name = "lodash",
Version = "4.17.20",
Type = "npm"
},
Entrypoint = new EntrypointProof
{
Type = "http_handler",
Route = "/api/v1/users",
Method = "POST",
Auth = "required",
Fqn = "com.example.UserController.createUser"
},
ScoreExplain = new ScoreExplanationDto
{
Kind = "stellaops_risk_v1",
RiskScore = 7.5,
Contributions = new[]
{
new ScoreContributionDto
{
Factor = "cvss_base",
Weight = 0.4,
RawValue = 9.8,
Contribution = 3.92,
Explanation = "CVSS v4 base score"
}
},
LastSeen = DateTimeOffset.UtcNow
},
LastSeen = DateTimeOffset.UtcNow
};
var json = JsonSerializer.Serialize(original, SerializerOptions);
var deserialized = JsonSerializer.Deserialize<FindingEvidenceResponse>(json, SerializerOptions);
Assert.NotNull(deserialized);
Assert.Equal(original.FindingId, deserialized.FindingId);
Assert.Equal(original.Cve, deserialized.Cve);
Assert.Equal(original.Component?.Purl, deserialized.Component?.Purl);
Assert.Equal(original.Entrypoint?.Type, deserialized.Entrypoint?.Type);
Assert.Equal(original.ScoreExplain?.RiskScore, deserialized.ScoreExplain?.RiskScore);
}
[Fact]
public void ComponentRef_SerializesAllFields()
{
var component = new ComponentRef
{
Purl = "pkg:nuget/Newtonsoft.Json@13.0.1",
Name = "Newtonsoft.Json",
Version = "13.0.1",
Type = "nuget"
};
var json = JsonSerializer.Serialize(component, SerializerOptions);
Assert.Contains("\"purl\":\"pkg:nuget/Newtonsoft.Json@13.0.1\"", json);
Assert.Contains("\"name\":\"Newtonsoft.Json\"", json);
Assert.Contains("\"version\":\"13.0.1\"", json);
Assert.Contains("\"type\":\"nuget\"", json);
}
[Fact]
public void EntrypointProof_SerializesWithLocation()
{
var entrypoint = new EntrypointProof
{
Type = "grpc_method",
Route = "grpc.UserService.GetUser",
Auth = "required",
Phase = "runtime",
Fqn = "com.example.UserServiceImpl.getUser",
Location = new SourceLocation
{
File = "src/main/java/com/example/UserServiceImpl.java",
Line = 42,
Column = 5
}
};
var json = JsonSerializer.Serialize(entrypoint, SerializerOptions);
Assert.Contains("\"type\":\"grpc_method\"", json);
Assert.Contains("\"route\":\"grpc.UserService.GetUser\"", json);
Assert.Contains("\"location\":", json);
Assert.Contains("\"file\":\"src/main/java/com/example/UserServiceImpl.java\"", json);
Assert.Contains("\"line\":42", json);
}
[Fact]
public void BoundaryProofDto_SerializesWithControls()
{
var boundary = new BoundaryProofDto
{
Kind = "network",
Surface = new SurfaceDescriptor
{
Type = "api",
Protocol = "https",
Port = 443
},
Exposure = new ExposureDescriptor
{
Level = "public",
InternetFacing = true,
Zone = "dmz"
},
Auth = new AuthDescriptor
{
Required = true,
Type = "jwt",
Roles = new[] { "admin", "user" }
},
Controls = new[]
{
new ControlDescriptor
{
Type = "waf",
Active = true,
Config = "OWASP-ModSecurity"
}
},
LastSeen = DateTimeOffset.UtcNow,
Confidence = 0.95
};
var json = JsonSerializer.Serialize(boundary, SerializerOptions);
Assert.Contains("\"kind\":\"network\"", json);
Assert.Contains("\"internet_facing\":true", json);
Assert.Contains("\"controls\":[", json);
Assert.Contains("\"confidence\":0.95", json);
}
[Fact]
public void VexEvidenceDto_SerializesCorrectly()
{
var vex = new VexEvidenceDto
{
Status = "not_affected",
Justification = "vulnerable_code_not_in_execute_path",
Impact = "The vulnerable code path is never executed in our usage",
AttestationRef = "dsse:sha256:abc123",
IssuedAt = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero),
ExpiresAt = new DateTimeOffset(2026, 12, 1, 0, 0, 0, TimeSpan.Zero),
Source = "vendor"
};
var json = JsonSerializer.Serialize(vex, SerializerOptions);
Assert.Contains("\"status\":\"not_affected\"", json);
Assert.Contains("\"justification\":\"vulnerable_code_not_in_execute_path\"", json);
Assert.Contains("\"attestation_ref\":\"dsse:sha256:abc123\"", json);
Assert.Contains("\"source\":\"vendor\"", json);
}
[Fact]
public void ScoreExplanationDto_SerializesContributions()
{
var explanation = new ScoreExplanationDto
{
Kind = "stellaops_risk_v1",
RiskScore = 6.2,
Contributions = new[]
{
new ScoreContributionDto
{
Factor = "cvss_base",
Weight = 0.4,
RawValue = 9.8,
Contribution = 3.92,
Explanation = "Critical CVSS base score"
},
new ScoreContributionDto
{
Factor = "epss",
Weight = 0.2,
RawValue = 0.45,
Contribution = 0.09,
Explanation = "45% probability of exploitation"
},
new ScoreContributionDto
{
Factor = "reachability",
Weight = 0.3,
RawValue = 1.0,
Contribution = 0.3,
Explanation = "Reachable from HTTP entrypoint"
},
new ScoreContributionDto
{
Factor = "gate_multiplier",
Weight = 1.0,
RawValue = 0.5,
Contribution = -2.11,
Explanation = "Auth gate reduces exposure by 50%"
}
},
LastSeen = DateTimeOffset.UtcNow
};
var json = JsonSerializer.Serialize(explanation, SerializerOptions);
Assert.Contains("\"kind\":\"stellaops_risk_v1\"", json);
Assert.Contains("\"risk_score\":6.2", json);
Assert.Contains("\"contributions\":[", json);
Assert.Contains("\"factor\":\"cvss_base\"", json);
Assert.Contains("\"factor\":\"epss\"", json);
Assert.Contains("\"factor\":\"reachability\"", json);
Assert.Contains("\"factor\":\"gate_multiplier\"", json);
}
[Fact]
public void NullOptionalFields_AreOmittedOrNullInJson()
{
var response = new FindingEvidenceResponse
{
FindingId = "finding-minimal",
Cve = "CVE-2025-0001",
LastSeen = DateTimeOffset.UtcNow
// All optional fields are null
};
var json = JsonSerializer.Serialize(response, SerializerOptions);
var deserialized = JsonSerializer.Deserialize<FindingEvidenceResponse>(json, SerializerOptions);
Assert.NotNull(deserialized);
Assert.Null(deserialized.Component);
Assert.Null(deserialized.ReachablePath);
Assert.Null(deserialized.Entrypoint);
Assert.Null(deserialized.Boundary);
Assert.Null(deserialized.Vex);
Assert.Null(deserialized.ScoreExplain);
}
}