using System.Text; using FluentAssertions; using Xunit; using StellaOps.TestKit; namespace StellaOps.Scanner.Analyzers.Native.Tests; public class HeuristicScannerTests { [Trait("Category", TestCategories.Unit)] [Fact] public void Scan_DetectsElfSonamePattern() { // Arrange - binary containing soname strings var data = CreateTestBinaryWithStrings( "libfoo.so.1", "libbar.so", "randomdata"); // Act using var stream = new MemoryStream(data); var result = HeuristicScanner.Scan(stream, NativeFormat.Elf); // Assert result.Edges.Should().HaveCount(2); result.Edges.Should().Contain(e => e.LibraryName == "libfoo.so.1"); result.Edges.Should().Contain(e => e.LibraryName == "libbar.so"); result.Edges.Should().OnlyContain(e => e.ReasonCode == HeuristicReasonCodes.StringDlopen); } [Trait("Category", TestCategories.Unit)] [Fact] public void Scan_DetectsWindowsDllPattern() { // Arrange var data = CreateTestBinaryWithStrings( "kernel32.dll", "user32.dll", "notadll"); // Act using var stream = new MemoryStream(data); var result = HeuristicScanner.Scan(stream, NativeFormat.Pe); // Assert result.Edges.Should().HaveCount(2); result.Edges.Should().Contain(e => e.LibraryName == "kernel32.dll"); result.Edges.Should().Contain(e => e.LibraryName == "user32.dll"); result.Edges.Should().OnlyContain(e => e.ReasonCode == HeuristicReasonCodes.StringLoadLibrary); } [Trait("Category", TestCategories.Unit)] [Fact] public void Scan_DetectsMachODylibPattern() { // Arrange var data = CreateTestBinaryWithStrings( "libfoo.dylib", "@rpath/libbar.dylib", "@loader_path/libbaz.dylib"); // Act using var stream = new MemoryStream(data); var result = HeuristicScanner.Scan(stream, NativeFormat.MachO); // Assert result.Edges.Should().HaveCount(3); result.Edges.Should().Contain(e => e.LibraryName == "libfoo.dylib"); result.Edges.Should().Contain(e => e.LibraryName == "@rpath/libbar.dylib"); result.Edges.Should().Contain(e => e.LibraryName == "@loader_path/libbaz.dylib"); } [Trait("Category", TestCategories.Unit)] [Fact] public void Scan_AssignsHighConfidenceToPathLikeStrings() { // Arrange var data = CreateTestBinaryWithStrings( "/usr/lib/libfoo.so.1", "libbar.so"); // Act using var stream = new MemoryStream(data); var result = HeuristicScanner.Scan(stream, NativeFormat.Elf); // Assert var pathLikeEdge = result.Edges.First(e => e.LibraryName == "/usr/lib/libfoo.so.1"); var simpleSoname = result.Edges.First(e => e.LibraryName == "libbar.so"); pathLikeEdge.Confidence.Should().Be(HeuristicConfidence.High); simpleSoname.Confidence.Should().Be(HeuristicConfidence.Medium); } [Trait("Category", TestCategories.Unit)] [Fact] public void Scan_DetectsPluginConfigReferences() { // Arrange var data = CreateTestBinaryWithStrings( "/etc/myapp/plugins.conf", "config/plugin.json", "modules.conf"); // Act using var stream = new MemoryStream(data); var result = HeuristicScanner.Scan(stream, NativeFormat.Elf); // Assert result.PluginConfigs.Should().HaveCount(3); result.PluginConfigs.Should().Contain("plugins.conf"); result.PluginConfigs.Should().Contain("plugin.json"); result.PluginConfigs.Should().Contain("modules.conf"); } [Trait("Category", TestCategories.Unit)] [Fact] public void Scan_DetectsGoCgoImportDirective() { // Arrange - simulate Go binary with cgo import var marker = Encoding.UTF8.GetBytes("cgo_import_dynamic"); var library = Encoding.UTF8.GetBytes(" libcrypto.so"); var padding = new byte[16]; var data = marker.Concat(library).Concat(padding).ToArray(); // Act using var stream = new MemoryStream(data); var result = HeuristicScanner.Scan(stream, NativeFormat.Elf); // Assert result.Edges.Should().Contain(e => e.LibraryName == "libcrypto.so" && e.ReasonCode == HeuristicReasonCodes.GoCgoImport && e.Confidence == HeuristicConfidence.High); } [Trait("Category", TestCategories.Unit)] [Fact] public void Scan_DetectsGoCgoStaticImport() { // Arrange var marker = Encoding.UTF8.GetBytes("cgo_import_static"); var library = Encoding.UTF8.GetBytes(" libz.a"); var padding = new byte[16]; var data = marker.Concat(library).Concat(padding).ToArray(); // Act using var stream = new MemoryStream(data); var result = HeuristicScanner.Scan(stream, NativeFormat.Elf); // Assert result.Edges.Should().Contain(e => e.LibraryName == "libz.a" && e.ReasonCode == HeuristicReasonCodes.GoCgoImport); } [Trait("Category", TestCategories.Unit)] [Fact] public void Scan_DeduplicatesEdgesByLibraryName() { // Arrange - same library mentioned multiple times var data = CreateTestBinaryWithStrings( "libfoo.so", "some padding", "libfoo.so", "more padding", "libfoo.so"); // Act using var stream = new MemoryStream(data); var result = HeuristicScanner.Scan(stream, NativeFormat.Elf); // Assert result.Edges.Should().ContainSingle(e => e.LibraryName == "libfoo.so"); } [Trait("Category", TestCategories.Unit)] [Fact] public void Scan_IncludesFileOffsetInEdge() { // Arrange var prefix = new byte[100]; var libName = Encoding.UTF8.GetBytes("libtest.so"); var suffix = new byte[50]; var data = prefix.Concat(libName).Concat(suffix).ToArray(); // Act using var stream = new MemoryStream(data); var result = HeuristicScanner.Scan(stream, NativeFormat.Elf); // Assert var edge = result.Edges.First(); edge.FileOffset.Should().Be(100); } [Trait("Category", TestCategories.Unit)] [Fact] public void ScanForDynamicLoading_ReturnsOnlyLibraryEdges() { // Arrange var data = CreateTestBinaryWithStrings( "libfoo.so", "plugins.conf", "libbar.so"); // Act var edges = HeuristicScanner.ScanForDynamicLoading(data, NativeFormat.Elf); // Assert edges.Should().HaveCount(2); edges.Should().OnlyContain(e => e.ReasonCode == HeuristicReasonCodes.StringDlopen); } [Trait("Category", TestCategories.Unit)] [Fact] public void ScanForPluginConfigs_ReturnsOnlyConfigReferences() { // Arrange var data = CreateTestBinaryWithStrings( "libfoo.so", "/etc/plugins.conf", "plugin.json", "libbar.so"); // Act var configs = HeuristicScanner.ScanForPluginConfigs(data); // Assert configs.Should().HaveCount(2); configs.Should().Contain("plugins.conf"); configs.Should().Contain("plugin.json"); } [Trait("Category", TestCategories.Unit)] [Fact] public void Scan_EmptyStream_ReturnsEmptyResult() { // Arrange using var stream = new MemoryStream([]); // Act var result = HeuristicScanner.Scan(stream, NativeFormat.Elf); // Assert result.Edges.Should().BeEmpty(); result.PluginConfigs.Should().BeEmpty(); } [Trait("Category", TestCategories.Unit)] [Fact] public void Scan_NoValidStrings_ReturnsEmptyResult() { // Arrange - binary data with no printable strings var data = new byte[] { 0x00, 0x01, 0x02, 0xFF, 0xFE, 0x80, 0x90 }; // Act using var stream = new MemoryStream(data); var result = HeuristicScanner.Scan(stream, NativeFormat.Elf); // Assert result.Edges.Should().BeEmpty(); } [Trait("Category", TestCategories.Unit)] [Theory] [InlineData("libfoo.so.1", true)] [InlineData("libbar.so", true)] [InlineData("lib-baz_qux.so.2.3", true)] [InlineData("libfoo", false)] // Missing .so [InlineData("foo.so", false)] // Missing lib prefix [InlineData("lib.so", false)] // Too short public void Scan_ValidatesElfSonameFormat(string soname, bool shouldMatch) { // Arrange var data = CreateTestBinaryWithStrings(soname); // Act using var stream = new MemoryStream(data); using StellaOps.TestKit; var result = HeuristicScanner.Scan(stream, NativeFormat.Elf); // Assert if (shouldMatch) { result.Edges.Should().Contain(e => e.LibraryName == soname); } else { result.Edges.Should().NotContain(e => e.LibraryName == soname); } } private static byte[] CreateTestBinaryWithStrings(params string[] strings) { // Create a test binary with the given strings separated by null bytes var parts = new List(); foreach (var str in strings) { parts.Add(Encoding.UTF8.GetBytes(str)); parts.Add(new byte[] { 0x00, 0x00, 0x00, 0x00 }); // Null separator } return parts.SelectMany(p => p).ToArray(); } }