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