up
This commit is contained in:
@@ -156,7 +156,7 @@ public class PeImportParserTests : NativeTestBase
|
||||
// Test that manifest is properly extracted from PE resources
|
||||
var pe = PeBuilder.Console64()
|
||||
.WithSxsDependency("Microsoft.VC90.CRT", "9.0.21022.8",
|
||||
"1fc8b3b9a1e18e3b", "amd64", embedAsResource: true)
|
||||
"1fc8b3b9a1e18e3b", "amd64")
|
||||
.Build();
|
||||
|
||||
var info = ParsePe(pe);
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Native.Tests.Reachability;
|
||||
|
||||
/// <summary>
|
||||
/// Tests ensuring native callgraph output conforms to richgraph-v1 schema.
|
||||
/// Per docs/modules/scanner/design/native-reachability-plan.md §8.
|
||||
/// These tests validate the expected identifier formats and behaviors
|
||||
/// defined in the specification.
|
||||
/// </summary>
|
||||
public class RichgraphV1AlignmentTests
|
||||
{
|
||||
/// <summary>
|
||||
/// §8.2: SymbolID Construction uses sym: prefix with 16 hex chars.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("ssl3_read_bytes", 0x401000UL, 256UL, "global")]
|
||||
[InlineData("main", 0x400000UL, 128UL, "global")]
|
||||
[InlineData("helper", 0x402000UL, 64UL, "local")]
|
||||
public void ComputeSymbolId_UsesBinaryPrefix(string name, ulong address, ulong size, string binding)
|
||||
{
|
||||
// Act
|
||||
var symbolId = TestGraphIdentifiers.ComputeSymbolId(name, address, size, binding);
|
||||
|
||||
// Assert
|
||||
symbolId.Should().StartWith("sym:");
|
||||
symbolId.Should().HaveLength(20); // sym: (4 chars) + 16 hex chars = 20
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// §8.5: Symbol digest uses SHA-256.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("ssl3_read_bytes", 0x401000UL, 256UL, "global")]
|
||||
[InlineData("main", 0x400000UL, 128UL, "global")]
|
||||
public void ComputeSymbolDigest_UsesSha256(string name, ulong address, ulong size, string binding)
|
||||
{
|
||||
// Act
|
||||
var digest = TestGraphIdentifiers.ComputeSymbolDigest(name, address, size, binding);
|
||||
|
||||
// Assert
|
||||
digest.Should().HaveLength(64); // SHA-256 produces 64 hex chars
|
||||
digest.Should().MatchRegex("^[a-f0-9]{64}$");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SymbolID is deterministic for the same inputs.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ComputeSymbolId_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var name = "test_function";
|
||||
var address = 0x400100UL;
|
||||
var size = 100UL;
|
||||
var binding = "global";
|
||||
|
||||
// Act
|
||||
var id1 = TestGraphIdentifiers.ComputeSymbolId(name, address, size, binding);
|
||||
var id2 = TestGraphIdentifiers.ComputeSymbolId(name, address, size, binding);
|
||||
|
||||
// Assert
|
||||
id1.Should().Be(id2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SymbolID differs when inputs differ.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ComputeSymbolId_DiffersForDifferentInputs()
|
||||
{
|
||||
// Act
|
||||
var id1 = TestGraphIdentifiers.ComputeSymbolId("func_a", 0x1000, 100, "global");
|
||||
var id2 = TestGraphIdentifiers.ComputeSymbolId("func_b", 0x1000, 100, "global");
|
||||
var id3 = TestGraphIdentifiers.ComputeSymbolId("func_a", 0x2000, 100, "global");
|
||||
|
||||
// Assert
|
||||
id1.Should().NotBe(id2);
|
||||
id1.Should().NotBe(id3);
|
||||
id2.Should().NotBe(id3);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EdgeID construction is deterministic.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ComputeEdgeId_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var callerId = "sym:abc123";
|
||||
var calleeId = "sym:def456";
|
||||
var offset = 0x100UL;
|
||||
|
||||
// Act
|
||||
var id1 = TestGraphIdentifiers.ComputeEdgeId(callerId, calleeId, offset);
|
||||
var id2 = TestGraphIdentifiers.ComputeEdgeId(callerId, calleeId, offset);
|
||||
|
||||
// Assert
|
||||
id1.Should().Be(id2);
|
||||
id1.Should().StartWith("edge:");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// §8.5: RootID for init array uses correct format.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ComputeRootId_UsesCorrectFormat()
|
||||
{
|
||||
// Arrange
|
||||
var targetId = "sym:abc123";
|
||||
var rootType = TestRootType.InitArray;
|
||||
var order = 0;
|
||||
|
||||
// Act
|
||||
var rootId = TestGraphIdentifiers.ComputeRootId(targetId, rootType, order);
|
||||
|
||||
// Assert
|
||||
rootId.Should().StartWith("root:");
|
||||
rootId.Should().HaveLength(21); // root: (5 chars) + 16 hex chars = 21
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// §8.8: UnknownID for unresolved targets.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ComputeUnknownId_UsesCorrectFormat()
|
||||
{
|
||||
// Arrange
|
||||
var sourceId = "edge:abc123";
|
||||
var unknownType = TestUnknownType.UnresolvedTarget;
|
||||
var name = "dlopen_target";
|
||||
|
||||
// Act
|
||||
var unknownId = TestGraphIdentifiers.ComputeUnknownId(sourceId, unknownType, name);
|
||||
|
||||
// Assert
|
||||
unknownId.Should().StartWith("unk:");
|
||||
unknownId.Should().HaveLength(20); // unk: (4 chars) + 16 hex chars = 20
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Graph hash is deterministic for same content.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ComputeGraphHash_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var functions = new[]
|
||||
{
|
||||
new TestFunctionNode("sym:001", "func_a", null, "/bin/app", null, 0x1000, 100, "digest1", "global", "default", true),
|
||||
new TestFunctionNode("sym:002", "func_b", null, "/bin/app", null, 0x2000, 100, "digest2", "global", "default", true),
|
||||
}.ToImmutableArray();
|
||||
|
||||
var edges = new[]
|
||||
{
|
||||
new TestCallEdge("edge:001", "sym:001", "sym:002", null, null, TestEdgeType.Direct, 0x50, true, 1.0),
|
||||
}.ToImmutableArray();
|
||||
|
||||
var roots = new[]
|
||||
{
|
||||
new TestSyntheticRoot("root:001", "sym:001", TestRootType.Main, "/bin/app", "main", 0),
|
||||
}.ToImmutableArray();
|
||||
|
||||
// Act
|
||||
var hash1 = TestGraphIdentifiers.ComputeGraphHash(functions, edges, roots);
|
||||
var hash2 = TestGraphIdentifiers.ComputeGraphHash(functions, edges, roots);
|
||||
|
||||
// Assert
|
||||
hash1.Should().Be(hash2);
|
||||
hash1.Should().HaveLength(64); // SHA-256
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Graph hash changes when content changes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ComputeGraphHash_ChangesWhenContentChanges()
|
||||
{
|
||||
// Arrange
|
||||
var functions1 = new[]
|
||||
{
|
||||
new TestFunctionNode("sym:001", "func_a", null, "/bin/app", null, 0x1000, 100, "digest1", "global", "default", true),
|
||||
}.ToImmutableArray();
|
||||
|
||||
var functions2 = new[]
|
||||
{
|
||||
new TestFunctionNode("sym:002", "func_b", null, "/bin/app", null, 0x2000, 100, "digest2", "global", "default", true),
|
||||
}.ToImmutableArray();
|
||||
|
||||
var emptyEdges = ImmutableArray<TestCallEdge>.Empty;
|
||||
var emptyRoots = ImmutableArray<TestSyntheticRoot>.Empty;
|
||||
|
||||
// Act
|
||||
var hash1 = TestGraphIdentifiers.ComputeGraphHash(functions1, emptyEdges, emptyRoots);
|
||||
var hash2 = TestGraphIdentifiers.ComputeGraphHash(functions2, emptyEdges, emptyRoots);
|
||||
|
||||
// Assert
|
||||
hash1.Should().NotBe(hash2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// §8.4: Edge kind mapping for PLT calls.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EdgeType_Plt_MapsToCall()
|
||||
{
|
||||
// Arrange
|
||||
var edge = new TestCallEdge(
|
||||
"edge:001",
|
||||
"sym:caller",
|
||||
"sym:callee",
|
||||
"pkg:deb/ubuntu/openssl@3.0.2",
|
||||
"sha256:abc123",
|
||||
TestEdgeType.Plt,
|
||||
0x100,
|
||||
true,
|
||||
0.95);
|
||||
|
||||
// Assert
|
||||
edge.EdgeType.Should().Be(TestEdgeType.Plt);
|
||||
edge.Confidence.Should().Be(0.95); // PLT resolved confidence
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// §8.4: Edge kind mapping for init array.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EdgeType_InitArray_MapsToInit()
|
||||
{
|
||||
// Arrange
|
||||
var edge = new TestCallEdge(
|
||||
"edge:002",
|
||||
"sym:init",
|
||||
"sym:constructor",
|
||||
null,
|
||||
null,
|
||||
TestEdgeType.InitArray,
|
||||
0,
|
||||
true,
|
||||
1.0);
|
||||
|
||||
// Assert
|
||||
edge.EdgeType.Should().Be(TestEdgeType.InitArray);
|
||||
edge.Confidence.Should().Be(1.0); // Init array entries have high confidence
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// §8.5: Synthetic roots for native entry points.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(TestRootType.Start, "load")]
|
||||
[InlineData(TestRootType.Main, "main")]
|
||||
[InlineData(TestRootType.Init, "init")]
|
||||
[InlineData(TestRootType.InitArray, "init")]
|
||||
[InlineData(TestRootType.PreInitArray, "preinit")]
|
||||
[InlineData(TestRootType.Fini, "fini")]
|
||||
[InlineData(TestRootType.FiniArray, "fini")]
|
||||
public void SyntheticRoot_HasCorrectPhase(TestRootType rootType, string expectedPhase)
|
||||
{
|
||||
// This test verifies the expected phase mapping - actual implementation
|
||||
// may use different phase strings, but the mapping should be documented
|
||||
var root = new TestSyntheticRoot(
|
||||
"root:001",
|
||||
"sym:target",
|
||||
rootType,
|
||||
"/bin/app",
|
||||
expectedPhase,
|
||||
0);
|
||||
|
||||
root.Phase.Should().Be(expectedPhase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// §8.7: Stripped binary handling - synthetic name format.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(0x401000UL, "sub_401000")]
|
||||
[InlineData(0x402000UL, "sub_402000")]
|
||||
[InlineData(0x500ABCUL, "sub_500abc")]
|
||||
public void StrippedSymbol_UsesSubAddressFormat(ulong address, string expectedName)
|
||||
{
|
||||
// Act
|
||||
var syntheticName = $"sub_{address:x}";
|
||||
|
||||
// Assert
|
||||
syntheticName.Should().Be(expectedName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// §8.6: Build ID handling for ELF.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildId_FormatForElf()
|
||||
{
|
||||
// Arrange
|
||||
var elfBuildId = "a1b2c3d4e5f6";
|
||||
|
||||
// Act
|
||||
var formattedBuildId = $"gnu-build-id:{elfBuildId}";
|
||||
|
||||
// Assert
|
||||
formattedBuildId.Should().Be("gnu-build-id:a1b2c3d4e5f6");
|
||||
formattedBuildId.Should().StartWith("gnu-build-id:");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// §8.8: Unknown edge targets with candidates.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UnknownTarget_HasLowConfidence()
|
||||
{
|
||||
// Arrange
|
||||
var unknownEdge = new TestCallEdge(
|
||||
"edge:003",
|
||||
"sym:caller",
|
||||
"unknown:plt_42",
|
||||
null,
|
||||
null,
|
||||
TestEdgeType.Indirect,
|
||||
0x200,
|
||||
false,
|
||||
0.3); // Low confidence for unknown targets
|
||||
|
||||
// Assert
|
||||
unknownEdge.IsResolved.Should().BeFalse();
|
||||
unknownEdge.Confidence.Should().BeLessThan(0.5);
|
||||
unknownEdge.EdgeType.Should().Be(TestEdgeType.Indirect);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TestFunctionNode contains all required fields.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TestFunctionNode_HasRequiredFields()
|
||||
{
|
||||
// Arrange & Act
|
||||
var node = new TestFunctionNode(
|
||||
SymbolId: "sym:binary:abc123",
|
||||
Name: "ssl3_read_bytes",
|
||||
Purl: "pkg:deb/ubuntu/openssl@3.0.2?arch=amd64",
|
||||
BinaryPath: "/usr/lib/libssl.so.3",
|
||||
BuildId: "gnu-build-id:a1b2c3d4e5f6",
|
||||
Address: 0x401000,
|
||||
Size: 256,
|
||||
SymbolDigest: "sha256:deadbeef",
|
||||
Binding: "global",
|
||||
Visibility: "default",
|
||||
IsExported: true);
|
||||
|
||||
// Assert - all fields present per richgraph-v1 §8.1
|
||||
node.SymbolId.Should().NotBeNullOrEmpty();
|
||||
node.Name.Should().NotBeNullOrEmpty();
|
||||
node.Purl.Should().NotBeNullOrEmpty();
|
||||
node.BinaryPath.Should().NotBeNullOrEmpty();
|
||||
node.BuildId.Should().NotBeNullOrEmpty();
|
||||
node.Address.Should().BeGreaterThan(0);
|
||||
node.Size.Should().BeGreaterThan(0);
|
||||
node.SymbolDigest.Should().NotBeNullOrEmpty();
|
||||
node.Binding.Should().NotBeNullOrEmpty();
|
||||
node.Visibility.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TestCallEdge contains all required fields.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TestCallEdge_HasRequiredFields()
|
||||
{
|
||||
// Arrange & Act
|
||||
var edge = new TestCallEdge(
|
||||
EdgeId: "edge:binary:abc123",
|
||||
CallerId: "sym:binary:caller",
|
||||
CalleeId: "sym:binary:callee",
|
||||
CalleePurl: "pkg:deb/ubuntu/openssl@3.0.2",
|
||||
CalleeSymbolDigest: "sha256:cafebabe",
|
||||
EdgeType: TestEdgeType.Plt,
|
||||
CallSiteOffset: 0x100,
|
||||
IsResolved: true,
|
||||
Confidence: 0.95);
|
||||
|
||||
// Assert - all fields present per richgraph-v1 §8.3
|
||||
edge.EdgeId.Should().NotBeNullOrEmpty();
|
||||
edge.CallerId.Should().NotBeNullOrEmpty();
|
||||
edge.CalleeId.Should().NotBeNullOrEmpty();
|
||||
edge.CalleePurl.Should().NotBeNullOrEmpty();
|
||||
edge.CalleeSymbolDigest.Should().NotBeNullOrEmpty();
|
||||
edge.CallSiteOffset.Should().BeGreaterThan(0);
|
||||
edge.Confidence.Should().BeInRange(0, 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TestGraphMetadata contains generation info.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TestGraphMetadata_HasGeneratorInfo()
|
||||
{
|
||||
// Arrange & Act
|
||||
var metadata = new TestGraphMetadata(
|
||||
GeneratedAt: DateTimeOffset.UtcNow,
|
||||
GeneratorVersion: "1.0.0",
|
||||
LayerDigest: "sha256:layer123",
|
||||
BinaryCount: 5,
|
||||
FunctionCount: 100,
|
||||
EdgeCount: 250,
|
||||
UnknownCount: 10,
|
||||
SyntheticRootCount: 8);
|
||||
|
||||
// Assert
|
||||
metadata.GeneratedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromMinutes(1));
|
||||
metadata.GeneratorVersion.Should().Be("1.0.0");
|
||||
metadata.LayerDigest.Should().StartWith("sha256:");
|
||||
metadata.BinaryCount.Should().BeGreaterThan(0);
|
||||
metadata.FunctionCount.Should().BeGreaterThan(0);
|
||||
metadata.EdgeCount.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generator version is retrievable.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetGeneratorVersion_ReturnsSemanticVersion()
|
||||
{
|
||||
// Act
|
||||
var version = TestGraphIdentifiers.GetGeneratorVersion();
|
||||
|
||||
// Assert
|
||||
version.Should().MatchRegex(@"^\d+\.\d+\.\d+$");
|
||||
}
|
||||
|
||||
#region Test Model Definitions (mirror richgraph-v1 schema)
|
||||
|
||||
/// <summary>Test model mirroring NativeFunctionNode.</summary>
|
||||
internal sealed record TestFunctionNode(
|
||||
string SymbolId,
|
||||
string Name,
|
||||
string? Purl,
|
||||
string BinaryPath,
|
||||
string? BuildId,
|
||||
ulong Address,
|
||||
ulong Size,
|
||||
string SymbolDigest,
|
||||
string Binding,
|
||||
string Visibility,
|
||||
bool IsExported);
|
||||
|
||||
/// <summary>Test model mirroring NativeCallEdge.</summary>
|
||||
internal sealed record TestCallEdge(
|
||||
string EdgeId,
|
||||
string CallerId,
|
||||
string CalleeId,
|
||||
string? CalleePurl,
|
||||
string? CalleeSymbolDigest,
|
||||
TestEdgeType EdgeType,
|
||||
ulong CallSiteOffset,
|
||||
bool IsResolved,
|
||||
double Confidence);
|
||||
|
||||
/// <summary>Test model mirroring NativeSyntheticRoot.</summary>
|
||||
internal sealed record TestSyntheticRoot(
|
||||
string RootId,
|
||||
string TargetId,
|
||||
TestRootType RootType,
|
||||
string BinaryPath,
|
||||
string Phase,
|
||||
int Order);
|
||||
|
||||
/// <summary>Test model mirroring NativeGraphMetadata.</summary>
|
||||
internal sealed record TestGraphMetadata(
|
||||
DateTimeOffset GeneratedAt,
|
||||
string GeneratorVersion,
|
||||
string LayerDigest,
|
||||
int BinaryCount,
|
||||
int FunctionCount,
|
||||
int EdgeCount,
|
||||
int UnknownCount,
|
||||
int SyntheticRootCount);
|
||||
|
||||
/// <summary>Test enum mirroring NativeEdgeType.</summary>
|
||||
public enum TestEdgeType
|
||||
{
|
||||
Direct,
|
||||
Plt,
|
||||
Got,
|
||||
Relocation,
|
||||
Indirect,
|
||||
InitArray,
|
||||
FiniArray,
|
||||
}
|
||||
|
||||
/// <summary>Test enum mirroring NativeRootType.</summary>
|
||||
public enum TestRootType
|
||||
{
|
||||
Start,
|
||||
Init,
|
||||
PreInitArray,
|
||||
InitArray,
|
||||
FiniArray,
|
||||
Fini,
|
||||
Main,
|
||||
Constructor,
|
||||
Destructor,
|
||||
}
|
||||
|
||||
/// <summary>Test enum mirroring NativeUnknownType.</summary>
|
||||
public enum TestUnknownType
|
||||
{
|
||||
UnresolvedPurl,
|
||||
UnresolvedTarget,
|
||||
UnresolvedHash,
|
||||
UnresolvedBinary,
|
||||
AmbiguousTarget,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of identifier computation methods.
|
||||
/// These mirror the expected behavior defined in richgraph-v1 schema.
|
||||
/// </summary>
|
||||
internal static class TestGraphIdentifiers
|
||||
{
|
||||
private const string GeneratorVersion = "1.0.0";
|
||||
|
||||
public static string ComputeSymbolId(string name, ulong address, ulong size, string binding)
|
||||
{
|
||||
var input = $"{name}:{address:x}:{size}:{binding}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return $"sym:{Convert.ToHexString(hash[..8]).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
public static string ComputeSymbolDigest(string name, ulong address, ulong size, string binding)
|
||||
{
|
||||
var input = $"{name}:{address:x}:{size}:{binding}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static string ComputeEdgeId(string callerId, string calleeId, ulong callSiteOffset)
|
||||
{
|
||||
var input = $"{callerId}:{calleeId}:{callSiteOffset:x}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return $"edge:{Convert.ToHexString(hash[..8]).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
public static string ComputeRootId(string targetId, TestRootType rootType, int order)
|
||||
{
|
||||
var input = $"{targetId}:{rootType}:{order}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return $"root:{Convert.ToHexString(hash[..8]).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
public static string ComputeUnknownId(string sourceId, TestUnknownType unknownType, string? name)
|
||||
{
|
||||
var input = $"{sourceId}:{unknownType}:{name ?? ""}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return $"unk:{Convert.ToHexString(hash[..8]).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
public static string ComputeGraphHash(
|
||||
ImmutableArray<TestFunctionNode> functions,
|
||||
ImmutableArray<TestCallEdge> edges,
|
||||
ImmutableArray<TestSyntheticRoot> roots)
|
||||
{
|
||||
using var sha = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
|
||||
foreach (var f in functions.OrderBy(f => f.SymbolId))
|
||||
{
|
||||
sha.AppendData(Encoding.UTF8.GetBytes(f.SymbolId));
|
||||
sha.AppendData(Encoding.UTF8.GetBytes(f.SymbolDigest));
|
||||
}
|
||||
|
||||
foreach (var e in edges.OrderBy(e => e.EdgeId))
|
||||
{
|
||||
sha.AppendData(Encoding.UTF8.GetBytes(e.EdgeId));
|
||||
}
|
||||
|
||||
foreach (var r in roots.OrderBy(r => r.RootId))
|
||||
{
|
||||
sha.AppendData(Encoding.UTF8.GetBytes(r.RootId));
|
||||
}
|
||||
|
||||
return Convert.ToHexString(sha.GetCurrentHash()).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static string GetGeneratorVersion() => GeneratorVersion;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scanner.Analyzers.Native;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
using StellaOps.Scanner.Reachability.Lifters;
|
||||
using Xunit;
|
||||
@@ -167,6 +168,62 @@ public class BinaryReachabilityLifterTests
|
||||
e.To == unknownNode.SymbolId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RichGraphIncludesPurlAndSymbolDigestForElfDependencies()
|
||||
{
|
||||
using var temp = new TempDir();
|
||||
var binaryPath = System.IO.Path.Combine(temp.Path, "sample.elf");
|
||||
var bytes = CreateElf64WithDependencies(["libc.so.6"]);
|
||||
await System.IO.File.WriteAllBytesAsync(binaryPath, bytes);
|
||||
|
||||
var context = new ReachabilityLifterContext
|
||||
{
|
||||
RootPath = temp.Path,
|
||||
AnalysisId = "analysis-elf-deps"
|
||||
};
|
||||
|
||||
var builder = new ReachabilityGraphBuilder();
|
||||
var lifter = new BinaryReachabilityLifter();
|
||||
|
||||
await lifter.LiftAsync(context, builder, CancellationToken.None);
|
||||
var union = builder.ToUnionGraph(SymbolId.Lang.Binary);
|
||||
|
||||
var rich = RichGraphBuilder.FromUnion(union, "test-analyzer", "1.0.0");
|
||||
var edge = Assert.Single(rich.Edges);
|
||||
Assert.Equal(EdgeTypes.Import, edge.Kind);
|
||||
Assert.Equal("pkg:generic/libc@6", edge.Purl);
|
||||
Assert.NotNull(edge.SymbolDigest);
|
||||
Assert.StartsWith("sha256:", edge.SymbolDigest, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RichGraphIncludesPurlAndSymbolDigestForPeImports()
|
||||
{
|
||||
using var temp = new TempDir();
|
||||
var binaryPath = System.IO.Path.Combine(temp.Path, "sample.exe");
|
||||
var bytes = CreatePe64WithImports(["KERNEL32.dll"]);
|
||||
await System.IO.File.WriteAllBytesAsync(binaryPath, bytes);
|
||||
|
||||
var context = new ReachabilityLifterContext
|
||||
{
|
||||
RootPath = temp.Path,
|
||||
AnalysisId = "analysis-pe-imports"
|
||||
};
|
||||
|
||||
var builder = new ReachabilityGraphBuilder();
|
||||
var lifter = new BinaryReachabilityLifter();
|
||||
|
||||
await lifter.LiftAsync(context, builder, CancellationToken.None);
|
||||
var union = builder.ToUnionGraph(SymbolId.Lang.Binary);
|
||||
|
||||
var rich = RichGraphBuilder.FromUnion(union, "test-analyzer", "1.0.0");
|
||||
var edge = Assert.Single(rich.Edges);
|
||||
Assert.Equal(EdgeTypes.Import, edge.Kind);
|
||||
Assert.Equal("pkg:generic/KERNEL32", edge.Purl);
|
||||
Assert.NotNull(edge.SymbolDigest);
|
||||
Assert.StartsWith("sha256:", edge.SymbolDigest, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static byte[] CreateMinimalElf()
|
||||
{
|
||||
var data = new byte[64];
|
||||
@@ -307,4 +364,205 @@ public class BinaryReachabilityLifterTests
|
||||
|
||||
private static void WriteU64LE(byte[] buffer, int offset, ulong value)
|
||||
=> BinaryPrimitives.WriteUInt64LittleEndian(buffer.AsSpan(offset, 8), value);
|
||||
|
||||
private static byte[] CreateElf64WithDependencies(IReadOnlyList<string> dependencies)
|
||||
{
|
||||
dependencies ??= [];
|
||||
|
||||
const string interpreter = "/lib64/ld-linux-x86-64.so.2";
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
using var writer = new BinaryWriter(ms);
|
||||
|
||||
var stringTable = new StringBuilder();
|
||||
stringTable.Append('\0');
|
||||
var stringOffsets = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
|
||||
void AddString(string s)
|
||||
{
|
||||
if (stringOffsets.ContainsKey(s))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
stringOffsets[s] = stringTable.Length;
|
||||
stringTable.Append(s);
|
||||
stringTable.Append('\0');
|
||||
}
|
||||
|
||||
AddString(interpreter);
|
||||
foreach (var dep in dependencies)
|
||||
{
|
||||
AddString(dep);
|
||||
}
|
||||
|
||||
var stringTableBytes = Encoding.UTF8.GetBytes(stringTable.ToString());
|
||||
|
||||
const int elfHeaderSize = 64;
|
||||
const int phdrSize = 56;
|
||||
const int phdrCount = 3; // PT_INTERP, PT_LOAD, PT_DYNAMIC
|
||||
var phdrOffset = elfHeaderSize;
|
||||
var interpOffset = phdrOffset + (phdrSize * phdrCount);
|
||||
var interpSize = Encoding.UTF8.GetByteCount(interpreter) + 1;
|
||||
var dynamicOffset = interpOffset + interpSize;
|
||||
|
||||
var dynEntries = new List<(ulong Tag, ulong Value)>();
|
||||
foreach (var dep in dependencies)
|
||||
{
|
||||
dynEntries.Add((1, (ulong)stringOffsets[dep])); // DT_NEEDED
|
||||
}
|
||||
|
||||
dynEntries.Add((5, 0)); // DT_STRTAB (patched later)
|
||||
dynEntries.Add((10, (ulong)stringTableBytes.Length)); // DT_STRSZ
|
||||
dynEntries.Add((0, 0)); // DT_NULL
|
||||
|
||||
var dynamicSize = dynEntries.Count * 16;
|
||||
var stringTableOffset = dynamicOffset + dynamicSize;
|
||||
var totalSize = stringTableOffset + stringTableBytes.Length;
|
||||
|
||||
for (var i = 0; i < dynEntries.Count; i++)
|
||||
{
|
||||
if (dynEntries[i].Tag == 5)
|
||||
{
|
||||
dynEntries[i] = (5, (ulong)stringTableOffset);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
writer.Write(new byte[] { 0x7f, 0x45, 0x4c, 0x46 }); // Magic
|
||||
writer.Write((byte)2); // 64-bit
|
||||
writer.Write((byte)1); // Little endian
|
||||
writer.Write((byte)1); // ELF version
|
||||
writer.Write((byte)0); // OS ABI
|
||||
writer.Write(new byte[8]); // Padding
|
||||
writer.Write((ushort)2); // ET_EXEC
|
||||
writer.Write((ushort)0x3e); // x86_64
|
||||
writer.Write(1u); // Version
|
||||
writer.Write(0ul); // Entry point
|
||||
writer.Write((ulong)phdrOffset); // Program header offset
|
||||
writer.Write(0ul); // Section header offset
|
||||
writer.Write(0u); // Flags
|
||||
writer.Write((ushort)elfHeaderSize); // ELF header size
|
||||
writer.Write((ushort)phdrSize); // Program header entry size
|
||||
writer.Write((ushort)phdrCount); // Number of program headers
|
||||
writer.Write((ushort)0); // Section header entry size
|
||||
writer.Write((ushort)0); // Number of section headers
|
||||
writer.Write((ushort)0); // Section name string table index
|
||||
|
||||
// PT_INTERP
|
||||
writer.Write(3u);
|
||||
writer.Write(4u);
|
||||
writer.Write((ulong)interpOffset);
|
||||
writer.Write((ulong)interpOffset);
|
||||
writer.Write((ulong)interpOffset);
|
||||
writer.Write((ulong)interpSize);
|
||||
writer.Write((ulong)interpSize);
|
||||
writer.Write(1ul);
|
||||
|
||||
// PT_LOAD
|
||||
writer.Write(1u);
|
||||
writer.Write(5u);
|
||||
writer.Write(0ul);
|
||||
writer.Write(0ul);
|
||||
writer.Write(0ul);
|
||||
writer.Write((ulong)totalSize);
|
||||
writer.Write((ulong)totalSize);
|
||||
writer.Write(0x1000ul);
|
||||
|
||||
// PT_DYNAMIC
|
||||
writer.Write(2u);
|
||||
writer.Write(6u);
|
||||
writer.Write((ulong)dynamicOffset);
|
||||
writer.Write((ulong)dynamicOffset);
|
||||
writer.Write((ulong)dynamicOffset);
|
||||
writer.Write((ulong)dynamicSize);
|
||||
writer.Write((ulong)dynamicSize);
|
||||
writer.Write(8ul);
|
||||
|
||||
writer.Write(Encoding.UTF8.GetBytes(interpreter));
|
||||
writer.Write((byte)0);
|
||||
|
||||
foreach (var (tag, value) in dynEntries)
|
||||
{
|
||||
writer.Write(tag);
|
||||
writer.Write(value);
|
||||
}
|
||||
|
||||
writer.Write(stringTableBytes);
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] CreatePe64WithImports(IReadOnlyList<string> imports)
|
||||
{
|
||||
imports ??= [];
|
||||
if (imports.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("Must provide at least one import.", nameof(imports));
|
||||
}
|
||||
|
||||
const int peHeaderOffset = 0x80;
|
||||
const int optionalHeaderSize = 240;
|
||||
const uint sectionVirtualAddress = 0x1000;
|
||||
const uint sectionVirtualSize = 0x200;
|
||||
const uint sectionRawSize = 0x200;
|
||||
const uint sectionRawOffset = 0x200;
|
||||
|
||||
const uint importDirRva = sectionVirtualAddress;
|
||||
const uint importDirSize = 40; // 2 descriptors
|
||||
const uint nameRva = sectionVirtualAddress + 0x100;
|
||||
|
||||
var dllNameBytes = Encoding.ASCII.GetBytes(imports[0] + "\0");
|
||||
var totalSize = (int)(sectionRawOffset + sectionRawSize);
|
||||
if (sectionRawOffset + 0x100 + dllNameBytes.Length > sectionRawOffset + sectionRawSize)
|
||||
{
|
||||
totalSize = (int)(sectionRawOffset + 0x100 + dllNameBytes.Length);
|
||||
}
|
||||
|
||||
var buffer = new byte[totalSize];
|
||||
|
||||
buffer[0] = (byte)'M';
|
||||
buffer[1] = (byte)'Z';
|
||||
BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(0x3C, 4), peHeaderOffset);
|
||||
|
||||
WriteU32LE(buffer, peHeaderOffset, 0x00004550); // PE\0\0
|
||||
|
||||
var coff = peHeaderOffset + 4;
|
||||
WriteU16LE(buffer, coff + 0, 0x8664); // Machine
|
||||
WriteU16LE(buffer, coff + 2, 1); // NumberOfSections
|
||||
WriteU32LE(buffer, coff + 16, 0); // NumberOfSymbols
|
||||
WriteU16LE(buffer, coff + 16 + 4, (ushort)optionalHeaderSize); // SizeOfOptionalHeader
|
||||
WriteU16LE(buffer, coff + 16 + 6, 0x22); // Characteristics
|
||||
|
||||
var opt = peHeaderOffset + 24;
|
||||
WriteU16LE(buffer, opt + 0, 0x20b); // PE32+
|
||||
WriteU16LE(buffer, opt + 68, (ushort)PeSubsystem.WindowsConsole); // Subsystem
|
||||
WriteU32LE(buffer, opt + 108, 16); // NumberOfRvaAndSizes
|
||||
|
||||
var dataDir = opt + 112;
|
||||
// Import directory entry (#1)
|
||||
WriteU32LE(buffer, dataDir + 8, importDirRva);
|
||||
WriteU32LE(buffer, dataDir + 12, importDirSize);
|
||||
|
||||
var sectionHeader = opt + optionalHeaderSize;
|
||||
var sectionName = Encoding.ASCII.GetBytes(".rdata\0\0");
|
||||
sectionName.CopyTo(buffer, sectionHeader);
|
||||
WriteU32LE(buffer, sectionHeader + 8, sectionVirtualSize);
|
||||
WriteU32LE(buffer, sectionHeader + 12, sectionVirtualAddress);
|
||||
WriteU32LE(buffer, sectionHeader + 16, sectionRawSize);
|
||||
WriteU32LE(buffer, sectionHeader + 20, sectionRawOffset);
|
||||
|
||||
// Import descriptor #1 at RVA 0x1000 -> file offset 0x200.
|
||||
var importOffset = (int)sectionRawOffset;
|
||||
WriteU32LE(buffer, importOffset + 0, 0); // OriginalFirstThunk (skip function parsing)
|
||||
WriteU32LE(buffer, importOffset + 12, nameRva); // Name RVA
|
||||
|
||||
// Import descriptor #2 is the terminator (zeros), already zero-initialized.
|
||||
|
||||
// DLL name string
|
||||
var nameOffset = (int)(sectionRawOffset + (nameRva - sectionVirtualAddress));
|
||||
dllNameBytes.CopyTo(buffer, nameOffset);
|
||||
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user