up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-27 07:46:56 +02:00
parent d63af51f84
commit ea970ead2a
302 changed files with 43161 additions and 1534 deletions

View File

@@ -0,0 +1,34 @@
namespace StellaOps.Scanner.Analyzers.Native;
/// <summary>
/// Represents a declared dependency extracted from ELF dynamic sections.
/// </summary>
/// <param name="Soname">The shared library name from DT_NEEDED entry.</param>
/// <param name="ReasonCode">The reason code for this dependency (e.g., "elf-dtneeded").</param>
/// <param name="VersionNeeds">Symbol versions required from this dependency.</param>
public sealed record ElfDeclaredDependency(
string Soname,
string ReasonCode,
IReadOnlyList<ElfVersionNeed> VersionNeeds);
/// <summary>
/// Represents a symbol version requirement from .gnu.version_r section.
/// </summary>
/// <param name="Version">The version string (e.g., "GLIBC_2.17").</param>
/// <param name="Hash">The ELF hash of the version string.</param>
public sealed record ElfVersionNeed(string Version, uint Hash);
/// <summary>
/// Contains all dynamic section information extracted from an ELF binary.
/// </summary>
/// <param name="BinaryId">The build-id of the binary (if present).</param>
/// <param name="Interpreter">The interpreter path (e.g., /lib64/ld-linux-x86-64.so.2).</param>
/// <param name="Rpath">Runtime search paths from DT_RPATH (colon-separated, split into list).</param>
/// <param name="Runpath">Runtime search paths from DT_RUNPATH (colon-separated, split into list).</param>
/// <param name="Dependencies">Declared dependencies from DT_NEEDED entries.</param>
public sealed record ElfDynamicInfo(
string? BinaryId,
string? Interpreter,
IReadOnlyList<string> Rpath,
IReadOnlyList<string> Runpath,
IReadOnlyList<ElfDeclaredDependency> Dependencies);

View File

@@ -0,0 +1,457 @@
using System.Buffers.Binary;
using System.Text;
namespace StellaOps.Scanner.Analyzers.Native;
/// <summary>
/// Parses ELF dynamic sections to extract declared dependencies, rpath/runpath, and version needs.
/// </summary>
public static class ElfDynamicSectionParser
{
private const uint PT_DYNAMIC = 2;
private const uint PT_INTERP = 3;
private const uint PT_NOTE = 4;
private const ulong DT_NULL = 0;
private const ulong DT_NEEDED = 1;
private const ulong DT_STRTAB = 5;
private const ulong DT_STRSZ = 10;
private const ulong DT_RPATH = 15;
private const ulong DT_RUNPATH = 29;
private const ulong DT_VERNEED = 0x6ffffffe;
private const ulong DT_VERNEEDNUM = 0x6fffffff;
/// <summary>
/// Parses ELF dynamic sections from a stream.
/// </summary>
/// <param name="stream">The stream containing the ELF binary.</param>
/// <param name="dynamicInfo">The parsed dynamic information.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if parsing succeeded, false otherwise.</returns>
public static bool TryParse(Stream stream, out ElfDynamicInfo dynamicInfo, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(stream);
dynamicInfo = new ElfDynamicInfo(null, null, [], [], []);
using var buffer = new MemoryStream();
stream.CopyTo(buffer);
var data = buffer.ToArray();
var span = data.AsSpan();
if (!IsValidElf(span))
{
return false;
}
var is64Bit = span[4] == 2;
var isBigEndian = span[5] == 2;
// Parse program headers to find PT_DYNAMIC, PT_INTERP, PT_NOTE
var (phoff, phentsize, phnum) = GetProgramHeaderInfo(span, is64Bit, isBigEndian);
if (phentsize == 0 || phnum == 0 || phoff == 0)
{
// No program headers - could be an object file or stripped binary
dynamicInfo = new ElfDynamicInfo(null, null, [], [], []);
return true;
}
ulong dynamicOffset = 0, dynamicSize = 0;
string? interpreter = null;
string? buildId = null;
for (var i = 0; i < phnum; i++)
{
var entryOffset = (long)(phoff + (ulong)(i * phentsize));
if (entryOffset + phentsize > span.Length)
{
break;
}
var phSpan = span.Slice((int)entryOffset, phentsize);
var pType = ReadUInt32(phSpan, 0, isBigEndian);
if (pType == PT_DYNAMIC)
{
(dynamicOffset, dynamicSize) = GetSegmentOffsetAndSize(phSpan, is64Bit, isBigEndian);
}
else if (pType == PT_INTERP && interpreter is null)
{
interpreter = ParseInterpreter(span, phSpan, is64Bit, isBigEndian);
}
else if (pType == PT_NOTE && buildId is null)
{
buildId = ParseBuildId(span, phSpan, is64Bit, isBigEndian);
}
}
if (dynamicOffset == 0 || dynamicSize == 0)
{
// No dynamic section found - static binary
dynamicInfo = new ElfDynamicInfo(buildId, interpreter, [], [], []);
return true;
}
// Parse the dynamic section
var dynResult = ParseDynamicSection(span, dynamicOffset, dynamicSize, is64Bit, isBigEndian);
// Build version needs map from .gnu.version_r if present
var versionNeedsMap = new Dictionary<string, List<ElfVersionNeed>>();
if (dynResult.VerneedOffset > 0 && dynResult.VerneedNum > 0 && dynResult.StrtabOffset > 0)
{
versionNeedsMap = ParseVersionNeeds(span, dynResult.VerneedOffset, dynResult.VerneedNum,
dynResult.StrtabOffset, dynResult.StrtabSize, isBigEndian);
}
// Build dependencies list with version needs
var dependencies = new List<ElfDeclaredDependency>();
var seenSonames = new HashSet<string>(StringComparer.Ordinal);
foreach (var sonameOffset in dynResult.NeededOffsets)
{
var soname = ReadNullTerminatedString(span, dynResult.StrtabOffset, dynResult.StrtabSize, sonameOffset);
if (string.IsNullOrEmpty(soname) || !seenSonames.Add(soname))
{
continue; // Skip duplicates, preserve first occurrence order
}
var versions = versionNeedsMap.TryGetValue(soname, out var v) ? v : [];
dependencies.Add(new ElfDeclaredDependency(soname, "elf-dtneeded", versions));
}
// Parse rpath and runpath
var rpath = ParsePathList(span, dynResult.StrtabOffset, dynResult.StrtabSize, dynResult.RpathOffset);
var runpath = ParsePathList(span, dynResult.StrtabOffset, dynResult.StrtabSize, dynResult.RunpathOffset);
dynamicInfo = new ElfDynamicInfo(buildId, interpreter, rpath, runpath, dependencies);
return true;
}
private static bool IsValidElf(ReadOnlySpan<byte> span)
{
if (span.Length < 64)
{
return false;
}
return span[0] == 0x7F && span[1] == (byte)'E' && span[2] == (byte)'L' && span[3] == (byte)'F';
}
private static (ulong phoff, ushort phentsize, ushort phnum) GetProgramHeaderInfo(
ReadOnlySpan<byte> span, bool is64Bit, bool isBigEndian)
{
if (is64Bit)
{
var phoff = ReadUInt64(span, 32, isBigEndian);
var phentsize = ReadUInt16(span, 54, isBigEndian);
var phnum = ReadUInt16(span, 56, isBigEndian);
return (phoff, phentsize, phnum);
}
else
{
var phoff = ReadUInt32(span, 28, isBigEndian);
var phentsize = ReadUInt16(span, 42, isBigEndian);
var phnum = ReadUInt16(span, 44, isBigEndian);
return (phoff, phentsize, phnum);
}
}
private static (ulong offset, ulong size) GetSegmentOffsetAndSize(
ReadOnlySpan<byte> phSpan, bool is64Bit, bool isBigEndian)
{
if (is64Bit)
{
var offset = ReadUInt64(phSpan, 8, isBigEndian);
var size = ReadUInt64(phSpan, 32, isBigEndian);
return (offset, size);
}
else
{
var offset = ReadUInt32(phSpan, 4, isBigEndian);
var size = ReadUInt32(phSpan, 16, isBigEndian);
return (offset, size);
}
}
private static string? ParseInterpreter(ReadOnlySpan<byte> span, ReadOnlySpan<byte> phSpan, bool is64Bit, bool isBigEndian)
{
var (offset, size) = GetSegmentOffsetAndSize(phSpan, is64Bit, isBigEndian);
if (size == 0 || offset + size > (ulong)span.Length)
{
return null;
}
var interpSpan = span.Slice((int)offset, (int)size);
var terminator = interpSpan.IndexOf((byte)0);
var count = terminator >= 0 ? terminator : interpSpan.Length;
var str = Encoding.ASCII.GetString(interpSpan[..count]);
return string.IsNullOrWhiteSpace(str) ? null : str;
}
private static string? ParseBuildId(ReadOnlySpan<byte> span, ReadOnlySpan<byte> phSpan, bool is64Bit, bool isBigEndian)
{
var (offset, size) = GetSegmentOffsetAndSize(phSpan, is64Bit, isBigEndian);
if (size == 0 || offset + size > (ulong)span.Length)
{
return null;
}
var noteSpan = span.Slice((int)offset, (int)size);
return ParseElfNote(noteSpan, isBigEndian);
}
private static string? ParseElfNote(ReadOnlySpan<byte> note, bool bigEndian)
{
var offset = 0;
while (offset + 12 <= note.Length)
{
var namesz = ReadUInt32(note, offset, bigEndian);
var descsz = ReadUInt32(note, offset + 4, bigEndian);
var type = ReadUInt32(note, offset + 8, bigEndian);
var nameStart = offset + 12;
var namePadded = AlignTo4(namesz);
var descStart = nameStart + namePadded;
var descPadded = AlignTo4(descsz);
var next = descStart + descPadded;
if (next > note.Length)
{
break;
}
// NT_GNU_BUILD_ID = 3, name = "GNU"
if (type == 3 && namesz >= 3)
{
var name = note.Slice(nameStart, (int)Math.Min(namesz, (uint)(note.Length - nameStart)));
if (name[0] == (byte)'G' && name[1] == (byte)'N' && name[2] == (byte)'U')
{
var desc = note.Slice(descStart, (int)Math.Min(descsz, (uint)(note.Length - descStart)));
return Convert.ToHexString(desc).ToLowerInvariant();
}
}
offset = next;
}
return null;
}
private sealed record DynamicSectionResult(
ulong StrtabOffset,
ulong StrtabSize,
List<ulong> NeededOffsets,
ulong RpathOffset,
ulong RunpathOffset,
ulong VerneedOffset,
ulong VerneedNum);
private static DynamicSectionResult ParseDynamicSection(
ReadOnlySpan<byte> span, ulong dynOffset, ulong dynSize, bool is64Bit, bool isBigEndian)
{
var entrySize = is64Bit ? 16 : 8;
var neededOffsets = new List<ulong>();
ulong strtab = 0, strsz = 0, rpath = 0, runpath = 0, verneed = 0, verneednum = 0;
var offset = (int)dynOffset;
var end = (int)Math.Min(dynOffset + dynSize, (ulong)span.Length);
while (offset + entrySize <= end)
{
ulong tag, val;
if (is64Bit)
{
tag = ReadUInt64(span, offset, isBigEndian);
val = ReadUInt64(span, offset + 8, isBigEndian);
}
else
{
tag = ReadUInt32(span, offset, isBigEndian);
val = ReadUInt32(span, offset + 4, isBigEndian);
}
if (tag == DT_NULL)
{
break;
}
switch (tag)
{
case DT_NEEDED:
neededOffsets.Add(val);
break;
case DT_STRTAB:
strtab = val;
break;
case DT_STRSZ:
strsz = val;
break;
case DT_RPATH:
rpath = val;
break;
case DT_RUNPATH:
runpath = val;
break;
case DT_VERNEED:
verneed = val;
break;
case DT_VERNEEDNUM:
verneednum = val;
break;
}
offset += entrySize;
}
// DT_STRTAB is a virtual address, we need to convert it to file offset
// For simplicity, assume the string table is loaded at the same file offset
// In real binaries, we'd need to consult the section headers or program headers
// For now, search for the string table in the file
var strtabFileOffset = FindStringTableOffset(span, strtab, is64Bit, isBigEndian);
return new DynamicSectionResult(strtabFileOffset, strsz, neededOffsets, rpath, runpath, verneed, verneednum);
}
private static ulong FindStringTableOffset(ReadOnlySpan<byte> span, ulong strtabVaddr, bool is64Bit, bool isBigEndian)
{
// Parse section headers to find .dynstr section
ulong shoff;
ushort shentsize, shnum, shstrndx;
if (is64Bit)
{
shoff = ReadUInt64(span, 40, isBigEndian);
shentsize = ReadUInt16(span, 58, isBigEndian);
shnum = ReadUInt16(span, 60, isBigEndian);
shstrndx = ReadUInt16(span, 62, isBigEndian);
}
else
{
shoff = ReadUInt32(span, 32, isBigEndian);
shentsize = ReadUInt16(span, 46, isBigEndian);
shnum = ReadUInt16(span, 48, isBigEndian);
shstrndx = ReadUInt16(span, 50, isBigEndian);
}
if (shoff == 0 || shentsize == 0 || shnum == 0)
{
return strtabVaddr; // Fallback to vaddr as offset
}
// Find section with matching address
for (var i = 0; i < shnum; i++)
{
var entryOffset = (long)(shoff + (ulong)(i * shentsize));
if (entryOffset + shentsize > span.Length)
{
break;
}
var shSpan = span.Slice((int)entryOffset, shentsize);
ulong shAddr, shOffset;
if (is64Bit)
{
shAddr = ReadUInt64(shSpan, 16, isBigEndian);
shOffset = ReadUInt64(shSpan, 24, isBigEndian);
}
else
{
shAddr = ReadUInt32(shSpan, 12, isBigEndian);
shOffset = ReadUInt32(shSpan, 16, isBigEndian);
}
if (shAddr == strtabVaddr)
{
return shOffset;
}
}
return strtabVaddr; // Fallback to vaddr as offset
}
private static Dictionary<string, List<ElfVersionNeed>> ParseVersionNeeds(
ReadOnlySpan<byte> span, ulong verneedVaddr, ulong verneedNum,
ulong strtabOffset, ulong strtabSize, bool isBigEndian)
{
var result = new Dictionary<string, List<ElfVersionNeed>>(StringComparer.Ordinal);
// Find .gnu.version_r section offset (similar to string table lookup)
// For now, use a simple heuristic - the section is typically near the string table
// In production, we'd properly parse section headers
// The version need structure:
// Elf64_Verneed: vn_version (2), vn_cnt (2), vn_file (4), vn_aux (4), vn_next (4)
// Elf64_Vernaux: vna_hash (4), vna_flags (2), vna_other (2), vna_name (4), vna_next (4)
// For this implementation, we'd need to:
// 1. Find the .gnu.version_r section file offset from section headers
// 2. Parse each Verneed entry and its aux entries
// 3. Map version strings to the file they come from
// This is a simplified placeholder - full implementation would parse section headers
return result;
}
private static string? ReadNullTerminatedString(ReadOnlySpan<byte> span, ulong strtabOffset, ulong strtabSize, ulong strOffset)
{
var absoluteOffset = strtabOffset + strOffset;
if (absoluteOffset >= (ulong)span.Length)
{
return null;
}
var maxLen = Math.Min(strtabSize - strOffset, (ulong)(span.Length - (int)absoluteOffset));
if (maxLen <= 0)
{
return null;
}
var strSpan = span.Slice((int)absoluteOffset, (int)maxLen);
var terminator = strSpan.IndexOf((byte)0);
var count = terminator >= 0 ? terminator : strSpan.Length;
return Encoding.UTF8.GetString(strSpan[..count]);
}
private static IReadOnlyList<string> ParsePathList(ReadOnlySpan<byte> span, ulong strtabOffset, ulong strtabSize, ulong pathOffset)
{
if (pathOffset == 0)
{
return [];
}
var pathStr = ReadNullTerminatedString(span, strtabOffset, strtabSize, pathOffset);
if (string.IsNullOrEmpty(pathStr))
{
return [];
}
// Split colon-separated paths, filter empty entries
return pathStr.Split(':', StringSplitOptions.RemoveEmptyEntries);
}
private static uint ReadUInt32(ReadOnlySpan<byte> span, int offset, bool bigEndian)
{
return bigEndian
? BinaryPrimitives.ReadUInt32BigEndian(span.Slice(offset, 4))
: BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(offset, 4));
}
private static ulong ReadUInt64(ReadOnlySpan<byte> span, int offset, bool bigEndian)
{
return bigEndian
? BinaryPrimitives.ReadUInt64BigEndian(span.Slice(offset, 8))
: BinaryPrimitives.ReadUInt64LittleEndian(span.Slice(offset, 8));
}
private static ushort ReadUInt16(ReadOnlySpan<byte> span, int offset, bool bigEndian)
{
return bigEndian
? BinaryPrimitives.ReadUInt16BigEndian(span.Slice(offset, 2))
: BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(offset, 2));
}
private static int AlignTo4(uint value) => (int)((value + 3) & ~3u);
}

View File

@@ -0,0 +1,64 @@
namespace StellaOps.Scanner.Analyzers.Native;
/// <summary>
/// Confidence level for heuristically detected dependencies.
/// </summary>
public enum HeuristicConfidence
{
/// <summary>Low confidence - may be false positive (e.g., string in data section).</summary>
Low = 1,
/// <summary>Medium confidence - likely a dependency (e.g., near call instruction).</summary>
Medium = 2,
/// <summary>High confidence - very likely a dependency (e.g., format string for dlopen).</summary>
High = 3,
}
/// <summary>
/// Reason codes for heuristic dependency detection.
/// </summary>
public static class HeuristicReasonCodes
{
/// <summary>Detected via dlopen/dlsym string pattern.</summary>
public const string StringDlopen = "string-dlopen";
/// <summary>Detected via LoadLibrary/GetProcAddress string pattern.</summary>
public const string StringLoadLibrary = "string-loadlibrary";
/// <summary>Detected via plugin configuration file.</summary>
public const string ConfigPlugin = "config-plugin";
/// <summary>Detected via ecosystem-specific hints (Go/Rust/etc).</summary>
public const string EcosystemHeuristic = "ecosystem-heuristic";
/// <summary>Detected via CGO import directive.</summary>
public const string GoCgoImport = "go-cgo-import";
/// <summary>Detected via Rust FFI pattern.</summary>
public const string RustFfi = "rust-ffi";
}
/// <summary>
/// A heuristically detected dependency edge.
/// </summary>
/// <param name="LibraryName">The detected library name or path.</param>
/// <param name="ReasonCode">The reason code for detection.</param>
/// <param name="Confidence">Confidence level of the detection.</param>
/// <param name="Context">Optional context about where/how it was detected.</param>
/// <param name="FileOffset">Optional file offset where the string was found.</param>
public sealed record HeuristicEdge(
string LibraryName,
string ReasonCode,
HeuristicConfidence Confidence,
string? Context,
long? FileOffset);
/// <summary>
/// Result of heuristic scanning for a binary.
/// </summary>
/// <param name="Edges">All detected heuristic edges.</param>
/// <param name="PluginConfigs">Any plugin configuration files referenced.</param>
public sealed record HeuristicScanResult(
IReadOnlyList<HeuristicEdge> Edges,
IReadOnlyList<string> PluginConfigs);

View File

@@ -0,0 +1,410 @@
using System.Buffers.Binary;
using System.Text;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.Native;
/// <summary>
/// Scans native binaries for heuristic dependency indicators.
/// Detects dlopen/LoadLibrary strings, plugin configs, and ecosystem-specific hints.
/// </summary>
public static partial class HeuristicScanner
{
// Common shared library patterns
private static readonly Regex ElfSonamePattern = SonameRegex();
private static readonly Regex WindowsDllPattern = DllRegex();
private static readonly Regex MacOsDylibPattern = DylibRegex();
// Plugin config patterns
private static readonly string[] PluginConfigPatterns =
[
"plugins.conf",
"plugin.conf",
"plugins.json",
"plugin.json",
"plugins.xml",
"plugin.xml",
".so.conf",
"modules.conf",
"extensions.conf",
];
// Go-specific patterns
private static readonly byte[] GoCgoImportMarker = "cgo_import_dynamic"u8.ToArray();
private static readonly byte[] GoCgoImportStatic = "cgo_import_static"u8.ToArray();
// Rust-specific patterns
private static readonly byte[] RustPanicPrefix = "panicked at"u8.ToArray();
private static readonly byte[] RustCratePattern = ".rlib"u8.ToArray();
/// <summary>
/// Scans a binary stream for heuristic dependency indicators.
/// </summary>
public static HeuristicScanResult Scan(Stream stream, NativeFormat format)
{
ArgumentNullException.ThrowIfNull(stream);
using var buffer = new MemoryStream();
stream.CopyTo(buffer);
var data = buffer.ToArray();
var edges = new List<HeuristicEdge>();
var pluginConfigs = new List<string>();
// Extract printable strings and analyze them
var strings = ExtractStrings(data, minLength: 4);
foreach (var (str, offset) in strings)
{
// Check for dynamic library loading patterns
AnalyzeDynamicLoadingString(str, offset, format, edges);
// Check for plugin config references
AnalyzePluginConfig(str, pluginConfigs);
}
// Check for Go-specific patterns
ScanForGoPatterns(data, edges);
// Check for Rust-specific patterns
ScanForRustPatterns(data, edges);
// Deduplicate edges by library name
var uniqueEdges = edges
.GroupBy(e => (e.LibraryName, e.ReasonCode))
.Select(g => g.OrderByDescending(e => e.Confidence).First())
.ToList();
return new HeuristicScanResult(uniqueEdges, pluginConfigs.Distinct().ToList());
}
/// <summary>
/// Scans specifically for dlopen/LoadLibrary style strings.
/// </summary>
public static IReadOnlyList<HeuristicEdge> ScanForDynamicLoading(byte[] data, NativeFormat format)
{
var edges = new List<HeuristicEdge>();
var strings = ExtractStrings(data, minLength: 4);
foreach (var (str, offset) in strings)
{
AnalyzeDynamicLoadingString(str, offset, format, edges);
}
return edges
.GroupBy(e => e.LibraryName)
.Select(g => g.OrderByDescending(e => e.Confidence).First())
.ToList();
}
/// <summary>
/// Scans for plugin configuration file references.
/// </summary>
public static IReadOnlyList<string> ScanForPluginConfigs(byte[] data)
{
var configs = new List<string>();
var strings = ExtractStrings(data, minLength: 6);
foreach (var (str, _) in strings)
{
AnalyzePluginConfig(str, configs);
}
return configs.Distinct().ToList();
}
private static void AnalyzeDynamicLoadingString(
string str,
long offset,
NativeFormat format,
List<HeuristicEdge> edges)
{
// Check for format-appropriate library patterns
switch (format)
{
case NativeFormat.Elf:
if (ElfSonamePattern.IsMatch(str))
{
var confidence = DetermineConfidence(str, isPathLike: str.Contains('/'));
edges.Add(new HeuristicEdge(
str,
HeuristicReasonCodes.StringDlopen,
confidence,
"ELF soname pattern",
offset));
}
break;
case NativeFormat.Pe:
if (WindowsDllPattern.IsMatch(str))
{
var confidence = DetermineConfidence(str, isPathLike: str.Contains('\\') || str.Contains('/'));
edges.Add(new HeuristicEdge(
str,
HeuristicReasonCodes.StringLoadLibrary,
confidence,
"PE DLL pattern",
offset));
}
break;
case NativeFormat.MachO:
if (MacOsDylibPattern.IsMatch(str) || str.EndsWith(".dylib", StringComparison.OrdinalIgnoreCase))
{
var confidence = DetermineConfidence(str, isPathLike: str.Contains('/'));
edges.Add(new HeuristicEdge(
str,
HeuristicReasonCodes.StringDlopen,
confidence,
"Mach-O dylib pattern",
offset));
}
break;
}
// Check for cross-platform dlopen-style patterns
// Require at least 1 character between "lib" and ".so" (e.g., "libx.so" minimum)
if (str.StartsWith("lib", StringComparison.Ordinal) && str.Contains(".so"))
{
var soIndex = str.IndexOf(".so", StringComparison.Ordinal);
if (soIndex > 3 && !edges.Any(e => e.LibraryName == str))
{
var confidence = DetermineConfidence(str, isPathLike: str.Contains('/'));
edges.Add(new HeuristicEdge(
str,
HeuristicReasonCodes.StringDlopen,
confidence,
"Generic soname pattern",
offset));
}
}
}
private static HeuristicConfidence DetermineConfidence(string libraryName, bool isPathLike)
{
// Higher confidence for path-like strings (more likely to be actual dlopen args)
if (isPathLike)
{
return HeuristicConfidence.High;
}
// Medium confidence for standard naming conventions
if (libraryName.StartsWith("lib", StringComparison.Ordinal) ||
libraryName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) ||
libraryName.EndsWith(".dylib", StringComparison.OrdinalIgnoreCase))
{
return HeuristicConfidence.Medium;
}
// Lower confidence for generic matches
return HeuristicConfidence.Low;
}
private static void AnalyzePluginConfig(string str, List<string> configs)
{
foreach (var pattern in PluginConfigPatterns)
{
if (str.EndsWith(pattern, StringComparison.OrdinalIgnoreCase) ||
str.Contains(pattern, StringComparison.OrdinalIgnoreCase))
{
// Extract just the filename if it's a path
var filename = str;
var lastSlash = str.LastIndexOfAny(['/', '\\']);
if (lastSlash >= 0 && lastSlash < str.Length - 1)
{
filename = str[(lastSlash + 1)..];
}
configs.Add(filename);
break;
}
}
}
private static void ScanForGoPatterns(byte[] data, List<HeuristicEdge> edges)
{
// Look for cgo_import_dynamic markers
var cgoImportOffsets = FindAllOccurrences(data, GoCgoImportMarker);
foreach (var offset in cgoImportOffsets)
{
// Extract the library name following the marker
var libraryName = ExtractFollowingString(data, offset + GoCgoImportMarker.Length);
if (!string.IsNullOrEmpty(libraryName) && IsValidLibraryName(libraryName))
{
edges.Add(new HeuristicEdge(
libraryName,
HeuristicReasonCodes.GoCgoImport,
HeuristicConfidence.High,
"Go CGO import directive",
offset));
}
}
// Look for cgo_import_static markers
var staticOffsets = FindAllOccurrences(data, GoCgoImportStatic);
foreach (var offset in staticOffsets)
{
var libraryName = ExtractFollowingString(data, offset + GoCgoImportStatic.Length);
if (!string.IsNullOrEmpty(libraryName) && IsValidLibraryName(libraryName))
{
edges.Add(new HeuristicEdge(
libraryName,
HeuristicReasonCodes.GoCgoImport,
HeuristicConfidence.High,
"Go CGO static import",
offset));
}
}
}
private static void ScanForRustPatterns(byte[] data, List<HeuristicEdge> edges)
{
// Look for Rust panic messages that might indicate FFI usage
var panicOffsets = FindAllOccurrences(data, RustPanicPrefix);
if (panicOffsets.Count > 0)
{
// Binary is likely Rust - look for linked libraries in a more targeted way
var strings = ExtractStrings(data, minLength: 4);
foreach (var (str, offset) in strings)
{
// Look for extern "C" FFI patterns
if (str.Contains("libstd-") || str.Contains("libcore-"))
{
continue; // Skip Rust standard library
}
// Look for native library references
if ((str.StartsWith("lib", StringComparison.Ordinal) && str.Contains(".so")) ||
str.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
{
// Only add if it looks like an FFI dependency
if (!str.Contains("rust") && !str.Contains("std"))
{
edges.Add(new HeuristicEdge(
str,
HeuristicReasonCodes.RustFfi,
HeuristicConfidence.Medium,
"Rust FFI library reference",
offset));
}
}
}
}
// Look for .rlib references
var rlibOffsets = FindAllOccurrences(data, RustCratePattern);
if (rlibOffsets.Count > 0)
{
// This is a Rust binary - we've already processed above
}
}
private static List<(string Value, long Offset)> ExtractStrings(byte[] data, int minLength)
{
var results = new List<(string, long)>();
var currentString = new StringBuilder();
var stringStart = -1L;
for (var i = 0; i < data.Length; i++)
{
var b = data[i];
// Check for printable ASCII
if (b >= 0x20 && b < 0x7F)
{
if (currentString.Length == 0)
{
stringStart = i;
}
currentString.Append((char)b);
}
else
{
// End of string
if (currentString.Length >= minLength)
{
results.Add((currentString.ToString(), stringStart));
}
currentString.Clear();
}
}
// Don't forget the last string
if (currentString.Length >= minLength)
{
results.Add((currentString.ToString(), stringStart));
}
return results;
}
private static string? ExtractFollowingString(byte[] data, int startOffset)
{
// Skip whitespace and null bytes
var i = startOffset;
while (i < data.Length && (data[i] == 0 || data[i] == ' ' || data[i] == '\t'))
{
i++;
}
var sb = new StringBuilder();
while (i < data.Length && data[i] >= 0x20 && data[i] < 0x7F)
{
sb.Append((char)data[i]);
i++;
if (sb.Length > 256) break; // Sanity limit
}
var result = sb.ToString().Trim();
return string.IsNullOrEmpty(result) ? null : result;
}
private static List<int> FindAllOccurrences(byte[] data, byte[] pattern)
{
var results = new List<int>();
if (pattern.Length == 0 || data.Length < pattern.Length)
{
return results;
}
for (var i = 0; i <= data.Length - pattern.Length; i++)
{
var match = true;
for (var j = 0; j < pattern.Length; j++)
{
if (data[i + j] != pattern[j])
{
match = false;
break;
}
}
if (match)
{
results.Add(i);
}
}
return results;
}
private static bool IsValidLibraryName(string name)
{
if (string.IsNullOrWhiteSpace(name) || name.Length < 3)
{
return false;
}
// Basic validation - should contain alphanumeric and common separators
return name.All(c => char.IsLetterOrDigit(c) || c == '.' || c == '_' || c == '-' || c == '/');
}
[GeneratedRegex(@"^(/[a-zA-Z0-9_/.-]+/)?lib[a-zA-Z0-9_+-]+\.so(\.[0-9]+)*$", RegexOptions.Compiled)]
private static partial Regex SonameRegex();
[GeneratedRegex(@"^[a-zA-Z0-9_+-]+\.dll$", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
private static partial Regex DllRegex();
[GeneratedRegex(@"^(@rpath/|@loader_path/|@executable_path/|/)?[a-zA-Z0-9_+-]+\.dylib$", RegexOptions.Compiled)]
private static partial Regex DylibRegex();
}

View File

@@ -0,0 +1,38 @@
namespace StellaOps.Scanner.Analyzers.Native;
/// <summary>
/// Represents a declared dependency extracted from Mach-O load commands.
/// </summary>
/// <param name="Path">The dylib path (may contain @rpath, @loader_path, @executable_path).</param>
/// <param name="ReasonCode">The reason code: "macho-loadlib", "macho-reexport", "macho-weaklib", "macho-lazylib".</param>
/// <param name="CurrentVersion">The current version of the dylib.</param>
/// <param name="CompatibilityVersion">The compatibility version of the dylib.</param>
public sealed record MachODeclaredDependency(
string Path,
string ReasonCode,
string? CurrentVersion,
string? CompatibilityVersion);
/// <summary>
/// Represents a single Mach-O slice (architecture) in a potentially universal binary.
/// </summary>
/// <param name="CpuType">The CPU type (e.g., x86_64, arm64).</param>
/// <param name="CpuSubtype">The CPU subtype.</param>
/// <param name="Uuid">The UUID of this slice (if present).</param>
/// <param name="Rpaths">Runtime search paths from LC_RPATH commands.</param>
/// <param name="Dependencies">Declared dependencies from LC_LOAD_DYLIB etc.</param>
public sealed record MachOSlice(
string? CpuType,
uint CpuSubtype,
string? Uuid,
IReadOnlyList<string> Rpaths,
IReadOnlyList<MachODeclaredDependency> Dependencies);
/// <summary>
/// Contains all load command information extracted from a Mach-O binary.
/// </summary>
/// <param name="IsUniversal">True if this is a fat/universal binary.</param>
/// <param name="Slices">Individual architecture slices.</param>
public sealed record MachOImportInfo(
bool IsUniversal,
IReadOnlyList<MachOSlice> Slices);

View File

@@ -0,0 +1,330 @@
using System.Buffers.Binary;
using System.Text;
namespace StellaOps.Scanner.Analyzers.Native;
/// <summary>
/// Parses Mach-O load commands to extract dependencies, rpaths, and UUIDs.
/// </summary>
public static class MachOLoadCommandParser
{
// Mach-O magic numbers
private const uint MH_MAGIC = 0xFEEDFACE; // 32-bit little endian
private const uint MH_CIGAM = 0xCEFAEDFE; // 32-bit big endian
private const uint MH_MAGIC_64 = 0xFEEDFACF; // 64-bit little endian
private const uint MH_CIGAM_64 = 0xCFFAEDFE; // 64-bit big endian
private const uint FAT_MAGIC = 0xCAFEBABE; // Fat binary big endian
private const uint FAT_CIGAM = 0xBEBAFECA; // Fat binary little endian
// Load commands
private const uint LC_LOAD_DYLIB = 0x0C;
private const uint LC_LOAD_WEAK_DYLIB = 0x80000018;
private const uint LC_REEXPORT_DYLIB = 0x8000001F;
private const uint LC_LAZY_LOAD_DYLIB = 0x20;
private const uint LC_RPATH = 0x8000001C;
private const uint LC_UUID = 0x1B;
/// <summary>
/// Parses Mach-O load commands from a stream.
/// </summary>
public static bool TryParse(Stream stream, out MachOImportInfo importInfo, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(stream);
importInfo = new MachOImportInfo(false, []);
using var buffer = new MemoryStream();
stream.CopyTo(buffer);
var data = buffer.ToArray();
var span = data.AsSpan();
if (span.Length < 4)
{
return false;
}
var magic = BinaryPrimitives.ReadUInt32BigEndian(span);
// Check for fat binary
if (magic == FAT_MAGIC || magic == FAT_CIGAM)
{
return TryParseFatBinary(span, magic == FAT_CIGAM, out importInfo);
}
// Check for single architecture binary
if (magic == MH_MAGIC || magic == MH_CIGAM || magic == MH_MAGIC_64 || magic == MH_CIGAM_64)
{
if (TryParseSingleArchitecture(span, 0, out var slice))
{
importInfo = new MachOImportInfo(false, [slice]);
return true;
}
}
return false;
}
private static bool TryParseFatBinary(ReadOnlySpan<byte> span, bool swapped, out MachOImportInfo importInfo)
{
importInfo = new MachOImportInfo(true, []);
if (span.Length < 8)
{
return false;
}
var nfat_arch = swapped
? BinaryPrimitives.ReverseEndianness(BinaryPrimitives.ReadUInt32BigEndian(span.Slice(4, 4)))
: BinaryPrimitives.ReadUInt32BigEndian(span.Slice(4, 4));
if (nfat_arch > 20) // Sanity check
{
return false;
}
var slices = new List<MachOSlice>();
var fatArchSize = 20; // sizeof(fat_arch) for 32-bit fat header
var offset = 8;
for (var i = 0; i < nfat_arch; i++)
{
if (offset + fatArchSize > span.Length)
{
break;
}
var archOffset = swapped
? BinaryPrimitives.ReverseEndianness(BinaryPrimitives.ReadUInt32BigEndian(span.Slice(offset + 8, 4)))
: BinaryPrimitives.ReadUInt32BigEndian(span.Slice(offset + 8, 4));
if (archOffset < span.Length && TryParseSingleArchitecture(span, (int)archOffset, out var slice))
{
slices.Add(slice);
}
offset += fatArchSize;
}
importInfo = new MachOImportInfo(true, slices);
return slices.Count > 0;
}
private static bool TryParseSingleArchitecture(ReadOnlySpan<byte> span, int baseOffset, out MachOSlice slice)
{
slice = new MachOSlice(null, 0, null, [], []);
if (baseOffset + 28 > span.Length)
{
return false;
}
var localSpan = span[baseOffset..];
var magic = BinaryPrimitives.ReadUInt32LittleEndian(localSpan);
bool is64Bit;
bool bigEndian;
switch (magic)
{
case MH_MAGIC:
is64Bit = false;
bigEndian = false;
break;
case MH_CIGAM:
is64Bit = false;
bigEndian = true;
break;
case MH_MAGIC_64:
is64Bit = true;
bigEndian = false;
break;
case MH_CIGAM_64:
is64Bit = true;
bigEndian = true;
break;
default:
return false;
}
// Parse mach_header(_64)
var cputype = ReadUInt32(localSpan, 4, bigEndian);
var cpusubtype = ReadUInt32(localSpan, 8, bigEndian);
var ncmds = ReadUInt32(localSpan, 16, bigEndian);
var sizeofcmds = ReadUInt32(localSpan, 20, bigEndian);
var headerSize = is64Bit ? 32 : 28;
var cmdOffset = headerSize;
string? uuid = null;
var rpaths = new List<string>();
var dependencies = new List<MachODeclaredDependency>();
var seenPaths = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < ncmds; i++)
{
if (cmdOffset + 8 > localSpan.Length)
{
break;
}
var cmd = ReadUInt32(localSpan, cmdOffset, bigEndian);
var cmdsize = ReadUInt32(localSpan, cmdOffset + 4, bigEndian);
if (cmdsize < 8 || cmdOffset + cmdsize > localSpan.Length)
{
break;
}
switch (cmd)
{
case LC_UUID when cmdsize >= 24:
uuid = ParseUuid(localSpan.Slice(cmdOffset + 8, 16));
break;
case LC_RPATH:
var rpathStr = ParseLoadCommandString(localSpan, cmdOffset, cmdsize, bigEndian);
if (!string.IsNullOrEmpty(rpathStr))
{
rpaths.Add(rpathStr);
}
break;
case LC_LOAD_DYLIB:
AddDependency(localSpan, cmdOffset, cmdsize, bigEndian, "macho-loadlib", seenPaths, dependencies);
break;
case LC_LOAD_WEAK_DYLIB:
AddDependency(localSpan, cmdOffset, cmdsize, bigEndian, "macho-weaklib", seenPaths, dependencies);
break;
case LC_REEXPORT_DYLIB:
AddDependency(localSpan, cmdOffset, cmdsize, bigEndian, "macho-reexport", seenPaths, dependencies);
break;
case LC_LAZY_LOAD_DYLIB:
AddDependency(localSpan, cmdOffset, cmdsize, bigEndian, "macho-lazylib", seenPaths, dependencies);
break;
}
cmdOffset += (int)cmdsize;
}
var cpuTypeStr = MapCpuType(cputype);
slice = new MachOSlice(cpuTypeStr, cpusubtype, uuid, rpaths, dependencies);
return true;
}
private static void AddDependency(
ReadOnlySpan<byte> span, int cmdOffset, uint cmdsize, bool bigEndian,
string reasonCode, HashSet<string> seenPaths, List<MachODeclaredDependency> dependencies)
{
// dylib_command structure:
// uint32_t cmd, cmdsize
// lc_str name (offset from start of load command)
// uint32_t timestamp
// uint32_t current_version
// uint32_t compatibility_version
if (cmdsize < 24)
{
return;
}
var nameOffset = ReadUInt32(span, cmdOffset + 8, bigEndian);
var currentVersion = ReadUInt32(span, cmdOffset + 16, bigEndian);
var compatVersion = ReadUInt32(span, cmdOffset + 20, bigEndian);
var pathEnd = (int)Math.Min(cmdsize, (uint)(span.Length - cmdOffset));
var nameStart = cmdOffset + (int)nameOffset;
if (nameStart >= span.Length || nameStart >= cmdOffset + pathEnd)
{
return;
}
var nameSpan = span.Slice(nameStart, Math.Min(pathEnd - (int)nameOffset, span.Length - nameStart));
var nullIndex = nameSpan.IndexOf((byte)0);
var nameLength = nullIndex >= 0 ? nullIndex : nameSpan.Length;
var path = Encoding.UTF8.GetString(nameSpan[..nameLength]);
if (string.IsNullOrEmpty(path) || !seenPaths.Add(path))
{
return;
}
var currentVersionStr = FormatVersion(currentVersion);
var compatVersionStr = FormatVersion(compatVersion);
dependencies.Add(new MachODeclaredDependency(path, reasonCode, currentVersionStr, compatVersionStr));
}
private static string? ParseLoadCommandString(ReadOnlySpan<byte> span, int cmdOffset, uint cmdsize, bool bigEndian)
{
// LC_RPATH structure:
// uint32_t cmd, cmdsize
// lc_str path (offset from start of load command)
if (cmdsize < 12)
{
return null;
}
var pathOffset = ReadUInt32(span, cmdOffset + 8, bigEndian);
var pathEnd = (int)Math.Min(cmdsize, (uint)(span.Length - cmdOffset));
var pathStart = cmdOffset + (int)pathOffset;
if (pathStart >= span.Length || pathStart >= cmdOffset + pathEnd)
{
return null;
}
var pathSpan = span.Slice(pathStart, Math.Min(pathEnd - (int)pathOffset, span.Length - pathStart));
var nullIndex = pathSpan.IndexOf((byte)0);
var pathLength = nullIndex >= 0 ? nullIndex : pathSpan.Length;
return Encoding.UTF8.GetString(pathSpan[..pathLength]);
}
private static string ParseUuid(ReadOnlySpan<byte> uuidBytes)
{
// Format as standard UUID: 8-4-4-4-12 (all lowercase)
return ($"{Convert.ToHexString(uuidBytes[..4])}-{Convert.ToHexString(uuidBytes.Slice(4, 2))}-" +
$"{Convert.ToHexString(uuidBytes.Slice(6, 2))}-{Convert.ToHexString(uuidBytes.Slice(8, 2))}-" +
$"{Convert.ToHexString(uuidBytes.Slice(10, 6))}").ToLowerInvariant();
}
private static string FormatVersion(uint version)
{
// Mach-O version format: xxxx.yy.zz encoded as (xxxx << 16) | (yy << 8) | zz
var major = version >> 16;
var minor = (version >> 8) & 0xFF;
var patch = version & 0xFF;
return $"{major}.{minor}.{patch}";
}
private static uint ReadUInt32(ReadOnlySpan<byte> span, int offset, bool bigEndian)
{
return bigEndian
? BinaryPrimitives.ReadUInt32BigEndian(span.Slice(offset, 4))
: BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(offset, 4));
}
private static string? MapCpuType(uint cpuType) => cpuType switch
{
0x00000001 => "vax",
0x00000006 => "mc680x0",
0x00000007 => "x86",
0x01000007 => "x86_64",
0x0000000A => "mc98000",
0x0000000B => "hppa",
0x0000000C => "arm",
0x0100000C => "arm64",
0x0200000C => "arm64_32",
0x0000000D => "mc88000",
0x0000000E => "sparc",
0x0000000F => "i860",
0x00000012 => "powerpc",
0x01000012 => "powerpc64",
_ => null,
};
}

View File

@@ -0,0 +1,421 @@
namespace StellaOps.Scanner.Analyzers.Native;
/// <summary>
/// Represents a step in the resolution process for explain traces.
/// </summary>
/// <param name="SearchPath">The path that was searched.</param>
/// <param name="SearchReason">Why this path was searched (e.g., "rpath", "runpath", "default").</param>
/// <param name="Found">Whether the library was found at this path.</param>
/// <param name="ResolvedPath">The full resolved path if found.</param>
public sealed record ResolveStep(
string SearchPath,
string SearchReason,
bool Found,
string? ResolvedPath);
/// <summary>
/// Result of resolving a native library dependency.
/// </summary>
/// <param name="RequestedName">The original library name that was requested.</param>
/// <param name="Resolved">Whether the library was successfully resolved.</param>
/// <param name="ResolvedPath">The final resolved path (if resolved).</param>
/// <param name="Steps">The resolution steps taken (explain trace).</param>
public sealed record ResolveResult(
string RequestedName,
bool Resolved,
string? ResolvedPath,
IReadOnlyList<ResolveStep> Steps);
/// <summary>
/// Virtual filesystem interface for resolver operations.
/// </summary>
public interface IVirtualFileSystem
{
bool FileExists(string path);
bool DirectoryExists(string path);
IEnumerable<string> EnumerateFiles(string directory, string pattern);
}
/// <summary>
/// Simple virtual filesystem implementation backed by a set of known paths.
/// </summary>
public sealed class VirtualFileSystem : IVirtualFileSystem
{
private readonly HashSet<string> _files;
private readonly HashSet<string> _directories;
public VirtualFileSystem(IEnumerable<string> files)
{
_files = new HashSet<string>(files, StringComparer.OrdinalIgnoreCase);
_directories = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var file in _files)
{
var dir = Path.GetDirectoryName(file);
while (!string.IsNullOrEmpty(dir))
{
_directories.Add(dir);
dir = Path.GetDirectoryName(dir);
}
}
}
public bool FileExists(string path) => _files.Contains(NormalizePath(path));
public bool DirectoryExists(string path) => _directories.Contains(NormalizePath(path));
public IEnumerable<string> EnumerateFiles(string directory, string pattern)
{
var normalizedDir = NormalizePath(directory);
return _files.Where(f =>
{
var fileDir = Path.GetDirectoryName(f);
return string.Equals(fileDir, normalizedDir, StringComparison.OrdinalIgnoreCase);
});
}
private static string NormalizePath(string path) =>
path.Replace('\\', '/').TrimEnd('/');
}
/// <summary>
/// Resolves ELF shared library dependencies using the Linux dynamic linker algorithm.
/// </summary>
public static class ElfResolver
{
private static readonly string[] DefaultSearchPaths =
[
"/lib64",
"/usr/lib64",
"/lib",
"/usr/lib",
"/usr/local/lib64",
"/usr/local/lib",
];
/// <summary>
/// Resolves an ELF shared library dependency.
/// </summary>
/// <param name="soname">The soname to resolve (e.g., "libc.so.6").</param>
/// <param name="rpaths">DT_RPATH values from the binary.</param>
/// <param name="runpaths">DT_RUNPATH values from the binary.</param>
/// <param name="ldLibraryPath">LD_LIBRARY_PATH entries (optional).</param>
/// <param name="origin">The $ORIGIN value (directory of the executable).</param>
/// <param name="fs">Virtual filesystem for checking existence.</param>
/// <returns>Resolution result with explain trace.</returns>
public static ResolveResult Resolve(
string soname,
IReadOnlyList<string> rpaths,
IReadOnlyList<string> runpaths,
IReadOnlyList<string>? ldLibraryPath,
string? origin,
IVirtualFileSystem fs)
{
var steps = new List<ResolveStep>();
// Resolution order (simplified Linux dynamic linker):
// 1. DT_RPATH (unless DT_RUNPATH is present)
// 2. LD_LIBRARY_PATH
// 3. DT_RUNPATH
// 4. /etc/ld.so.cache (skipped - not available in virtual fs)
// 5. Default paths (/lib, /usr/lib, etc.)
var hasRunpath = runpaths.Count > 0;
// 1. DT_RPATH (only if no RUNPATH)
if (!hasRunpath && rpaths.Count > 0)
{
var result = SearchPaths(soname, rpaths, "rpath", origin, fs, steps);
if (result != null)
{
return new ResolveResult(soname, true, result, steps);
}
}
// 2. LD_LIBRARY_PATH
if (ldLibraryPath is { Count: > 0 })
{
var result = SearchPaths(soname, ldLibraryPath, "ld_library_path", origin, fs, steps);
if (result != null)
{
return new ResolveResult(soname, true, result, steps);
}
}
// 3. DT_RUNPATH
if (hasRunpath)
{
var result = SearchPaths(soname, runpaths, "runpath", origin, fs, steps);
if (result != null)
{
return new ResolveResult(soname, true, result, steps);
}
}
// 4. Default paths
var defaultResult = SearchPaths(soname, DefaultSearchPaths, "default", origin, fs, steps);
if (defaultResult != null)
{
return new ResolveResult(soname, true, defaultResult, steps);
}
return new ResolveResult(soname, false, null, steps);
}
private static string? SearchPaths(
string soname,
IEnumerable<string> paths,
string reason,
string? origin,
IVirtualFileSystem fs,
List<ResolveStep> steps)
{
foreach (var path in paths)
{
var expandedPath = ExpandOrigin(path, origin);
var fullPath = Path.Combine(expandedPath, soname).Replace('\\', '/');
var found = fs.FileExists(fullPath);
steps.Add(new ResolveStep(expandedPath, reason, found, found ? fullPath : null));
if (found)
{
return fullPath;
}
}
return null;
}
private static string ExpandOrigin(string path, string? origin)
{
if (string.IsNullOrEmpty(origin))
{
return path;
}
return path
.Replace("$ORIGIN", origin)
.Replace("${ORIGIN}", origin);
}
}
/// <summary>
/// Resolves PE DLL dependencies using the Windows DLL search order.
/// </summary>
public static class PeResolver
{
private static readonly string[] SystemDirectories =
[
"C:/Windows/System32",
"C:/Windows/SysWOW64",
"C:/Windows",
];
/// <summary>
/// Resolves a PE DLL dependency using SafeDll search order.
/// </summary>
/// <param name="dllName">The DLL name to resolve.</param>
/// <param name="applicationDirectory">The application's directory.</param>
/// <param name="currentDirectory">The current working directory (optional, used with LOAD_WITH_ALTERED_SEARCH_PATH).</param>
/// <param name="pathEnvironment">PATH environment variable entries.</param>
/// <param name="fs">Virtual filesystem for checking existence.</param>
/// <returns>Resolution result with explain trace.</returns>
public static ResolveResult Resolve(
string dllName,
string? applicationDirectory,
string? currentDirectory,
IReadOnlyList<string>? pathEnvironment,
IVirtualFileSystem fs)
{
var steps = new List<ResolveStep>();
// SafeDllSearchMode search order (default in Windows):
// 1. Application directory
// 2. System directory (System32)
// 3. 16-bit system directory (System)
// 4. Windows directory
// 5. Current directory
// 6. PATH directories
// 1. Application directory
if (!string.IsNullOrEmpty(applicationDirectory))
{
var result = TryPath(dllName, applicationDirectory, "application_directory", fs, steps);
if (result != null) return new ResolveResult(dllName, true, result, steps);
}
// 2-4. System directories
foreach (var sysDir in SystemDirectories)
{
var result = TryPath(dllName, sysDir, "system_directory", fs, steps);
if (result != null) return new ResolveResult(dllName, true, result, steps);
}
// 5. Current directory
if (!string.IsNullOrEmpty(currentDirectory))
{
var result = TryPath(dllName, currentDirectory, "current_directory", fs, steps);
if (result != null) return new ResolveResult(dllName, true, result, steps);
}
// 6. PATH directories
if (pathEnvironment is { Count: > 0 })
{
foreach (var pathDir in pathEnvironment)
{
var result = TryPath(dllName, pathDir, "path_environment", fs, steps);
if (result != null) return new ResolveResult(dllName, true, result, steps);
}
}
return new ResolveResult(dllName, false, null, steps);
}
private static string? TryPath(
string dllName,
string directory,
string reason,
IVirtualFileSystem fs,
List<ResolveStep> steps)
{
var fullPath = Path.Combine(directory, dllName).Replace('\\', '/');
var found = fs.FileExists(fullPath);
steps.Add(new ResolveStep(directory, reason, found, found ? fullPath : null));
return found ? fullPath : null;
}
}
/// <summary>
/// Resolves Mach-O dylib dependencies using the macOS dynamic linker algorithm.
/// </summary>
public static class MachOResolver
{
private static readonly string[] DefaultFrameworkPaths =
[
"/System/Library/Frameworks",
"/Library/Frameworks",
];
private static readonly string[] DefaultLibraryPaths =
[
"/usr/lib",
"/usr/local/lib",
];
/// <summary>
/// Resolves a Mach-O dylib dependency.
/// </summary>
/// <param name="dylibPath">The dylib path (may contain @rpath, @loader_path, @executable_path).</param>
/// <param name="rpaths">LC_RPATH values from the binary.</param>
/// <param name="loaderPath">The @loader_path value (directory of the loading binary).</param>
/// <param name="executablePath">The @executable_path value (directory of the main executable).</param>
/// <param name="fs">Virtual filesystem for checking existence.</param>
/// <returns>Resolution result with explain trace.</returns>
public static ResolveResult Resolve(
string dylibPath,
IReadOnlyList<string> rpaths,
string? loaderPath,
string? executablePath,
IVirtualFileSystem fs)
{
var steps = new List<ResolveStep>();
// Handle @rpath
if (dylibPath.StartsWith("@rpath/", StringComparison.Ordinal))
{
var relativePath = dylibPath[7..]; // Remove "@rpath/"
foreach (var rpath in rpaths)
{
var expandedRpath = ExpandPlaceholders(rpath, loaderPath, executablePath);
var fullPath = Path.Combine(expandedRpath, relativePath).Replace('\\', '/');
var found = fs.FileExists(fullPath);
steps.Add(new ResolveStep(expandedRpath, "rpath", found, found ? fullPath : null));
if (found)
{
return new ResolveResult(dylibPath, true, fullPath, steps);
}
}
// Try default paths for @rpath
foreach (var defaultPath in DefaultLibraryPaths)
{
var fullPath = Path.Combine(defaultPath, relativePath).Replace('\\', '/');
var found = fs.FileExists(fullPath);
steps.Add(new ResolveStep(defaultPath, "default_library_path", found, found ? fullPath : null));
if (found)
{
return new ResolveResult(dylibPath, true, fullPath, steps);
}
}
return new ResolveResult(dylibPath, false, null, steps);
}
// Handle @loader_path
if (dylibPath.StartsWith("@loader_path/", StringComparison.Ordinal))
{
var relativePath = dylibPath[13..];
var expanded = ExpandPlaceholders(dylibPath, loaderPath, executablePath);
var found = fs.FileExists(expanded);
steps.Add(new ResolveStep(loaderPath ?? ".", "loader_path", found, found ? expanded : null));
return new ResolveResult(dylibPath, found, found ? expanded : null, steps);
}
// Handle @executable_path
if (dylibPath.StartsWith("@executable_path/", StringComparison.Ordinal))
{
var expanded = ExpandPlaceholders(dylibPath, loaderPath, executablePath);
var found = fs.FileExists(expanded);
steps.Add(new ResolveStep(executablePath ?? ".", "executable_path", found, found ? expanded : null));
return new ResolveResult(dylibPath, found, found ? expanded : null, steps);
}
// Absolute path or relative path
if (dylibPath.StartsWith("/", StringComparison.Ordinal))
{
var found = fs.FileExists(dylibPath);
steps.Add(new ResolveStep(Path.GetDirectoryName(dylibPath) ?? "/", "absolute_path", found, found ? dylibPath : null));
return new ResolveResult(dylibPath, found, found ? dylibPath : null, steps);
}
// Relative path - search in default locations
foreach (var defaultPath in DefaultLibraryPaths)
{
var fullPath = Path.Combine(defaultPath, dylibPath).Replace('\\', '/');
var found = fs.FileExists(fullPath);
steps.Add(new ResolveStep(defaultPath, "default_library_path", found, found ? fullPath : null));
if (found)
{
return new ResolveResult(dylibPath, true, fullPath, steps);
}
}
return new ResolveResult(dylibPath, false, null, steps);
}
private static string ExpandPlaceholders(string path, string? loaderPath, string? executablePath)
{
var result = path;
if (!string.IsNullOrEmpty(loaderPath))
{
result = result.Replace("@loader_path", loaderPath);
}
if (!string.IsNullOrEmpty(executablePath))
{
result = result.Replace("@executable_path", executablePath);
}
return result.Replace('\\', '/');
}
}

View File

@@ -0,0 +1,275 @@
namespace StellaOps.Scanner.Analyzers.Native.Observations;
/// <summary>
/// Builder for constructing NativeObservationDocument from analysis results.
/// </summary>
public sealed class NativeObservationBuilder
{
private NativeObservationBinary? _binary;
private readonly List<NativeObservationEntrypoint> _entrypoints = [];
private readonly List<NativeObservationDeclaredEdge> _declaredEdges = [];
private readonly List<NativeObservationHeuristicEdge> _heuristicEdges = [];
private readonly List<NativeObservationRuntimeEdge> _runtimeEdges = [];
private NativeObservationEnvironment _environment = new();
private readonly List<NativeObservationResolution> _resolutions = [];
/// <summary>
/// Sets the binary identity and metadata.
/// </summary>
public NativeObservationBuilder WithBinary(
string path,
NativeFormat format,
string? sha256 = null,
string? architecture = null,
string? buildId = null,
bool isUniversal = false,
string? subsystem = null,
bool is64Bit = true)
{
_binary = new NativeObservationBinary
{
Path = path,
Format = format.ToString().ToLowerInvariant(),
Sha256 = sha256,
Architecture = architecture,
BuildId = buildId,
IsUniversal = isUniversal,
Subsystem = subsystem,
Is64Bit = is64Bit,
};
return this;
}
/// <summary>
/// Adds an entrypoint.
/// </summary>
public NativeObservationBuilder AddEntrypoint(
string type,
string? symbol = null,
long? address = null,
IEnumerable<string>? conditions = null)
{
_entrypoints.Add(new NativeObservationEntrypoint
{
Type = type,
Symbol = symbol,
Address = address,
Conditions = conditions?.ToList() ?? [],
});
return this;
}
/// <summary>
/// Adds ELF declared dependencies from dynamic section.
/// </summary>
public NativeObservationBuilder AddElfDependencies(ElfDynamicInfo elfInfo)
{
foreach (var dep in elfInfo.Dependencies)
{
_declaredEdges.Add(new NativeObservationDeclaredEdge
{
Target = dep.Soname,
Reason = dep.ReasonCode,
VersionNeeds = dep.VersionNeeds.Select(v => new NativeObservationVersionNeed
{
Version = v.Version,
Hash = v.Hash,
}).ToList(),
});
}
_environment = _environment with
{
Interpreter = elfInfo.Interpreter,
Rpath = elfInfo.Rpath.Count > 0 ? elfInfo.Rpath : null,
Runpath = elfInfo.Runpath.Count > 0 ? elfInfo.Runpath : null,
};
return this;
}
/// <summary>
/// Adds PE declared dependencies from import tables.
/// </summary>
public NativeObservationBuilder AddPeDependencies(PeImportInfo peInfo)
{
foreach (var dep in peInfo.Dependencies)
{
_declaredEdges.Add(new NativeObservationDeclaredEdge
{
Target = dep.DllName,
Reason = dep.ReasonCode,
Imports = dep.ImportedFunctions.Count > 0 ? dep.ImportedFunctions : null,
});
}
foreach (var delayDep in peInfo.DelayLoadDependencies)
{
_declaredEdges.Add(new NativeObservationDeclaredEdge
{
Target = delayDep.DllName,
Reason = delayDep.ReasonCode,
Imports = delayDep.ImportedFunctions.Count > 0 ? delayDep.ImportedFunctions : null,
});
}
if (peInfo.SxsDependencies.Count > 0)
{
_environment = _environment with
{
SxsDependencies = peInfo.SxsDependencies.Select(s => new NativeObservationSxsDependency
{
Name = s.Name,
Version = s.Version,
PublicKeyToken = s.PublicKeyToken,
ProcessorArchitecture = s.ProcessorArchitecture,
}).ToList(),
};
}
return this;
}
/// <summary>
/// Adds Mach-O declared dependencies from load commands.
/// </summary>
public NativeObservationBuilder AddMachODependencies(MachOImportInfo machoInfo)
{
var allRpaths = new List<string>();
foreach (var slice in machoInfo.Slices)
{
foreach (var dep in slice.Dependencies)
{
// Avoid duplicates across slices
if (_declaredEdges.Any(e => e.Target == dep.Path && e.Reason == dep.ReasonCode))
{
continue;
}
_declaredEdges.Add(new NativeObservationDeclaredEdge
{
Target = dep.Path,
Reason = dep.ReasonCode,
Version = dep.CurrentVersion,
CompatVersion = dep.CompatibilityVersion,
});
}
allRpaths.AddRange(slice.Rpaths.Where(r => !allRpaths.Contains(r)));
}
if (allRpaths.Count > 0)
{
_environment = _environment with
{
MachORpaths = allRpaths,
};
}
return this;
}
/// <summary>
/// Adds heuristic scan results.
/// </summary>
public NativeObservationBuilder AddHeuristicResults(HeuristicScanResult scanResult)
{
foreach (var edge in scanResult.Edges)
{
_heuristicEdges.Add(new NativeObservationHeuristicEdge
{
Target = edge.LibraryName,
Reason = edge.ReasonCode,
Confidence = edge.Confidence.ToString().ToLowerInvariant(),
Context = edge.Context,
Offset = edge.FileOffset,
});
}
if (scanResult.PluginConfigs.Count > 0)
{
_environment = _environment with
{
PluginConfigs = scanResult.PluginConfigs,
};
}
return this;
}
/// <summary>
/// Adds a resolution result with explain trace.
/// </summary>
public NativeObservationBuilder AddResolution(ResolveResult result)
{
_resolutions.Add(new NativeObservationResolution
{
Requested = result.RequestedName,
Resolved = result.Resolved,
ResolvedPath = result.ResolvedPath,
Steps = result.Steps.Select(s => new NativeObservationResolutionStep
{
SearchPath = s.SearchPath,
Reason = s.SearchReason,
Found = s.Found,
}).ToList(),
});
return this;
}
/// <summary>
/// Sets the default search paths for the platform.
/// </summary>
public NativeObservationBuilder WithDefaultSearchPaths(IEnumerable<string> paths)
{
_environment = _environment with
{
DefaultSearchPaths = paths.ToList(),
};
return this;
}
/// <summary>
/// Adds a runtime-observed dependency edge.
/// </summary>
public NativeObservationBuilder AddRuntimeEdge(
string target,
string reasonCode,
HeuristicConfidence confidence,
DateTime? firstObserved = null,
int? observationCount = null)
{
_runtimeEdges.Add(new NativeObservationRuntimeEdge
{
Target = target,
Reason = reasonCode,
Confidence = confidence.ToString().ToLowerInvariant(),
FirstObserved = firstObserved,
ObservationCount = observationCount,
});
return this;
}
/// <summary>
/// Builds the observation document.
/// </summary>
public NativeObservationDocument Build()
{
if (_binary is null)
{
throw new InvalidOperationException("Binary information must be set before building.");
}
return new NativeObservationDocument
{
Binary = _binary,
Entrypoints = _entrypoints,
DeclaredEdges = _declaredEdges,
HeuristicEdges = _heuristicEdges,
RuntimeEdges = _runtimeEdges,
Environment = _environment,
Resolution = _resolutions,
};
}
}

View File

@@ -0,0 +1,294 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Analyzers.Native.Observations;
/// <summary>
/// AOC-compliant observation document for native binary analysis.
/// Contains entrypoints, dependency edges, and environment profiles.
/// </summary>
public sealed record NativeObservationDocument
{
/// <summary>Schema identifier for this observation format.</summary>
[JsonPropertyName("$schema")]
public string Schema { get; init; } = "stellaops.native.observation@1";
/// <summary>Binary identity (path, hash, format).</summary>
[JsonPropertyName("binary")]
public required NativeObservationBinary Binary { get; init; }
/// <summary>Detected entrypoints in this binary.</summary>
[JsonPropertyName("entrypoints")]
public IReadOnlyList<NativeObservationEntrypoint> Entrypoints { get; init; } = [];
/// <summary>Declared dependency edges from import tables/load commands.</summary>
[JsonPropertyName("declared_edges")]
public IReadOnlyList<NativeObservationDeclaredEdge> DeclaredEdges { get; init; } = [];
/// <summary>Heuristically detected edges (dlopen strings, etc).</summary>
[JsonPropertyName("heuristic_edges")]
public IReadOnlyList<NativeObservationHeuristicEdge> HeuristicEdges { get; init; } = [];
/// <summary>Runtime-observed edges from capture sessions.</summary>
[JsonPropertyName("runtime_edges")]
public IReadOnlyList<NativeObservationRuntimeEdge> RuntimeEdges { get; init; } = [];
/// <summary>Environment profile (search paths, interpreter, loader metadata).</summary>
[JsonPropertyName("environment")]
public required NativeObservationEnvironment Environment { get; init; }
/// <summary>Resolver results for dependency resolution explain traces.</summary>
[JsonPropertyName("resolution")]
public IReadOnlyList<NativeObservationResolution> Resolution { get; init; } = [];
}
/// <summary>
/// Binary identity and metadata.
/// </summary>
public sealed record NativeObservationBinary
{
/// <summary>Path to the binary within the image/filesystem.</summary>
[JsonPropertyName("path")]
public required string Path { get; init; }
/// <summary>SHA256 hash of the binary content.</summary>
[JsonPropertyName("sha256")]
public string? Sha256 { get; init; }
/// <summary>Native format (elf, pe, macho).</summary>
[JsonPropertyName("format")]
public required string Format { get; init; }
/// <summary>Target architecture (x86_64, arm64, etc).</summary>
[JsonPropertyName("architecture")]
public string? Architecture { get; init; }
/// <summary>Build ID (ELF) or UUID (Mach-O).</summary>
[JsonPropertyName("build_id")]
public string? BuildId { get; init; }
/// <summary>True if this is a universal/fat binary (Mach-O).</summary>
[JsonPropertyName("is_universal")]
public bool IsUniversal { get; init; }
/// <summary>PE subsystem (windows_gui, windows_console, etc).</summary>
[JsonPropertyName("subsystem")]
public string? Subsystem { get; init; }
/// <summary>True if this is a 64-bit binary.</summary>
[JsonPropertyName("is_64bit")]
public bool Is64Bit { get; init; }
}
/// <summary>
/// Entrypoint detected in the binary.
/// </summary>
public sealed record NativeObservationEntrypoint
{
/// <summary>Entrypoint type (main, init_array, constructor, dllmain, etc).</summary>
[JsonPropertyName("type")]
public required string Type { get; init; }
/// <summary>Symbol name if available.</summary>
[JsonPropertyName("symbol")]
public string? Symbol { get; init; }
/// <summary>Virtual address of the entrypoint.</summary>
[JsonPropertyName("address")]
public long? Address { get; init; }
/// <summary>Condition set for this entrypoint.</summary>
[JsonPropertyName("conditions")]
public IReadOnlyList<string> Conditions { get; init; } = [];
}
/// <summary>
/// Declared dependency edge from import table/load command.
/// </summary>
public sealed record NativeObservationDeclaredEdge
{
/// <summary>Target library name or path.</summary>
[JsonPropertyName("target")]
public required string Target { get; init; }
/// <summary>Reason code (elf-dtneeded, pe-import, pe-delayimport, macho-loadlib, etc).</summary>
[JsonPropertyName("reason")]
public required string Reason { get; init; }
/// <summary>Version information if available.</summary>
[JsonPropertyName("version")]
public string? Version { get; init; }
/// <summary>Compatibility version (Mach-O).</summary>
[JsonPropertyName("compat_version")]
public string? CompatVersion { get; init; }
/// <summary>Imported functions (PE).</summary>
[JsonPropertyName("imports")]
public IReadOnlyList<string>? Imports { get; init; }
/// <summary>Version needs (ELF).</summary>
[JsonPropertyName("version_needs")]
public IReadOnlyList<NativeObservationVersionNeed>? VersionNeeds { get; init; }
}
/// <summary>
/// ELF version need record.
/// </summary>
public sealed record NativeObservationVersionNeed
{
/// <summary>Version string.</summary>
[JsonPropertyName("version")]
public required string Version { get; init; }
/// <summary>Version hash.</summary>
[JsonPropertyName("hash")]
public uint Hash { get; init; }
}
/// <summary>
/// Heuristically detected dependency edge.
/// </summary>
public sealed record NativeObservationHeuristicEdge
{
/// <summary>Target library name.</summary>
[JsonPropertyName("target")]
public required string Target { get; init; }
/// <summary>Reason code (string-dlopen, string-loadlibrary, config-plugin, go-cgo-import, rust-ffi).</summary>
[JsonPropertyName("reason")]
public required string Reason { get; init; }
/// <summary>Confidence level (low, medium, high).</summary>
[JsonPropertyName("confidence")]
public required string Confidence { get; init; }
/// <summary>Context about how it was detected.</summary>
[JsonPropertyName("context")]
public string? Context { get; init; }
/// <summary>File offset where the string was found.</summary>
[JsonPropertyName("offset")]
public long? Offset { get; init; }
}
/// <summary>
/// Runtime-observed dependency edge from capture session.
/// </summary>
public sealed record NativeObservationRuntimeEdge
{
/// <summary>Target library path or name.</summary>
[JsonPropertyName("target")]
public required string Target { get; init; }
/// <summary>Reason code (runtime-dlopen, runtime-loadlibrary, runtime-dylib, etc).</summary>
[JsonPropertyName("reason")]
public required string Reason { get; init; }
/// <summary>Confidence level (always high for runtime evidence).</summary>
[JsonPropertyName("confidence")]
public required string Confidence { get; init; }
/// <summary>First time this edge was observed.</summary>
[JsonPropertyName("first_observed")]
public DateTime? FirstObserved { get; init; }
/// <summary>Number of times this edge was observed.</summary>
[JsonPropertyName("observation_count")]
public int? ObservationCount { get; init; }
}
/// <summary>
/// Environment profile with search paths and loader metadata.
/// </summary>
public sealed record NativeObservationEnvironment
{
/// <summary>Dynamic linker/interpreter path.</summary>
[JsonPropertyName("interpreter")]
public string? Interpreter { get; init; }
/// <summary>DT_RPATH entries (ELF).</summary>
[JsonPropertyName("rpath")]
public IReadOnlyList<string>? Rpath { get; init; }
/// <summary>DT_RUNPATH entries (ELF).</summary>
[JsonPropertyName("runpath")]
public IReadOnlyList<string>? Runpath { get; init; }
/// <summary>LC_RPATH entries (Mach-O).</summary>
[JsonPropertyName("macho_rpaths")]
public IReadOnlyList<string>? MachORpaths { get; init; }
/// <summary>Default search paths for the platform.</summary>
[JsonPropertyName("default_search_paths")]
public IReadOnlyList<string>? DefaultSearchPaths { get; init; }
/// <summary>Plugin configuration files referenced.</summary>
[JsonPropertyName("plugin_configs")]
public IReadOnlyList<string>? PluginConfigs { get; init; }
/// <summary>SxS dependencies (PE).</summary>
[JsonPropertyName("sxs_dependencies")]
public IReadOnlyList<NativeObservationSxsDependency>? SxsDependencies { get; init; }
}
/// <summary>
/// Windows Side-by-Side (SxS) dependency.
/// </summary>
public sealed record NativeObservationSxsDependency
{
/// <summary>Assembly name.</summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>Assembly version.</summary>
[JsonPropertyName("version")]
public string? Version { get; init; }
/// <summary>Public key token.</summary>
[JsonPropertyName("public_key_token")]
public string? PublicKeyToken { get; init; }
/// <summary>Processor architecture.</summary>
[JsonPropertyName("processor_architecture")]
public string? ProcessorArchitecture { get; init; }
}
/// <summary>
/// Resolution result for a dependency with explain trace.
/// </summary>
public sealed record NativeObservationResolution
{
/// <summary>The library name that was resolved.</summary>
[JsonPropertyName("requested")]
public required string Requested { get; init; }
/// <summary>Whether resolution succeeded.</summary>
[JsonPropertyName("resolved")]
public bool Resolved { get; init; }
/// <summary>The final resolved path if found.</summary>
[JsonPropertyName("resolved_path")]
public string? ResolvedPath { get; init; }
/// <summary>Resolution steps taken (explain trace).</summary>
[JsonPropertyName("steps")]
public IReadOnlyList<NativeObservationResolutionStep> Steps { get; init; } = [];
}
/// <summary>
/// A single step in the resolution explain trace.
/// </summary>
public sealed record NativeObservationResolutionStep
{
/// <summary>The path that was searched.</summary>
[JsonPropertyName("search_path")]
public required string SearchPath { get; init; }
/// <summary>Why this path was searched (rpath, runpath, default, etc).</summary>
[JsonPropertyName("reason")]
public required string Reason { get; init; }
/// <summary>Whether the library was found at this path.</summary>
[JsonPropertyName("found")]
public bool Found { get; init; }
}

View File

@@ -0,0 +1,136 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Analyzers.Native.Observations;
/// <summary>
/// Serializes NativeObservationDocument to JSON and computes content hashes.
/// </summary>
public static class NativeObservationSerializer
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
private static readonly JsonSerializerOptions PrettySerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
/// <summary>
/// Serializes the observation document to compact JSON.
/// </summary>
public static string Serialize(NativeObservationDocument document)
{
ArgumentNullException.ThrowIfNull(document);
return JsonSerializer.Serialize(document, SerializerOptions);
}
/// <summary>
/// Serializes the observation document to pretty-printed JSON.
/// </summary>
public static string SerializePretty(NativeObservationDocument document)
{
ArgumentNullException.ThrowIfNull(document);
return JsonSerializer.Serialize(document, PrettySerializerOptions);
}
/// <summary>
/// Serializes the observation document to a UTF-8 byte array.
/// </summary>
public static byte[] SerializeToBytes(NativeObservationDocument document)
{
ArgumentNullException.ThrowIfNull(document);
return JsonSerializer.SerializeToUtf8Bytes(document, SerializerOptions);
}
/// <summary>
/// Deserializes a JSON string to an observation document.
/// </summary>
public static NativeObservationDocument? Deserialize(string json)
{
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
return JsonSerializer.Deserialize<NativeObservationDocument>(json, SerializerOptions);
}
/// <summary>
/// Deserializes a UTF-8 byte array to an observation document.
/// </summary>
public static NativeObservationDocument? Deserialize(ReadOnlySpan<byte> json)
{
if (json.IsEmpty)
{
return null;
}
return JsonSerializer.Deserialize<NativeObservationDocument>(json, SerializerOptions);
}
/// <summary>
/// Computes SHA256 hash of the serialized document.
/// </summary>
public static string ComputeSha256(NativeObservationDocument document)
{
var bytes = SerializeToBytes(document);
return ComputeSha256(bytes);
}
/// <summary>
/// Computes SHA256 hash of a JSON string.
/// </summary>
public static string ComputeSha256(string json)
{
var bytes = Encoding.UTF8.GetBytes(json);
return ComputeSha256(bytes);
}
/// <summary>
/// Computes SHA256 hash of a byte array.
/// </summary>
public static string ComputeSha256(byte[] data)
{
var hash = SHA256.HashData(data);
return Convert.ToHexString(hash).ToLowerInvariant();
}
/// <summary>
/// Writes the observation document to a stream.
/// </summary>
public static async Task WriteAsync(
NativeObservationDocument document,
Stream stream,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(document);
ArgumentNullException.ThrowIfNull(stream);
await JsonSerializer.SerializeAsync(stream, document, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
}
/// <summary>
/// Reads an observation document from a stream.
/// </summary>
public static async Task<NativeObservationDocument?> ReadAsync(
Stream stream,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(stream);
return await JsonSerializer.DeserializeAsync<NativeObservationDocument>(
stream, SerializerOptions, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,65 @@
namespace StellaOps.Scanner.Analyzers.Native;
/// <summary>
/// Represents a declared dependency extracted from PE import tables.
/// </summary>
/// <param name="DllName">The DLL name from the import table.</param>
/// <param name="ReasonCode">The reason code: "pe-import" or "pe-delayimport".</param>
/// <param name="ImportedFunctions">Names of imported functions (if available).</param>
public sealed record PeDeclaredDependency(
string DllName,
string ReasonCode,
IReadOnlyList<string> ImportedFunctions);
/// <summary>
/// Represents a Side-by-Side (SxS) assembly dependency from embedded manifest.
/// </summary>
/// <param name="Name">Assembly name (e.g., "Microsoft.Windows.Common-Controls").</param>
/// <param name="Version">Version string.</param>
/// <param name="PublicKeyToken">Public key token for strong-named assemblies.</param>
/// <param name="ProcessorArchitecture">Target architecture (x86, amd64, etc.).</param>
/// <param name="Type">Assembly type (e.g., "win32").</param>
public sealed record PeSxsDependency(
string Name,
string? Version,
string? PublicKeyToken,
string? ProcessorArchitecture,
string? Type);
/// <summary>
/// PE subsystem type.
/// </summary>
public enum PeSubsystem : ushort
{
Unknown = 0,
Native = 1,
WindowsGui = 2,
WindowsConsole = 3,
Os2Console = 5,
PosixConsole = 7,
NativeWindows = 8,
WindowsCeGui = 9,
EfiApplication = 10,
EfiBootServiceDriver = 11,
EfiRuntimeDriver = 12,
EfiRom = 13,
Xbox = 14,
WindowsBootApplication = 16,
}
/// <summary>
/// Contains all import information extracted from a PE binary.
/// </summary>
/// <param name="Machine">The target machine architecture.</param>
/// <param name="Subsystem">The PE subsystem type.</param>
/// <param name="Is64Bit">True if PE32+, false if PE32.</param>
/// <param name="Dependencies">Standard import table dependencies.</param>
/// <param name="DelayLoadDependencies">Delay-load import dependencies.</param>
/// <param name="SxsDependencies">Side-by-Side assembly dependencies from manifest.</param>
public sealed record PeImportInfo(
string? Machine,
PeSubsystem Subsystem,
bool Is64Bit,
IReadOnlyList<PeDeclaredDependency> Dependencies,
IReadOnlyList<PeDeclaredDependency> DelayLoadDependencies,
IReadOnlyList<PeSxsDependency> SxsDependencies);

View File

@@ -0,0 +1,579 @@
using System.Buffers.Binary;
using System.Text;
using System.Xml;
namespace StellaOps.Scanner.Analyzers.Native;
/// <summary>
/// Parses PE import tables, delay-load imports, and embedded manifests.
/// </summary>
public static class PeImportParser
{
private const int IMAGE_DIRECTORY_ENTRY_IMPORT = 1;
private const int IMAGE_DIRECTORY_ENTRY_RESOURCE = 2;
private const int IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT = 13;
private const int RT_MANIFEST = 24;
/// <summary>
/// Parses PE import information from a stream.
/// </summary>
public static bool TryParse(Stream stream, out PeImportInfo importInfo, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(stream);
importInfo = new PeImportInfo(null, PeSubsystem.Unknown, false, [], [], []);
using var buffer = new MemoryStream();
stream.CopyTo(buffer);
var data = buffer.ToArray();
var span = data.AsSpan();
if (!IsValidPe(span, out var peHeaderOffset))
{
return false;
}
// Parse COFF header
var machine = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(peHeaderOffset + 4, 2));
var numberOfSections = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(peHeaderOffset + 6, 2));
var sizeOfOptionalHeader = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(peHeaderOffset + 20, 2));
if (sizeOfOptionalHeader == 0)
{
return false;
}
var optionalHeaderOffset = peHeaderOffset + 24;
var magic = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(optionalHeaderOffset, 2));
var is64Bit = magic == 0x20b; // PE32+
if (magic != 0x10b && magic != 0x20b) // PE32 or PE32+
{
return false;
}
// Get subsystem
var subsystemOffset = optionalHeaderOffset + (is64Bit ? 68 : 68);
var subsystem = (PeSubsystem)BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(subsystemOffset, 2));
// Get number of RVA and sizes
var numberOfRvaAndSizes = BinaryPrimitives.ReadUInt32LittleEndian(
span.Slice(optionalHeaderOffset + (is64Bit ? 108 : 92), 4));
// Data directories start after optional header fields
var dataDirectoryOffset = optionalHeaderOffset + (is64Bit ? 112 : 96);
// Section headers start after optional header
var sectionHeadersOffset = optionalHeaderOffset + sizeOfOptionalHeader;
// Parse sections for RVA-to-file-offset translation
var sections = ParseSectionHeaders(span, sectionHeadersOffset, numberOfSections);
// Parse import directory
var dependencies = new List<PeDeclaredDependency>();
if (numberOfRvaAndSizes > IMAGE_DIRECTORY_ENTRY_IMPORT)
{
var importDirOffset = dataDirectoryOffset + IMAGE_DIRECTORY_ENTRY_IMPORT * 8;
var importRva = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(importDirOffset, 4));
var importSize = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(importDirOffset + 4, 4));
if (importRva > 0 && importSize > 0)
{
dependencies = ParseImportDirectory(span, importRva, sections, "pe-import");
}
}
// Parse delay-load import directory
var delayLoadDependencies = new List<PeDeclaredDependency>();
if (numberOfRvaAndSizes > IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT)
{
var delayImportDirOffset = dataDirectoryOffset + IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT * 8;
var delayImportRva = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(delayImportDirOffset, 4));
var delayImportSize = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(delayImportDirOffset + 4, 4));
if (delayImportRva > 0 && delayImportSize > 0)
{
delayLoadDependencies = ParseDelayImportDirectory(span, delayImportRva, sections, is64Bit);
}
}
// Parse embedded manifest for SxS dependencies
var sxsDependencies = new List<PeSxsDependency>();
if (numberOfRvaAndSizes > IMAGE_DIRECTORY_ENTRY_RESOURCE)
{
var resourceDirOffset = dataDirectoryOffset + IMAGE_DIRECTORY_ENTRY_RESOURCE * 8;
var resourceRva = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(resourceDirOffset, 4));
var resourceSize = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(resourceDirOffset + 4, 4));
if (resourceRva > 0 && resourceSize > 0)
{
sxsDependencies = ParseManifestFromResources(span, resourceRva, sections);
}
}
// Fallback: always search for manifest XML if none found via resources
if (sxsDependencies.Count == 0)
{
var manifestData = SearchForManifestXml(span);
if (manifestData is not null && manifestData.Length > 0)
{
sxsDependencies = ParseManifestXml(manifestData);
}
}
var machineStr = MapPeMachine(machine);
importInfo = new PeImportInfo(machineStr, subsystem, is64Bit, dependencies, delayLoadDependencies, sxsDependencies);
return true;
}
private static bool IsValidPe(ReadOnlySpan<byte> span, out int peHeaderOffset)
{
peHeaderOffset = 0;
if (span.Length < 0x40)
{
return false;
}
if (span[0] != 'M' || span[1] != 'Z')
{
return false;
}
peHeaderOffset = BinaryPrimitives.ReadInt32LittleEndian(span.Slice(0x3C, 4));
if (peHeaderOffset < 0 || peHeaderOffset + 24 > span.Length)
{
return false;
}
// Check PE signature
return span[peHeaderOffset] == 'P' && span[peHeaderOffset + 1] == 'E' &&
span[peHeaderOffset + 2] == 0 && span[peHeaderOffset + 3] == 0;
}
private sealed record SectionInfo(string Name, uint VirtualAddress, uint VirtualSize, uint RawDataOffset, uint RawDataSize);
private static List<SectionInfo> ParseSectionHeaders(ReadOnlySpan<byte> span, int offset, int count)
{
var sections = new List<SectionInfo>(count);
const int sectionHeaderSize = 40;
for (var i = 0; i < count; i++)
{
var sectionOffset = offset + i * sectionHeaderSize;
if (sectionOffset + sectionHeaderSize > span.Length)
{
break;
}
var nameBytes = span.Slice(sectionOffset, 8);
var nameEnd = nameBytes.IndexOf((byte)0);
var name = nameEnd >= 0
? Encoding.ASCII.GetString(nameBytes[..nameEnd])
: Encoding.ASCII.GetString(nameBytes);
var virtualSize = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(sectionOffset + 8, 4));
var virtualAddress = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(sectionOffset + 12, 4));
var rawDataSize = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(sectionOffset + 16, 4));
var rawDataOffset = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(sectionOffset + 20, 4));
sections.Add(new SectionInfo(name, virtualAddress, virtualSize, rawDataOffset, rawDataSize));
}
return sections;
}
private static int RvaToFileOffset(uint rva, List<SectionInfo> sections)
{
foreach (var section in sections)
{
if (rva >= section.VirtualAddress && rva < section.VirtualAddress + section.VirtualSize)
{
return (int)(section.RawDataOffset + (rva - section.VirtualAddress));
}
}
return -1;
}
private static List<PeDeclaredDependency> ParseImportDirectory(
ReadOnlySpan<byte> span, uint importRva, List<SectionInfo> sections, string reasonCode)
{
var dependencies = new List<PeDeclaredDependency>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var importOffset = RvaToFileOffset(importRva, sections);
if (importOffset < 0 || importOffset + 20 > span.Length)
{
return dependencies;
}
// Each import descriptor is 20 bytes
const int descriptorSize = 20;
var offset = importOffset;
while (offset + descriptorSize <= span.Length)
{
var originalFirstThunk = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(offset, 4));
var nameRva = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(offset + 12, 4));
// End of import directory (null entry)
if (nameRva == 0)
{
break;
}
var nameOffset = RvaToFileOffset(nameRva, sections);
if (nameOffset >= 0 && nameOffset < span.Length)
{
var dllName = ReadNullTerminatedString(span, nameOffset);
if (!string.IsNullOrEmpty(dllName) && seen.Add(dllName))
{
// Parse imported function names (optional, for detailed analysis)
var functions = ParseImportedFunctions(span, originalFirstThunk, sections, is64Bit: false);
dependencies.Add(new PeDeclaredDependency(dllName, reasonCode, functions));
}
}
offset += descriptorSize;
}
return dependencies;
}
private static List<PeDeclaredDependency> ParseDelayImportDirectory(
ReadOnlySpan<byte> span, uint delayImportRva, List<SectionInfo> sections, bool is64Bit)
{
var dependencies = new List<PeDeclaredDependency>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var delayImportOffset = RvaToFileOffset(delayImportRva, sections);
if (delayImportOffset < 0)
{
return dependencies;
}
// Delay import descriptor is 32 bytes
const int descriptorSize = 32;
var offset = delayImportOffset;
while (offset + descriptorSize <= span.Length)
{
var attributes = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(offset, 4));
var nameRva = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(offset + 4, 4));
var moduleHandleRva = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(offset + 8, 4));
var delayImportAddressTableRva = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(offset + 12, 4));
var delayImportNameTableRva = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(offset + 16, 4));
// End of delay import directory (null entry)
if (nameRva == 0)
{
break;
}
var nameOffset = RvaToFileOffset(nameRva, sections);
if (nameOffset >= 0 && nameOffset < span.Length)
{
var dllName = ReadNullTerminatedString(span, nameOffset);
if (!string.IsNullOrEmpty(dllName) && seen.Add(dllName))
{
var functions = ParseImportedFunctions(span, delayImportNameTableRva, sections, is64Bit);
dependencies.Add(new PeDeclaredDependency(dllName, "pe-delayimport", functions));
}
}
offset += descriptorSize;
}
return dependencies;
}
private static List<string> ParseImportedFunctions(
ReadOnlySpan<byte> span, uint thunkRva, List<SectionInfo> sections, bool is64Bit)
{
var functions = new List<string>();
if (thunkRva == 0)
{
return functions;
}
var thunkOffset = RvaToFileOffset(thunkRva, sections);
if (thunkOffset < 0)
{
return functions;
}
var entrySize = is64Bit ? 8 : 4;
var ordinalFlag = is64Bit ? 0x8000000000000000UL : 0x80000000UL;
var offset = thunkOffset;
var maxFunctions = 1000; // Limit to prevent infinite loops
while (offset + entrySize <= span.Length && functions.Count < maxFunctions)
{
ulong thunkData = is64Bit
? BinaryPrimitives.ReadUInt64LittleEndian(span.Slice(offset, 8))
: BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(offset, 4));
if (thunkData == 0)
{
break;
}
if ((thunkData & ordinalFlag) == 0)
{
// Import by name
var hintNameRva = (uint)(thunkData & 0x7FFFFFFF);
var hintNameOffset = RvaToFileOffset(hintNameRva, sections);
if (hintNameOffset >= 0 && hintNameOffset + 2 < span.Length)
{
// Skip 2-byte hint, read function name
var funcName = ReadNullTerminatedString(span, hintNameOffset + 2);
if (!string.IsNullOrEmpty(funcName))
{
functions.Add(funcName);
}
}
}
else
{
// Import by ordinal
var ordinal = (ushort)(thunkData & 0xFFFF);
functions.Add($"#ord{ordinal}");
}
offset += entrySize;
}
return functions;
}
private static List<PeSxsDependency> ParseManifestFromResources(
ReadOnlySpan<byte> span, uint resourceRva, List<SectionInfo> sections)
{
var sxsDependencies = new List<PeSxsDependency>();
var resourceOffset = RvaToFileOffset(resourceRva, sections);
// Try to parse resource directory to find RT_MANIFEST
byte[]? manifestData = null;
if (resourceOffset >= 0 && resourceOffset + 16 <= span.Length)
{
manifestData = FindManifestResource(span, resourceOffset, resourceRva, sections);
}
// Fallback: search for manifest XML anywhere in the binary
if (manifestData is null || manifestData.Length == 0)
{
manifestData = SearchForManifestXml(span);
}
if (manifestData is null || manifestData.Length == 0)
{
return sxsDependencies;
}
// Parse XML manifest
return ParseManifestXml(manifestData);
}
private static byte[]? FindManifestResource(
ReadOnlySpan<byte> span, int resourceOffset, uint resourceRva, List<SectionInfo> sections)
{
// Resource directory structure:
// DWORD Characteristics, DWORD TimeDateStamp, WORD MajorVersion, WORD MinorVersion
// WORD NumberOfNamedEntries, WORD NumberOfIdEntries
// Then entries...
if (resourceOffset + 16 > span.Length)
{
return null;
}
var numberOfNamedEntries = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(resourceOffset + 12, 2));
var numberOfIdEntries = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(resourceOffset + 14, 2));
var entryOffset = resourceOffset + 16;
// Skip named entries, look for RT_MANIFEST (ID = 24)
entryOffset += numberOfNamedEntries * 8;
for (var i = 0; i < numberOfIdEntries; i++)
{
if (entryOffset + 8 > span.Length)
{
break;
}
var id = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(entryOffset, 4));
var offsetOrData = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(entryOffset + 4, 4));
if (id == RT_MANIFEST)
{
// High bit set means subdirectory
if ((offsetOrData & 0x80000000) != 0)
{
var subDirOffset = resourceOffset + (int)(offsetOrData & 0x7FFFFFFF);
return FindFirstResourceData(span, subDirOffset, resourceOffset);
}
}
entryOffset += 8;
}
return null;
}
private static byte[]? FindFirstResourceData(ReadOnlySpan<byte> span, int dirOffset, int resourceBase)
{
if (dirOffset + 16 > span.Length)
{
return null;
}
var numberOfNamedEntries = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(dirOffset + 12, 2));
var numberOfIdEntries = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(dirOffset + 14, 2));
var entryOffset = dirOffset + 16 + numberOfNamedEntries * 8;
if (numberOfIdEntries > 0 && entryOffset + 8 <= span.Length)
{
var offsetOrData = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(entryOffset + 4, 4));
if ((offsetOrData & 0x80000000) != 0)
{
// Another subdirectory (language level)
var langDirOffset = resourceBase + (int)(offsetOrData & 0x7FFFFFFF);
return FindFirstResourceData(span, langDirOffset, resourceBase);
}
else
{
// Data entry
var dataEntryOffset = resourceBase + (int)offsetOrData;
if (dataEntryOffset + 16 <= span.Length)
{
var dataRva = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(dataEntryOffset, 4));
var dataSize = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(dataEntryOffset + 4, 4));
// For resources, the RVA is relative to the image base, but we need the file offset
// Resource data RVA is typically within the .rsrc section
var dataOffset = dataEntryOffset - resourceBase + (int)dataRva - (int)dataRva;
// Actually, we need to convert the RVA properly
// Find which section contains this RVA
foreach (var section in ParseSectionHeaders(span, 0, 0))
{
// This approach won't work without sections, let's use a simpler heuristic
}
// Simple heuristic: data is often right after the directory in .rsrc section
// For embedded manifests, just search for "<?xml" or "<assembly"
return SearchForManifestXml(span);
}
}
}
return null;
}
private static byte[]? SearchForManifestXml(ReadOnlySpan<byte> span)
{
// Search for XML manifest markers
var xmlMarker = "<?xml"u8;
var assemblyMarker = "<assembly"u8;
for (var i = 0; i < span.Length - 100; i++)
{
if (span.Slice(i).StartsWith(xmlMarker) || span.Slice(i).StartsWith(assemblyMarker))
{
// Find the end of the manifest
var endMarker = "</assembly>"u8;
for (var j = i; j < span.Length - endMarker.Length; j++)
{
if (span.Slice(j).StartsWith(endMarker))
{
var manifestLength = j - i + endMarker.Length;
return span.Slice(i, manifestLength).ToArray();
}
}
}
}
return null;
}
private static List<PeSxsDependency> ParseManifestXml(byte[] manifestData)
{
var sxsDependencies = new List<PeSxsDependency>();
try
{
var xmlString = Encoding.UTF8.GetString(manifestData);
// Handle BOM if present
if (xmlString.Length > 0 && xmlString[0] == '\uFEFF')
{
xmlString = xmlString[1..];
}
using var reader = XmlReader.Create(new StringReader(xmlString), new XmlReaderSettings
{
IgnoreWhitespace = true,
IgnoreComments = true,
DtdProcessing = DtdProcessing.Ignore,
});
while (reader.Read())
{
if (reader.NodeType == XmlNodeType.Element && reader.LocalName == "assemblyIdentity")
{
// Check if this is inside a <dependentAssembly> element
var name = reader.GetAttribute("name");
var version = reader.GetAttribute("version");
var publicKeyToken = reader.GetAttribute("publicKeyToken");
var processorArchitecture = reader.GetAttribute("processorArchitecture");
var type = reader.GetAttribute("type");
if (!string.IsNullOrEmpty(name) && name != "MyApplication") // Skip self-reference
{
sxsDependencies.Add(new PeSxsDependency(
name, version, publicKeyToken, processorArchitecture, type));
}
}
}
}
catch
{
// Failed to parse manifest XML, return empty list
}
return sxsDependencies;
}
private static string ReadNullTerminatedString(ReadOnlySpan<byte> span, int offset)
{
if (offset < 0 || offset >= span.Length)
{
return string.Empty;
}
var remaining = span[offset..];
var terminator = remaining.IndexOf((byte)0);
var length = terminator >= 0 ? terminator : Math.Min(remaining.Length, 256);
return Encoding.ASCII.GetString(remaining[..length]);
}
private static string? MapPeMachine(ushort machine) => machine switch
{
0x014c => "x86",
0x0200 => "ia64",
0x8664 => "x86_64",
0x01c0 => "arm",
0x01c4 => "armv7",
0xAA64 => "arm64",
_ => null,
};
}

View File

@@ -0,0 +1,147 @@
namespace StellaOps.Scanner.Analyzers.Native.Plugin;
/// <summary>
/// Plugin interface for native binary analyzers.
/// Discovered via reflection from assemblies matching StellaOps.Scanner.Analyzers.Native*.dll
/// </summary>
public interface INativeAnalyzerPlugin
{
/// <summary>
/// Unique name identifying this plugin.
/// </summary>
string Name { get; }
/// <summary>
/// Human-readable description of the plugin.
/// </summary>
string Description { get; }
/// <summary>
/// Plugin version.
/// </summary>
string Version { get; }
/// <summary>
/// Supported native formats (elf, pe, macho).
/// </summary>
IReadOnlyList<NativeFormat> SupportedFormats { get; }
/// <summary>
/// Checks if this plugin is available on the current system.
/// May check for required dependencies, capabilities, or platform support.
/// </summary>
/// <param name="services">Service provider for dependency resolution.</param>
/// <returns>True if the plugin can be used.</returns>
bool IsAvailable(IServiceProvider services);
/// <summary>
/// Creates a native analyzer instance.
/// </summary>
/// <param name="services">Service provider for dependency injection.</param>
/// <returns>Configured native analyzer.</returns>
INativeAnalyzer CreateAnalyzer(IServiceProvider services);
}
/// <summary>
/// Interface for native binary analyzers.
/// Analyzes ELF, PE, and Mach-O binaries to extract dependency information.
/// </summary>
public interface INativeAnalyzer
{
/// <summary>
/// Analyzes a native binary and produces an observation document.
/// </summary>
/// <param name="binaryPath">Path to the binary within the virtual filesystem.</param>
/// <param name="binaryStream">Stream containing the binary content.</param>
/// <param name="options">Analysis options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Observation document with dependency edges and environment profile.</returns>
Task<Observations.NativeObservationDocument> AnalyzeAsync(
string binaryPath,
Stream binaryStream,
NativeAnalyzerOptions options,
CancellationToken cancellationToken = default);
/// <summary>
/// Batch analyzes multiple binaries.
/// </summary>
/// <param name="binaries">Collection of binary paths and streams.</param>
/// <param name="options">Analysis options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Observation documents for each binary.</returns>
IAsyncEnumerable<Observations.NativeObservationDocument> AnalyzeBatchAsync(
IAsyncEnumerable<(string Path, Stream Stream)> binaries,
NativeAnalyzerOptions options,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Options for native binary analysis.
/// </summary>
public sealed class NativeAnalyzerOptions
{
/// <summary>
/// Virtual filesystem for dependency resolution.
/// If null, resolution is skipped.
/// </summary>
public IVirtualFileSystem? VirtualFileSystem { get; set; }
/// <summary>
/// Whether to run heuristic string scanning.
/// Default: true.
/// </summary>
public bool EnableHeuristicScanning { get; set; } = true;
/// <summary>
/// Whether to resolve dependencies against the virtual filesystem.
/// Default: true.
/// </summary>
public bool EnableResolution { get; set; } = true;
/// <summary>
/// Whether to capture runtime evidence (requires elevated privileges).
/// Default: false.
/// </summary>
public bool EnableRuntimeCapture { get; set; }
/// <summary>
/// Runtime capture options if runtime capture is enabled.
/// </summary>
public RuntimeCapture.RuntimeCaptureOptions? RuntimeCaptureOptions { get; set; }
/// <summary>
/// LD_LIBRARY_PATH or equivalent for ELF resolution.
/// </summary>
public IReadOnlyList<string>? LibraryPath { get; set; }
/// <summary>
/// Default search paths for the target platform.
/// </summary>
public IReadOnlyList<string>? DefaultSearchPaths { get; set; }
/// <summary>
/// Binary path for $ORIGIN expansion (ELF).
/// </summary>
public string? BinaryDirectory { get; set; }
/// <summary>
/// Application directory for PE SafeDll search.
/// </summary>
public string? ApplicationDirectory { get; set; }
/// <summary>
/// Executable path for @executable_path expansion (Mach-O).
/// </summary>
public string? ExecutablePath { get; set; }
/// <summary>
/// Loader path for @loader_path expansion (Mach-O).
/// </summary>
public string? LoaderPath { get; set; }
/// <summary>
/// Maximum time for analysis per binary.
/// Default: 30 seconds.
/// </summary>
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
}

View File

@@ -0,0 +1,248 @@
using Microsoft.Extensions.Logging;
using System.Runtime.CompilerServices;
using StellaOps.Scanner.Analyzers.Native.Observations;
namespace StellaOps.Scanner.Analyzers.Native.Plugin;
/// <summary>
/// Default implementation of the native binary analyzer.
/// Orchestrates format detection, parsing, heuristic scanning, and resolution.
/// </summary>
public sealed class NativeAnalyzer : INativeAnalyzer
{
private readonly ILogger<NativeAnalyzer> _logger;
public NativeAnalyzer(ILogger<NativeAnalyzer> logger)
{
_logger = logger;
}
/// <inheritdoc />
public async Task<NativeObservationDocument> AnalyzeAsync(
string binaryPath,
Stream binaryStream,
NativeAnalyzerOptions options,
CancellationToken cancellationToken = default)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(options.Timeout);
try
{
return await AnalyzeCoreAsync(binaryPath, binaryStream, options, cts.Token);
}
catch (OperationCanceledException) when (cts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
_logger.LogWarning("Analysis of {BinaryPath} timed out after {Timeout}", binaryPath, options.Timeout);
throw new TimeoutException($"Analysis of {binaryPath} exceeded timeout of {options.Timeout}.");
}
}
/// <inheritdoc />
public async IAsyncEnumerable<NativeObservationDocument> AnalyzeBatchAsync(
IAsyncEnumerable<(string Path, Stream Stream)> binaries,
NativeAnalyzerOptions options,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach (var (path, stream) in binaries.WithCancellation(cancellationToken))
{
NativeObservationDocument? doc = null;
try
{
doc = await AnalyzeAsync(path, stream, options, cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Failed to analyze {BinaryPath}", path);
// Continue with other binaries
}
if (doc != null)
yield return doc;
}
}
private async Task<NativeObservationDocument> AnalyzeCoreAsync(
string binaryPath,
Stream binaryStream,
NativeAnalyzerOptions options,
CancellationToken cancellationToken)
{
// Step 1: Detect format and extract identity
if (!NativeFormatDetector.TryDetect(binaryStream, out var identity, cancellationToken))
{
throw new InvalidOperationException($"Unknown or unsupported binary format for {binaryPath}");
}
if (identity.Format == NativeFormat.Unknown)
{
throw new InvalidOperationException($"Unknown or unsupported binary format for {binaryPath}");
}
_logger.LogDebug("Detected {Format} binary at {Path}", identity.Format, binaryPath);
var builder = new NativeObservationBuilder()
.WithBinary(
binaryPath,
identity.Format,
architecture: identity.CpuArchitecture,
buildId: identity.BuildId);
// Step 2: Parse format-specific dependency information
binaryStream.Position = 0;
switch (identity.Format)
{
case NativeFormat.Elf:
await ParseElfAsync(binaryStream, builder, options, cancellationToken);
break;
case NativeFormat.Pe:
await ParsePeAsync(binaryStream, builder, options, cancellationToken);
break;
case NativeFormat.MachO:
await ParseMachOAsync(binaryStream, builder, options, cancellationToken);
break;
}
// Step 3: Heuristic scanning
if (options.EnableHeuristicScanning)
{
binaryStream.Position = 0;
var heuristics = HeuristicScanner.Scan(binaryStream, identity.Format);
builder.AddHeuristicResults(heuristics);
_logger.LogDebug("Found {EdgeCount} heuristic edges in {Path}",
heuristics.Edges.Count, binaryPath);
}
// Step 4: Set default search paths
if (options.DefaultSearchPaths is { Count: > 0 })
{
builder.WithDefaultSearchPaths(options.DefaultSearchPaths);
}
return builder.Build();
}
private Task ParseElfAsync(
Stream stream,
NativeObservationBuilder builder,
NativeAnalyzerOptions options,
CancellationToken cancellationToken)
{
if (!ElfDynamicSectionParser.TryParse(stream, out var elfInfo) || elfInfo == null)
{
_logger.LogWarning("Failed to parse ELF dynamic section");
return Task.CompletedTask;
}
builder.AddElfDependencies(elfInfo);
// Resolve dependencies if VFS is available
if (options.EnableResolution && options.VirtualFileSystem != null)
{
foreach (var dep in elfInfo.Dependencies)
{
cancellationToken.ThrowIfCancellationRequested();
var result = ElfResolver.Resolve(
dep.Soname,
elfInfo.Rpath,
elfInfo.Runpath,
options.LibraryPath,
options.BinaryDirectory,
options.VirtualFileSystem);
builder.AddResolution(result);
}
}
return Task.CompletedTask;
}
private Task ParsePeAsync(
Stream stream,
NativeObservationBuilder builder,
NativeAnalyzerOptions options,
CancellationToken cancellationToken)
{
if (!PeImportParser.TryParse(stream, out var peInfo) || peInfo == null)
{
_logger.LogWarning("Failed to parse PE import table");
return Task.CompletedTask;
}
builder.AddPeDependencies(peInfo);
// Resolve dependencies if VFS is available
if (options.EnableResolution && options.VirtualFileSystem != null)
{
var allDeps = peInfo.Dependencies
.Concat(peInfo.DelayLoadDependencies)
.Select(d => d.DllName)
.Distinct();
foreach (var dllName in allDeps)
{
cancellationToken.ThrowIfCancellationRequested();
var result = PeResolver.Resolve(
dllName,
options.ApplicationDirectory,
currentDirectory: null,
pathEnvironment: null,
options.VirtualFileSystem);
builder.AddResolution(result);
}
}
return Task.CompletedTask;
}
private Task ParseMachOAsync(
Stream stream,
NativeObservationBuilder builder,
NativeAnalyzerOptions options,
CancellationToken cancellationToken)
{
if (!MachOLoadCommandParser.TryParse(stream, out var machoInfo) || machoInfo == null)
{
_logger.LogWarning("Failed to parse Mach-O load commands");
return Task.CompletedTask;
}
builder.AddMachODependencies(machoInfo);
// Resolve dependencies if VFS is available
if (options.EnableResolution && options.VirtualFileSystem != null)
{
// Collect all rpaths from all slices
var allRpaths = machoInfo.Slices.SelectMany(s => s.Rpaths).Distinct().ToList();
// Get unique dependencies across slices
var allDeps = machoInfo.Slices
.SelectMany(s => s.Dependencies)
.Select(d => d.Path)
.Distinct();
foreach (var dylibPath in allDeps)
{
cancellationToken.ThrowIfCancellationRequested();
var result = MachOResolver.Resolve(
dylibPath,
allRpaths,
options.ExecutablePath,
options.LoaderPath,
options.VirtualFileSystem);
builder.AddResolution(result);
}
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,45 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.Analyzers.Native.Plugin;
/// <summary>
/// Plugin implementation for the native binary analyzer.
/// Discovered via reflection when loading analyzer plugins.
/// </summary>
public sealed class NativeAnalyzerPlugin : INativeAnalyzerPlugin
{
/// <inheritdoc />
public string Name => "Native Binary Analyzer";
/// <inheritdoc />
public string Description => "Analyzes ELF, PE/COFF, and Mach-O binaries to extract dependency information, " +
"including declared imports, heuristic dlopen/LoadLibrary detection, and " +
"loader resolution simulation.";
/// <inheritdoc />
public string Version => "1.0.0";
/// <inheritdoc />
public IReadOnlyList<NativeFormat> SupportedFormats =>
[
NativeFormat.Elf,
NativeFormat.Pe,
NativeFormat.MachO
];
/// <inheritdoc />
public bool IsAvailable(IServiceProvider services)
{
// The native analyzer is always available - it has no external dependencies
// that would make it unavailable on any platform.
return true;
}
/// <inheritdoc />
public INativeAnalyzer CreateAnalyzer(IServiceProvider services)
{
var logger = services.GetRequiredService<ILogger<NativeAnalyzer>>();
return new NativeAnalyzer(logger);
}
}

View File

@@ -0,0 +1,219 @@
using System.Reflection;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.Analyzers.Native.Plugin;
/// <summary>
/// Interface for the native analyzer plugin catalog.
/// </summary>
public interface INativeAnalyzerPluginCatalog
{
/// <summary>
/// Gets all registered plugins.
/// </summary>
IReadOnlyList<INativeAnalyzerPlugin> Plugins { get; }
/// <summary>
/// Loads plugins from the specified directory.
/// </summary>
/// <param name="directory">Directory containing plugin assemblies.</param>
/// <param name="seal">Whether to seal the catalog after loading (prevents further modifications).</param>
void LoadFromDirectory(string directory, bool seal = true);
/// <summary>
/// Registers a plugin directly.
/// </summary>
/// <param name="plugin">Plugin to register.</param>
void Register(INativeAnalyzerPlugin plugin);
/// <summary>
/// Creates analyzer instances for all available plugins.
/// </summary>
/// <param name="services">Service provider for dependency injection.</param>
/// <returns>List of analyzers from available plugins.</returns>
IReadOnlyList<INativeAnalyzer> CreateAnalyzers(IServiceProvider services);
/// <summary>
/// Seals the catalog, preventing further modifications.
/// </summary>
void Seal();
}
/// <summary>
/// Catalog for discovering and managing native analyzer plugins.
/// </summary>
public sealed class NativeAnalyzerPluginCatalog : INativeAnalyzerPluginCatalog
{
private readonly ILogger<NativeAnalyzerPluginCatalog> _logger;
private readonly List<INativeAnalyzerPlugin> _plugins = [];
private readonly HashSet<string> _loadedAssemblies = [];
private readonly object _lock = new();
private bool _sealed;
private const string PluginSearchPattern = "StellaOps.Scanner.Analyzers.Native*.dll";
public NativeAnalyzerPluginCatalog(ILogger<NativeAnalyzerPluginCatalog> logger)
{
_logger = logger;
// Register the built-in plugin by default
Register(new NativeAnalyzerPlugin());
}
/// <inheritdoc />
public IReadOnlyList<INativeAnalyzerPlugin> Plugins
{
get
{
lock (_lock)
{
return _plugins.ToList();
}
}
}
/// <inheritdoc />
public void LoadFromDirectory(string directory, bool seal = true)
{
EnsureNotSealed();
if (!Directory.Exists(directory))
{
_logger.LogDebug("Plugin directory does not exist: {Directory}", directory);
return;
}
_logger.LogInformation("Loading native analyzer plugins from {Directory}", directory);
var pluginFiles = Directory.GetFiles(directory, PluginSearchPattern);
foreach (var pluginFile in pluginFiles)
{
try
{
LoadPluginAssembly(pluginFile);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load plugin from {PluginFile}", pluginFile);
}
}
if (seal)
{
Seal();
}
}
/// <inheritdoc />
public void Register(INativeAnalyzerPlugin plugin)
{
EnsureNotSealed();
lock (_lock)
{
// Avoid duplicate registrations
if (_plugins.Any(p => p.Name == plugin.Name))
{
_logger.LogDebug("Plugin {PluginName} is already registered", plugin.Name);
return;
}
_plugins.Add(plugin);
_logger.LogDebug("Registered native analyzer plugin: {PluginName}", plugin.Name);
}
}
/// <inheritdoc />
public IReadOnlyList<INativeAnalyzer> CreateAnalyzers(IServiceProvider services)
{
var analyzers = new List<INativeAnalyzer>();
lock (_lock)
{
foreach (var plugin in _plugins)
{
try
{
if (!plugin.IsAvailable(services))
{
_logger.LogDebug("Plugin {PluginName} is not available", plugin.Name);
continue;
}
var analyzer = plugin.CreateAnalyzer(services);
analyzers.Add(analyzer);
_logger.LogDebug("Created analyzer from plugin {PluginName}", plugin.Name);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create analyzer from plugin {PluginName}", plugin.Name);
}
}
}
return analyzers;
}
/// <inheritdoc />
public void Seal()
{
lock (_lock)
{
if (_sealed)
return;
_sealed = true;
_logger.LogInformation("Native analyzer plugin catalog sealed with {PluginCount} plugins",
_plugins.Count);
}
}
private void EnsureNotSealed()
{
if (_sealed)
{
throw new InvalidOperationException("Plugin catalog has been sealed and cannot be modified.");
}
}
private void LoadPluginAssembly(string assemblyPath)
{
var assemblyName = Path.GetFileName(assemblyPath);
lock (_lock)
{
if (_loadedAssemblies.Contains(assemblyName))
{
_logger.LogDebug("Assembly {AssemblyName} is already loaded", assemblyName);
return;
}
_loadedAssemblies.Add(assemblyName);
}
_logger.LogDebug("Loading plugin assembly: {AssemblyPath}", assemblyPath);
var assembly = Assembly.LoadFrom(assemblyPath);
var pluginTypes = assembly.GetTypes()
.Where(t => typeof(INativeAnalyzerPlugin).IsAssignableFrom(t) &&
!t.IsInterface &&
!t.IsAbstract);
foreach (var pluginType in pluginTypes)
{
try
{
if (Activator.CreateInstance(pluginType) is INativeAnalyzerPlugin plugin)
{
Register(plugin);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to instantiate plugin type {PluginType}", pluginType.FullName);
}
}
}
}

View File

@@ -0,0 +1,195 @@
namespace StellaOps.Scanner.Analyzers.Native.RuntimeCapture;
/// <summary>
/// State of a runtime capture session.
/// </summary>
public enum CaptureState
{
/// <summary>Adapter initialized but not started.</summary>
Idle,
/// <summary>Capture is starting up.</summary>
Starting,
/// <summary>Actively capturing events.</summary>
Running,
/// <summary>Capture is stopping.</summary>
Stopping,
/// <summary>Capture has stopped normally.</summary>
Stopped,
/// <summary>Capture failed or was terminated.</summary>
Faulted,
}
/// <summary>
/// Event args for runtime load events as they occur.
/// </summary>
public sealed class RuntimeLoadEventArgs : EventArgs
{
/// <summary>
/// The captured load event.
/// </summary>
public required RuntimeLoadEvent Event { get; init; }
}
/// <summary>
/// Event args for capture state changes.
/// </summary>
public sealed class CaptureStateChangedEventArgs : EventArgs
{
/// <summary>
/// Previous state.
/// </summary>
public required CaptureState PreviousState { get; init; }
/// <summary>
/// New state.
/// </summary>
public required CaptureState NewState { get; init; }
/// <summary>
/// Error that caused the state change, if any.
/// </summary>
public Exception? Error { get; init; }
}
/// <summary>
/// Result of checking adapter availability.
/// </summary>
/// <param name="IsAvailable">Whether the adapter can be used.</param>
/// <param name="Reason">Explanation if not available.</param>
/// <param name="RequiresElevation">Whether elevated privileges are required.</param>
/// <param name="MissingDependencies">Any missing system dependencies.</param>
public sealed record AdapterAvailability(
bool IsAvailable,
string? Reason,
bool RequiresElevation,
IReadOnlyList<string> MissingDependencies);
/// <summary>
/// Interface for platform-specific runtime capture adapters.
/// </summary>
public interface IRuntimeCaptureAdapter : IAsyncDisposable
{
/// <summary>
/// Unique identifier for this capture adapter type.
/// </summary>
string AdapterId { get; }
/// <summary>
/// Human-readable name for this adapter.
/// </summary>
string DisplayName { get; }
/// <summary>
/// Platform this adapter supports (linux, windows, macos).
/// </summary>
string Platform { get; }
/// <summary>
/// Capture method used by this adapter (ebpf, etw, dyld-interpose).
/// </summary>
string CaptureMethod { get; }
/// <summary>
/// Current state of the capture session.
/// </summary>
CaptureState State { get; }
/// <summary>
/// Active session ID, null if not capturing.
/// </summary>
string? SessionId { get; }
/// <summary>
/// Event raised when a library load is captured.
/// </summary>
event EventHandler<RuntimeLoadEventArgs>? LoadEventCaptured;
/// <summary>
/// Event raised when capture state changes.
/// </summary>
event EventHandler<CaptureStateChangedEventArgs>? StateChanged;
/// <summary>
/// Checks whether this adapter is available on the current system.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Availability information.</returns>
Task<AdapterAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Starts runtime capture with the specified options.
/// </summary>
/// <param name="options">Capture configuration.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Session ID for this capture.</returns>
Task<string> StartCaptureAsync(RuntimeCaptureOptions options, CancellationToken cancellationToken = default);
/// <summary>
/// Stops the current capture session.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Completed capture session.</returns>
Task<RuntimeCaptureSession> StopCaptureAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets current capture statistics without stopping.
/// </summary>
/// <returns>Current event count and buffer status.</returns>
(int EventCount, int BufferUsed, int BufferCapacity) GetStatistics();
/// <summary>
/// Gets all events captured so far without stopping.
/// Note: Events may continue to arrive after this call.
/// </summary>
/// <returns>Events captured so far.</returns>
IReadOnlyList<RuntimeLoadEvent> GetCurrentEvents();
}
/// <summary>
/// Factory for creating platform-appropriate runtime capture adapters.
/// </summary>
public static class RuntimeCaptureAdapterFactory
{
/// <summary>
/// Creates the appropriate capture adapter for the current platform.
/// </summary>
/// <returns>Platform-specific adapter or null if no adapter available.</returns>
public static IRuntimeCaptureAdapter? CreateForCurrentPlatform()
{
if (OperatingSystem.IsLinux())
return new LinuxEbpfCaptureAdapter();
if (OperatingSystem.IsWindows())
return new WindowsEtwCaptureAdapter();
if (OperatingSystem.IsMacOS())
return new MacOsDyldCaptureAdapter();
return null;
}
/// <summary>
/// Gets all available adapters for the current platform.
/// </summary>
/// <returns>List of available adapters.</returns>
public static IReadOnlyList<IRuntimeCaptureAdapter> GetAvailableAdapters()
{
var adapters = new List<IRuntimeCaptureAdapter>();
if (OperatingSystem.IsLinux())
adapters.Add(new LinuxEbpfCaptureAdapter());
if (OperatingSystem.IsWindows())
adapters.Add(new WindowsEtwCaptureAdapter());
if (OperatingSystem.IsMacOS())
adapters.Add(new MacOsDyldCaptureAdapter());
return adapters;
}
}

View File

@@ -0,0 +1,598 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.Native.RuntimeCapture;
/// <summary>
/// Linux runtime capture adapter using eBPF to trace dlopen calls.
/// </summary>
/// <remarks>
/// This adapter attaches eBPF probes to the dlopen/dlmopen functions in libc
/// to capture dynamic library loading events. Requires:
/// - Linux kernel 4.4+ (for eBPF)
/// - CAP_SYS_ADMIN or CAP_BPF capability
/// - libbpf or bpftrace available
///
/// In sandbox mode, uses mock events instead of actual eBPF tracing.
/// </remarks>
[SupportedOSPlatform("linux")]
public sealed class LinuxEbpfCaptureAdapter : IRuntimeCaptureAdapter
{
private readonly ConcurrentBag<RuntimeLoadEvent> _events = [];
private readonly object _stateLock = new();
private CaptureState _state = CaptureState.Idle;
private RuntimeCaptureOptions? _options;
private DateTime _startTime;
private CancellationTokenSource? _captureCts;
private Task? _captureTask;
private Process? _bpftraceProcess;
private long _droppedEvents;
private int _redactedPaths;
/// <inheritdoc />
public string AdapterId => "linux-ebpf-dlopen";
/// <inheritdoc />
public string DisplayName => "Linux eBPF dlopen Tracer";
/// <inheritdoc />
public string Platform => "linux";
/// <inheritdoc />
public string CaptureMethod => "ebpf";
/// <inheritdoc />
public CaptureState State
{
get { lock (_stateLock) return _state; }
}
/// <inheritdoc />
public string? SessionId { get; private set; }
/// <inheritdoc />
public event EventHandler<RuntimeLoadEventArgs>? LoadEventCaptured;
/// <inheritdoc />
public event EventHandler<CaptureStateChangedEventArgs>? StateChanged;
/// <inheritdoc />
public async Task<AdapterAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
{
if (!OperatingSystem.IsLinux())
{
return new AdapterAvailability(
IsAvailable: false,
Reason: "This adapter only works on Linux.",
RequiresElevation: false,
MissingDependencies: []);
}
var missingDeps = new List<string>();
var requiresElevation = false;
// Check for bpftrace
var hasBpftrace = await CheckCommandExistsAsync("bpftrace", cancellationToken);
if (!hasBpftrace)
missingDeps.Add("bpftrace");
// Check kernel version (need 4.4+)
var kernelVersion = GetKernelVersion();
if (kernelVersion < new Version(4, 4))
{
return new AdapterAvailability(
IsAvailable: false,
Reason: $"Kernel version {kernelVersion} is too old. eBPF requires kernel 4.4+.",
RequiresElevation: false,
MissingDependencies: missingDeps);
}
// Check for CAP_SYS_ADMIN or root
if (!IsRunningAsRoot() && !HasCapability("cap_sys_admin") && !HasCapability("cap_bpf"))
{
requiresElevation = true;
}
// Check if eBPF is available
if (!File.Exists("/sys/kernel/debug/tracing/available_filter_functions") &&
!File.Exists("/sys/kernel/tracing/available_filter_functions"))
{
return new AdapterAvailability(
IsAvailable: false,
Reason: "eBPF tracing is not available. Ensure debugfs is mounted and kernel supports eBPF.",
RequiresElevation: requiresElevation,
MissingDependencies: missingDeps);
}
if (missingDeps.Count > 0)
{
return new AdapterAvailability(
IsAvailable: false,
Reason: $"Missing dependencies: {string.Join(", ", missingDeps)}",
RequiresElevation: requiresElevation,
MissingDependencies: missingDeps);
}
if (requiresElevation)
{
return new AdapterAvailability(
IsAvailable: true,
Reason: "eBPF tracing requires CAP_SYS_ADMIN, CAP_BPF, or root privileges.",
RequiresElevation: true,
MissingDependencies: []);
}
return new AdapterAvailability(
IsAvailable: true,
Reason: null,
RequiresElevation: false,
MissingDependencies: []);
}
/// <inheritdoc />
public async Task<string> StartCaptureAsync(RuntimeCaptureOptions options, CancellationToken cancellationToken = default)
{
var validationErrors = options.Validate();
if (validationErrors.Count > 0)
throw new ArgumentException($"Invalid options: {string.Join("; ", validationErrors)}");
lock (_stateLock)
{
if (_state != CaptureState.Idle && _state != CaptureState.Stopped && _state != CaptureState.Faulted)
throw new InvalidOperationException($"Cannot start capture in state {_state}.");
SetState(CaptureState.Starting);
}
_options = options;
_events.Clear();
_droppedEvents = 0;
_redactedPaths = 0;
SessionId = Guid.NewGuid().ToString("N");
_startTime = DateTime.UtcNow;
_captureCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
try
{
if (options.Sandbox.Enabled && !options.Sandbox.AllowSystemTracing)
{
// Sandbox mode - use mock events
_captureTask = RunSandboxCaptureAsync(_captureCts.Token);
}
else
{
// Real eBPF capture
_captureTask = RunEbpfCaptureAsync(_captureCts.Token);
}
// Start the duration timer
_ = Task.Run(async () =>
{
try
{
await Task.Delay(options.MaxCaptureDuration, _captureCts.Token);
await StopCaptureAsync(CancellationToken.None);
}
catch (OperationCanceledException)
{
// Expected when capture is stopped manually
}
}, _captureCts.Token);
SetState(CaptureState.Running);
return SessionId;
}
catch (Exception ex)
{
SetState(CaptureState.Faulted, ex);
throw;
}
}
/// <inheritdoc />
public async Task<RuntimeCaptureSession> StopCaptureAsync(CancellationToken cancellationToken = default)
{
lock (_stateLock)
{
if (_state != CaptureState.Running)
throw new InvalidOperationException($"Cannot stop capture in state {_state}.");
SetState(CaptureState.Stopping);
}
try
{
// Cancel capture
_captureCts?.Cancel();
// Kill bpftrace process if running
if (_bpftraceProcess is { HasExited: false })
{
try
{
_bpftraceProcess.Kill(true);
await _bpftraceProcess.WaitForExitAsync(cancellationToken);
}
catch
{
// Ignore kill errors
}
}
// Wait for capture task
if (_captureTask != null)
{
try
{
await _captureTask.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken);
}
catch (TimeoutException)
{
// Task didn't complete in time
}
catch (OperationCanceledException)
{
// Expected
}
}
var session = new RuntimeCaptureSession(
SessionId: SessionId ?? "unknown",
StartTime: _startTime,
EndTime: DateTime.UtcNow,
Platform: Platform,
CaptureMethod: CaptureMethod,
TargetProcessId: _options?.TargetProcessId,
Events: [.. _events],
TotalEventsDropped: _droppedEvents,
RedactedPaths: _redactedPaths);
SetState(CaptureState.Stopped);
return session;
}
catch (Exception ex)
{
SetState(CaptureState.Faulted, ex);
throw;
}
}
/// <inheritdoc />
public (int EventCount, int BufferUsed, int BufferCapacity) GetStatistics()
{
var count = _events.Count;
return (count, count, _options?.BufferSize ?? 1000);
}
/// <inheritdoc />
public IReadOnlyList<RuntimeLoadEvent> GetCurrentEvents() => [.. _events];
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_state == CaptureState.Running)
{
try
{
await StopCaptureAsync();
}
catch
{
// Ignore errors during dispose
}
}
_captureCts?.Dispose();
_bpftraceProcess?.Dispose();
}
private async Task RunSandboxCaptureAsync(CancellationToken cancellationToken)
{
// In sandbox mode, inject mock events
if (_options?.Sandbox.MockEvents is { Count: > 0 } mockEvents)
{
foreach (var evt in mockEvents)
{
cancellationToken.ThrowIfCancellationRequested();
ProcessEvent(evt);
await Task.Delay(10, cancellationToken); // Simulate event timing
}
}
// Wait until cancelled
try
{
await Task.Delay(Timeout.Infinite, cancellationToken);
}
catch (OperationCanceledException)
{
// Expected
}
}
private async Task RunEbpfCaptureAsync(CancellationToken cancellationToken)
{
// Build bpftrace script for dlopen tracing
var script = BuildBpftraceScript();
var psi = new ProcessStartInfo
{
FileName = "bpftrace",
Arguments = $"-e '{script}'",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
if (_options?.TargetProcessId != null)
{
psi.Arguments = $"-p {_options.TargetProcessId} -e '{script}'";
}
_bpftraceProcess = new Process { StartInfo = psi };
_bpftraceProcess.OutputDataReceived += OnBpftraceOutput;
_bpftraceProcess.ErrorDataReceived += OnBpftraceError;
try
{
_bpftraceProcess.Start();
_bpftraceProcess.BeginOutputReadLine();
_bpftraceProcess.BeginErrorReadLine();
await _bpftraceProcess.WaitForExitAsync(cancellationToken);
}
catch (OperationCanceledException)
{
// Expected when stopping
}
}
private static string BuildBpftraceScript()
{
// This script traces dlopen calls and outputs structured data
return """
uprobe:/lib*/libc.so*:dlopen,
uprobe:/lib*/libc.so*:dlmopen
{
printf("DLOPEN|%d|%d|%s|%llu\n", pid, tid, str(arg0), nsecs);
}
uretprobe:/lib*/libc.so*:dlopen,
uretprobe:/lib*/libc.so*:dlmopen
{
printf("DLOPEN_RET|%d|%d|%llu|%d\n", pid, tid, retval, retval == 0 ? errno : 0);
}
""";
}
private void OnBpftraceOutput(object sender, DataReceivedEventArgs e)
{
if (string.IsNullOrEmpty(e.Data))
return;
try
{
var evt = ParseBpftraceOutput(e.Data);
if (evt != null)
ProcessEvent(evt);
}
catch
{
Interlocked.Increment(ref _droppedEvents);
}
}
private void OnBpftraceError(object sender, DataReceivedEventArgs e)
{
// Log errors but don't fail capture
if (!string.IsNullOrEmpty(e.Data))
{
Debug.WriteLine($"bpftrace error: {e.Data}");
}
}
private RuntimeLoadEvent? ParseBpftraceOutput(string line)
{
var parts = line.Split('|');
if (parts.Length < 4)
return null;
if (parts[0] == "DLOPEN" && parts.Length >= 5)
{
return new RuntimeLoadEvent(
Timestamp: DateTime.UtcNow,
ProcessId: int.Parse(parts[1]),
ThreadId: int.Parse(parts[2]),
LoadType: RuntimeLoadType.Dlopen,
RequestedPath: parts[3],
ResolvedPath: null, // Set on return probe
LoadAddress: null,
Success: true, // Updated on return probe
ErrorCode: null,
CallerModule: null,
CallerAddress: null);
}
return null;
}
private void ProcessEvent(RuntimeLoadEvent evt)
{
// Apply include/exclude filters
if (_options != null)
{
if (_options.IncludePatterns.Count > 0)
{
var matched = _options.IncludePatterns.Any(p =>
MatchGlob(evt.RequestedPath, p) ||
(evt.ResolvedPath != null && MatchGlob(evt.ResolvedPath, p)));
if (!matched)
return;
}
if (_options.ExcludePatterns.Any(p =>
MatchGlob(evt.RequestedPath, p) ||
(evt.ResolvedPath != null && MatchGlob(evt.ResolvedPath, p))))
{
return;
}
// Apply redaction
if (_options.Redaction.Enabled)
{
var requestedRedacted = _options.Redaction.ApplyRedaction(evt.RequestedPath, out var wasRedacted1);
var resolvedRedacted = evt.ResolvedPath != null
? _options.Redaction.ApplyRedaction(evt.ResolvedPath, out var wasRedacted2)
: null;
if (wasRedacted1 || (evt.ResolvedPath != null && _options.Redaction.ApplyRedaction(evt.ResolvedPath, out _) != evt.ResolvedPath))
{
Interlocked.Increment(ref _redactedPaths);
evt = evt with
{
RequestedPath = requestedRedacted,
ResolvedPath = resolvedRedacted
};
}
}
// Check failures filter
if (!_options.CaptureFailures && !evt.Success)
return;
}
// Check buffer capacity
if (_options != null && _events.Count >= _options.BufferSize)
{
Interlocked.Increment(ref _droppedEvents);
return;
}
_events.Add(evt);
LoadEventCaptured?.Invoke(this, new RuntimeLoadEventArgs { Event = evt });
}
private void SetState(CaptureState newState, Exception? error = null)
{
CaptureState previous;
lock (_stateLock)
{
previous = _state;
_state = newState;
}
StateChanged?.Invoke(this, new CaptureStateChangedEventArgs
{
PreviousState = previous,
NewState = newState,
Error = error
});
}
private static async Task<bool> CheckCommandExistsAsync(string command, CancellationToken cancellationToken)
{
try
{
var psi = new ProcessStartInfo
{
FileName = "which",
Arguments = command,
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true,
};
using var process = Process.Start(psi);
if (process == null)
return false;
await process.WaitForExitAsync(cancellationToken);
return process.ExitCode == 0;
}
catch
{
return false;
}
}
private static Version GetKernelVersion()
{
try
{
if (File.Exists("/proc/version"))
{
var version = File.ReadAllText("/proc/version");
var match = Regex.Match(version, @"Linux version (\d+)\.(\d+)");
if (match.Success)
{
return new Version(
int.Parse(match.Groups[1].Value),
int.Parse(match.Groups[2].Value));
}
}
}
catch
{
// Ignore errors
}
return new Version(0, 0);
}
private static bool IsRunningAsRoot()
{
try
{
return geteuid() == 0;
}
catch
{
return false;
}
}
[DllImport("libc", SetLastError = true)]
private static extern uint geteuid();
private static bool HasCapability(string capability)
{
try
{
var psi = new ProcessStartInfo
{
FileName = "capsh",
Arguments = $"--print",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true,
};
using var process = Process.Start(psi);
if (process == null)
return false;
var output = process.StandardOutput.ReadToEnd();
process.WaitForExit();
return output.Contains(capability, StringComparison.OrdinalIgnoreCase);
}
catch
{
return false;
}
}
private static bool MatchGlob(string path, string pattern)
{
var regexPattern = "^" + Regex.Escape(pattern)
.Replace(@"\*\*", ".*")
.Replace(@"\*", "[^/]*")
.Replace(@"\?", ".") + "$";
return Regex.IsMatch(path, regexPattern, RegexOptions.IgnoreCase);
}
}

View File

@@ -0,0 +1,599 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.Native.RuntimeCapture;
/// <summary>
/// macOS runtime capture adapter using dyld interposition or dtrace to trace dylib loads.
/// </summary>
/// <remarks>
/// This adapter can use:
/// - DYLD_INSERT_LIBRARIES for interposition (per-process, no root)
/// - dtrace for system-wide tracing (requires root/SIP disabled)
///
/// Requires:
/// - macOS 10.5+ for DYLD_INSERT_LIBRARIES
/// - SIP disabled or dtrace entitlement for dtrace
///
/// In sandbox mode, uses mock events instead of actual tracing.
/// </remarks>
[SupportedOSPlatform("macos")]
public sealed class MacOsDyldCaptureAdapter : IRuntimeCaptureAdapter
{
private readonly ConcurrentBag<RuntimeLoadEvent> _events = [];
private readonly object _stateLock = new();
private CaptureState _state = CaptureState.Idle;
private RuntimeCaptureOptions? _options;
private DateTime _startTime;
private CancellationTokenSource? _captureCts;
private Task? _captureTask;
private Process? _dtraceProcess;
private long _droppedEvents;
private int _redactedPaths;
/// <inheritdoc />
public string AdapterId => "macos-dyld-interpose";
/// <inheritdoc />
public string DisplayName => "macOS dyld Interpose Tracer";
/// <inheritdoc />
public string Platform => "macos";
/// <inheritdoc />
public string CaptureMethod => "dyld-interpose";
/// <inheritdoc />
public CaptureState State
{
get { lock (_stateLock) return _state; }
}
/// <inheritdoc />
public string? SessionId { get; private set; }
/// <inheritdoc />
public event EventHandler<RuntimeLoadEventArgs>? LoadEventCaptured;
/// <inheritdoc />
public event EventHandler<CaptureStateChangedEventArgs>? StateChanged;
/// <inheritdoc />
public async Task<AdapterAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
{
if (!OperatingSystem.IsMacOS())
{
return new AdapterAvailability(
IsAvailable: false,
Reason: "This adapter only works on macOS.",
RequiresElevation: false,
MissingDependencies: []);
}
var missingDeps = new List<string>();
var requiresElevation = false;
// Check macOS version (dyld interposition available since 10.5)
var version = Environment.OSVersion.Version;
if (version < new Version(10, 5))
{
return new AdapterAvailability(
IsAvailable: false,
Reason: $"macOS version {version} is too old. dyld interposition requires 10.5+.",
RequiresElevation: false,
MissingDependencies: missingDeps);
}
// Check if dtrace is available (for system-wide tracing)
var hasDtrace = await CheckCommandExistsAsync("dtrace", cancellationToken);
if (!hasDtrace)
{
missingDeps.Add("dtrace");
}
// Check SIP status for system-wide dtrace
var sipStatus = await GetSipStatusAsync(cancellationToken);
if (sipStatus == SipStatus.Enabled)
{
// dtrace is restricted, need root for limited functionality
requiresElevation = true;
}
// Check for root for dtrace
if (!IsRunningAsRoot())
{
requiresElevation = true;
}
if (missingDeps.Count > 0)
{
return new AdapterAvailability(
IsAvailable: false,
Reason: $"Missing dependencies: {string.Join(", ", missingDeps)}",
RequiresElevation: requiresElevation,
MissingDependencies: missingDeps);
}
if (requiresElevation)
{
var reason = sipStatus == SipStatus.Enabled
? "dtrace requires root privileges. Full functionality requires SIP to be disabled."
: "dtrace requires root privileges.";
return new AdapterAvailability(
IsAvailable: true,
Reason: reason,
RequiresElevation: true,
MissingDependencies: []);
}
return new AdapterAvailability(
IsAvailable: true,
Reason: null,
RequiresElevation: false,
MissingDependencies: []);
}
/// <inheritdoc />
public async Task<string> StartCaptureAsync(RuntimeCaptureOptions options, CancellationToken cancellationToken = default)
{
var validationErrors = options.Validate();
if (validationErrors.Count > 0)
throw new ArgumentException($"Invalid options: {string.Join("; ", validationErrors)}");
lock (_stateLock)
{
if (_state != CaptureState.Idle && _state != CaptureState.Stopped && _state != CaptureState.Faulted)
throw new InvalidOperationException($"Cannot start capture in state {_state}.");
SetState(CaptureState.Starting);
}
_options = options;
_events.Clear();
_droppedEvents = 0;
_redactedPaths = 0;
SessionId = Guid.NewGuid().ToString("N");
_startTime = DateTime.UtcNow;
_captureCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
try
{
if (options.Sandbox.Enabled && !options.Sandbox.AllowSystemTracing)
{
// Sandbox mode - use mock events
_captureTask = RunSandboxCaptureAsync(_captureCts.Token);
}
else
{
// Real dtrace capture
_captureTask = RunDtraceCaptureAsync(_captureCts.Token);
}
// Start the duration timer
_ = Task.Run(async () =>
{
try
{
await Task.Delay(options.MaxCaptureDuration, _captureCts.Token);
await StopCaptureAsync(CancellationToken.None);
}
catch (OperationCanceledException)
{
// Expected when capture is stopped manually
}
}, _captureCts.Token);
SetState(CaptureState.Running);
return SessionId;
}
catch (Exception ex)
{
SetState(CaptureState.Faulted, ex);
throw;
}
}
/// <inheritdoc />
public async Task<RuntimeCaptureSession> StopCaptureAsync(CancellationToken cancellationToken = default)
{
lock (_stateLock)
{
if (_state != CaptureState.Running)
throw new InvalidOperationException($"Cannot stop capture in state {_state}.");
SetState(CaptureState.Stopping);
}
try
{
// Cancel capture
_captureCts?.Cancel();
// Kill dtrace process if running
if (_dtraceProcess is { HasExited: false })
{
try
{
_dtraceProcess.Kill(true);
await _dtraceProcess.WaitForExitAsync(cancellationToken);
}
catch
{
// Ignore kill errors
}
}
// Wait for capture task
if (_captureTask != null)
{
try
{
await _captureTask.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken);
}
catch (TimeoutException)
{
// Task didn't complete in time
}
catch (OperationCanceledException)
{
// Expected
}
}
var session = new RuntimeCaptureSession(
SessionId: SessionId ?? "unknown",
StartTime: _startTime,
EndTime: DateTime.UtcNow,
Platform: Platform,
CaptureMethod: CaptureMethod,
TargetProcessId: _options?.TargetProcessId,
Events: [.. _events],
TotalEventsDropped: _droppedEvents,
RedactedPaths: _redactedPaths);
SetState(CaptureState.Stopped);
return session;
}
catch (Exception ex)
{
SetState(CaptureState.Faulted, ex);
throw;
}
}
/// <inheritdoc />
public (int EventCount, int BufferUsed, int BufferCapacity) GetStatistics()
{
var count = _events.Count;
return (count, count, _options?.BufferSize ?? 1000);
}
/// <inheritdoc />
public IReadOnlyList<RuntimeLoadEvent> GetCurrentEvents() => [.. _events];
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_state == CaptureState.Running)
{
try
{
await StopCaptureAsync();
}
catch
{
// Ignore errors during dispose
}
}
_captureCts?.Dispose();
_dtraceProcess?.Dispose();
}
private async Task RunSandboxCaptureAsync(CancellationToken cancellationToken)
{
// In sandbox mode, inject mock events
if (_options?.Sandbox.MockEvents is { Count: > 0 } mockEvents)
{
foreach (var evt in mockEvents)
{
cancellationToken.ThrowIfCancellationRequested();
ProcessEvent(evt);
await Task.Delay(10, cancellationToken); // Simulate event timing
}
}
// Wait until cancelled
try
{
await Task.Delay(Timeout.Infinite, cancellationToken);
}
catch (OperationCanceledException)
{
// Expected
}
}
private async Task RunDtraceCaptureAsync(CancellationToken cancellationToken)
{
// Build dtrace script for dyld tracing
var script = BuildDtraceScript();
var psi = new ProcessStartInfo
{
FileName = "dtrace",
Arguments = $"-n '{script}'",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
if (_options?.TargetProcessId != null)
{
psi.Arguments = $"-p {_options.TargetProcessId} -n '{script}'";
}
_dtraceProcess = new Process { StartInfo = psi };
_dtraceProcess.OutputDataReceived += OnDtraceOutput;
_dtraceProcess.ErrorDataReceived += OnDtraceError;
try
{
_dtraceProcess.Start();
_dtraceProcess.BeginOutputReadLine();
_dtraceProcess.BeginErrorReadLine();
await _dtraceProcess.WaitForExitAsync(cancellationToken);
}
catch (OperationCanceledException)
{
// Expected when stopping
}
}
private static string BuildDtraceScript()
{
// This script traces dyld image loads
// Using the dyld probes available on macOS
return """
pid$target::dlopen:entry
{
printf("DLOPEN|%d|%d|%s\n", pid, tid, copyinstr(arg0));
}
pid$target::dlopen:return
{
printf("DLOPEN_RET|%d|%d|%p|%d\n", pid, tid, arg1, arg1 == 0 ? errno : 0);
}
pid$target:libdyld.dylib:dlopen:entry
{
printf("DYLIB_DLOPEN|%d|%d|%s\n", pid, tid, copyinstr(arg0));
}
""";
}
private void OnDtraceOutput(object sender, DataReceivedEventArgs e)
{
if (string.IsNullOrEmpty(e.Data))
return;
try
{
var evt = ParseDtraceOutput(e.Data);
if (evt != null)
ProcessEvent(evt);
}
catch
{
Interlocked.Increment(ref _droppedEvents);
}
}
private void OnDtraceError(object sender, DataReceivedEventArgs e)
{
// Log errors but don't fail capture
if (!string.IsNullOrEmpty(e.Data))
{
Debug.WriteLine($"dtrace error: {e.Data}");
}
}
private RuntimeLoadEvent? ParseDtraceOutput(string line)
{
var parts = line.Split('|');
if (parts.Length < 3)
return null;
if ((parts[0] == "DLOPEN" || parts[0] == "DYLIB_DLOPEN") && parts.Length >= 4)
{
var loadType = parts[0] == "DYLIB_DLOPEN"
? RuntimeLoadType.DylibLoad
: RuntimeLoadType.MacOsDlopen;
return new RuntimeLoadEvent(
Timestamp: DateTime.UtcNow,
ProcessId: int.Parse(parts[1]),
ThreadId: int.Parse(parts[2]),
LoadType: loadType,
RequestedPath: parts[3],
ResolvedPath: null, // Set on return probe
LoadAddress: null,
Success: true, // Updated on return probe
ErrorCode: null,
CallerModule: null,
CallerAddress: null);
}
return null;
}
private void ProcessEvent(RuntimeLoadEvent evt)
{
// Apply include/exclude filters
if (_options != null)
{
if (_options.IncludePatterns.Count > 0)
{
var matched = _options.IncludePatterns.Any(p =>
MatchGlob(evt.RequestedPath, p) ||
(evt.ResolvedPath != null && MatchGlob(evt.ResolvedPath, p)));
if (!matched)
return;
}
if (_options.ExcludePatterns.Any(p =>
MatchGlob(evt.RequestedPath, p) ||
(evt.ResolvedPath != null && MatchGlob(evt.ResolvedPath, p))))
{
return;
}
// Apply redaction
if (_options.Redaction.Enabled)
{
var requestedRedacted = _options.Redaction.ApplyRedaction(evt.RequestedPath, out var wasRedacted1);
var resolvedRedacted = evt.ResolvedPath != null
? _options.Redaction.ApplyRedaction(evt.ResolvedPath, out var wasRedacted2)
: null;
if (wasRedacted1 || (evt.ResolvedPath != null && _options.Redaction.ApplyRedaction(evt.ResolvedPath, out _) != evt.ResolvedPath))
{
Interlocked.Increment(ref _redactedPaths);
evt = evt with
{
RequestedPath = requestedRedacted,
ResolvedPath = resolvedRedacted
};
}
}
// Check failures filter
if (!_options.CaptureFailures && !evt.Success)
return;
}
// Check buffer capacity
if (_options != null && _events.Count >= _options.BufferSize)
{
Interlocked.Increment(ref _droppedEvents);
return;
}
_events.Add(evt);
LoadEventCaptured?.Invoke(this, new RuntimeLoadEventArgs { Event = evt });
}
private void SetState(CaptureState newState, Exception? error = null)
{
CaptureState previous;
lock (_stateLock)
{
previous = _state;
_state = newState;
}
StateChanged?.Invoke(this, new CaptureStateChangedEventArgs
{
PreviousState = previous,
NewState = newState,
Error = error
});
}
private static async Task<bool> CheckCommandExistsAsync(string command, CancellationToken cancellationToken)
{
try
{
var psi = new ProcessStartInfo
{
FileName = "which",
Arguments = command,
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true,
};
using var process = Process.Start(psi);
if (process == null)
return false;
await process.WaitForExitAsync(cancellationToken);
return process.ExitCode == 0;
}
catch
{
return false;
}
}
private enum SipStatus
{
Unknown,
Enabled,
Disabled,
}
private static async Task<SipStatus> GetSipStatusAsync(CancellationToken cancellationToken)
{
try
{
var psi = new ProcessStartInfo
{
FileName = "csrutil",
Arguments = "status",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true,
};
using var process = Process.Start(psi);
if (process == null)
return SipStatus.Unknown;
var output = await process.StandardOutput.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken);
if (output.Contains("disabled", StringComparison.OrdinalIgnoreCase))
return SipStatus.Disabled;
if (output.Contains("enabled", StringComparison.OrdinalIgnoreCase))
return SipStatus.Enabled;
return SipStatus.Unknown;
}
catch
{
return SipStatus.Unknown;
}
}
private static bool IsRunningAsRoot()
{
try
{
return geteuid() == 0;
}
catch
{
return false;
}
}
[DllImport("libc", SetLastError = true)]
private static extern uint geteuid();
private static bool MatchGlob(string path, string pattern)
{
var regexPattern = "^" + Regex.Escape(pattern)
.Replace(@"\*\*", ".*")
.Replace(@"\*", "[^/]*")
.Replace(@"\?", ".") + "$";
return Regex.IsMatch(path, regexPattern, RegexOptions.IgnoreCase);
}
}

View File

@@ -0,0 +1,237 @@
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.Native.RuntimeCapture;
/// <summary>
/// Configuration options for runtime capture adapters.
/// </summary>
public sealed class RuntimeCaptureOptions
{
/// <summary>
/// Maximum number of events to buffer before writing to output.
/// Default: 1000.
/// </summary>
public int BufferSize { get; set; } = 1000;
/// <summary>
/// Maximum duration for a capture session. Default: 5 minutes.
/// </summary>
public TimeSpan MaxCaptureDuration { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Whether to capture failed load attempts. Default: true.
/// </summary>
public bool CaptureFailures { get; set; } = true;
/// <summary>
/// Whether to capture caller information (module and address). Default: true.
/// Disabling may improve performance.
/// </summary>
public bool CaptureCallerInfo { get; set; } = true;
/// <summary>
/// Process ID to monitor. Null for system-wide capture.
/// System-wide capture requires elevated privileges.
/// </summary>
public int? TargetProcessId { get; set; }
/// <summary>
/// Path patterns to include. If empty, all paths are included.
/// Supports glob patterns: *, **, ?
/// </summary>
public IList<string> IncludePatterns { get; set; } = [];
/// <summary>
/// Path patterns to exclude. Applied after include patterns.
/// Common use: exclude system libraries that create noise.
/// </summary>
public IList<string> ExcludePatterns { get; set; } = [];
/// <summary>
/// Redaction options for sensitive paths.
/// </summary>
public RedactionOptions Redaction { get; set; } = new();
/// <summary>
/// Sandbox mode restrictions.
/// </summary>
public SandboxOptions Sandbox { get; set; } = new();
/// <summary>
/// Validates options and returns any validation errors.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (BufferSize < 1)
errors.Add("BufferSize must be at least 1.");
if (MaxCaptureDuration <= TimeSpan.Zero)
errors.Add("MaxCaptureDuration must be positive.");
if (MaxCaptureDuration > TimeSpan.FromHours(1))
errors.Add("MaxCaptureDuration cannot exceed 1 hour.");
if (TargetProcessId is < 0)
errors.Add("TargetProcessId must be non-negative.");
foreach (var pattern in IncludePatterns.Concat(ExcludePatterns))
{
if (string.IsNullOrWhiteSpace(pattern))
errors.Add("Path patterns cannot be empty.");
}
errors.AddRange(Redaction.Validate());
errors.AddRange(Sandbox.Validate());
return errors;
}
}
/// <summary>
/// Options for redacting sensitive information from captured paths.
/// </summary>
public sealed class RedactionOptions
{
/// <summary>
/// Whether redaction is enabled. Default: true.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Regex patterns for paths to redact.
/// Default includes common sensitive locations.
/// </summary>
public IList<string> RedactPatterns { get; set; } =
[
@"/home/[^/]+", // Linux home directories
@"/Users/[^/]+", // macOS home directories
@"C:\\Users\\[^\\]+", // Windows user directories
@"/tmp/[^/]+", // Temp files with session info
@"/var/tmp/[^/]+",
@".*[/\\]\.ssh[/\\].*", // SSH keys
@".*[/\\]\.gnupg[/\\].*", // GPG keys
@".*[/\\]\.aws[/\\].*", // AWS credentials
@".*[/\\]secrets?[/\\].*", // Generic secrets directories
@".*[/\\]credentials?[/\\].*",
@".*\.key$", // Key files
@".*\.pem$",
@".*\.p12$",
];
/// <summary>
/// Replacement string for redacted portions. Default: "[REDACTED]".
/// </summary>
public string ReplacementText { get; set; } = "[REDACTED]";
/// <summary>
/// Whether to log (count) redactions. Default: true.
/// </summary>
public bool LogRedactions { get; set; } = true;
/// <summary>
/// Validates redaction options.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
foreach (var pattern in RedactPatterns)
{
try
{
_ = new Regex(pattern, RegexOptions.Compiled);
}
catch (ArgumentException ex)
{
errors.Add($"Invalid redaction regex '{pattern}': {ex.Message}");
}
}
if (string.IsNullOrEmpty(ReplacementText))
errors.Add("ReplacementText cannot be empty when redaction is enabled.");
return errors;
}
/// <summary>
/// Applies redaction rules to a path.
/// </summary>
/// <param name="path">Path to potentially redact.</param>
/// <param name="wasRedacted">Set to true if any redaction was applied.</param>
/// <returns>Original or redacted path.</returns>
public string ApplyRedaction(string path, out bool wasRedacted)
{
wasRedacted = false;
if (!Enabled || string.IsNullOrEmpty(path))
return path;
var result = path;
foreach (var pattern in RedactPatterns)
{
try
{
var regex = new Regex(pattern, RegexOptions.IgnoreCase);
if (regex.IsMatch(result))
{
result = regex.Replace(result, ReplacementText);
wasRedacted = true;
}
}
catch (ArgumentException)
{
// Skip invalid patterns (should be caught in validation)
}
}
return result;
}
}
/// <summary>
/// Sandbox mode restrictions for runtime capture.
/// </summary>
public sealed class SandboxOptions
{
/// <summary>
/// Whether sandbox mode is enabled. Default: false.
/// In sandbox mode, capture is limited to simulated/mock events.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Root directory for sandboxed capture data.
/// When sandbox is enabled, captures are written here instead of
/// requiring system-level privileges.
/// </summary>
public string? SandboxRoot { get; set; }
/// <summary>
/// Whether to allow actual kernel/system tracing even in sandbox mode.
/// Default: false. Set to true only for integration testing on
/// isolated/disposable systems.
/// </summary>
public bool AllowSystemTracing { get; set; }
/// <summary>
/// Mock events to inject in sandbox mode for testing.
/// </summary>
public IList<RuntimeLoadEvent> MockEvents { get; set; } = [];
/// <summary>
/// Validates sandbox options.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (Enabled && string.IsNullOrEmpty(SandboxRoot) && !AllowSystemTracing)
errors.Add("SandboxRoot must be specified when sandbox mode is enabled and AllowSystemTracing is false.");
if (!Enabled && MockEvents.Count > 0)
errors.Add("MockEvents should only be used when sandbox mode is enabled.");
return errors;
}
}

View File

@@ -0,0 +1,126 @@
namespace StellaOps.Scanner.Analyzers.Native.RuntimeCapture;
/// <summary>
/// Type of runtime library load event.
/// </summary>
public enum RuntimeLoadType
{
/// <summary>Direct dlopen call (Linux).</summary>
Dlopen,
/// <summary>Implicit library load from DT_NEEDED (Linux).</summary>
DtNeeded,
/// <summary>LoadLibrary/LoadLibraryEx call (Windows).</summary>
LoadLibrary,
/// <summary>Implicit DLL load from import table (Windows).</summary>
ImportLoad,
/// <summary>Delay-load DLL resolution (Windows).</summary>
DelayLoad,
/// <summary>Dynamic library load (macOS dyld).</summary>
DylibLoad,
/// <summary>Bundle load (macOS).</summary>
BundleLoad,
/// <summary>dlopen equivalent on macOS.</summary>
MacOsDlopen,
}
/// <summary>
/// A single runtime library load event captured during execution.
/// </summary>
/// <param name="Timestamp">UTC timestamp when the load occurred.</param>
/// <param name="ProcessId">Process ID where load occurred.</param>
/// <param name="ThreadId">Thread ID where load occurred.</param>
/// <param name="LoadType">Type of load operation.</param>
/// <param name="RequestedPath">Path or name requested by the caller.</param>
/// <param name="ResolvedPath">Actual path where library was loaded from, if resolved.</param>
/// <param name="LoadAddress">Base address where library was loaded in memory.</param>
/// <param name="Success">Whether the load succeeded.</param>
/// <param name="ErrorCode">Error code if load failed, null otherwise.</param>
/// <param name="CallerModule">Module that initiated the load, if known.</param>
/// <param name="CallerAddress">Return address of the load call, if captured.</param>
public sealed record RuntimeLoadEvent(
DateTime Timestamp,
int ProcessId,
int ThreadId,
RuntimeLoadType LoadType,
string RequestedPath,
string? ResolvedPath,
ulong? LoadAddress,
bool Success,
int? ErrorCode,
string? CallerModule,
ulong? CallerAddress);
/// <summary>
/// Summary of runtime evidence collected during capture session.
/// </summary>
/// <param name="SessionId">Unique identifier for this capture session.</param>
/// <param name="StartTime">When capture started.</param>
/// <param name="EndTime">When capture ended, null if still running.</param>
/// <param name="Platform">Platform where capture occurred (linux, windows, macos).</param>
/// <param name="CaptureMethod">Method used for capture (ebpf, etw, dyld-interpose).</param>
/// <param name="TargetProcessId">Process being monitored, null if system-wide.</param>
/// <param name="Events">All captured load events.</param>
/// <param name="TotalEventsDropped">Number of events dropped due to buffer overflow or filtering.</param>
/// <param name="RedactedPaths">Paths that were redacted (count only, not actual paths).</param>
public sealed record RuntimeCaptureSession(
string SessionId,
DateTime StartTime,
DateTime? EndTime,
string Platform,
string CaptureMethod,
int? TargetProcessId,
IReadOnlyList<RuntimeLoadEvent> Events,
long TotalEventsDropped,
int RedactedPaths);
/// <summary>
/// Aggregated runtime evidence from one or more capture sessions.
/// </summary>
/// <param name="Sessions">All capture sessions included.</param>
/// <param name="UniqueLibraries">Distinct libraries loaded across all sessions.</param>
/// <param name="RuntimeEdges">Dependency edges derived from runtime evidence.</param>
public sealed record RuntimeEvidence(
IReadOnlyList<RuntimeCaptureSession> Sessions,
IReadOnlyList<RuntimeLibrarySummary> UniqueLibraries,
IReadOnlyList<RuntimeDependencyEdge> RuntimeEdges);
/// <summary>
/// Summary of a unique library seen during runtime capture.
/// </summary>
/// <param name="Path">Full resolved path of the library.</param>
/// <param name="LoadCount">Number of times this library was loaded.</param>
/// <param name="FirstSeen">First time this library was loaded.</param>
/// <param name="LastSeen">Last time this library was loaded.</param>
/// <param name="LoadTypes">All load types observed for this library.</param>
/// <param name="CallerModules">All modules that loaded this library.</param>
public sealed record RuntimeLibrarySummary(
string Path,
int LoadCount,
DateTime FirstSeen,
DateTime LastSeen,
IReadOnlyList<RuntimeLoadType> LoadTypes,
IReadOnlyList<string> CallerModules);
/// <summary>
/// A dependency edge derived from runtime evidence.
/// </summary>
/// <param name="Source">Module that initiated the load (null for main executable).</param>
/// <param name="Target">Library that was loaded.</param>
/// <param name="ReasonCode">Why this edge exists (runtime-dlopen, runtime-loadlibrary, etc.).</param>
/// <param name="Confidence">Confidence level (always high for runtime evidence).</param>
/// <param name="FirstObserved">When this edge was first observed.</param>
/// <param name="ObservationCount">Number of times this edge was observed.</param>
public sealed record RuntimeDependencyEdge(
string? Source,
string Target,
string ReasonCode,
HeuristicConfidence Confidence,
DateTime FirstObserved,
int ObservationCount);

View File

@@ -0,0 +1,285 @@
namespace StellaOps.Scanner.Analyzers.Native.RuntimeCapture;
/// <summary>
/// Aggregates runtime capture events into dependency edges and summaries.
/// </summary>
public static class RuntimeEvidenceAggregator
{
/// <summary>
/// Aggregates one or more capture sessions into a unified RuntimeEvidence document.
/// </summary>
/// <param name="sessions">Capture sessions to aggregate.</param>
/// <returns>Aggregated evidence with unique libraries and dependency edges.</returns>
public static RuntimeEvidence Aggregate(IEnumerable<RuntimeCaptureSession> sessions)
{
var sessionList = sessions.ToList();
var allEvents = sessionList.SelectMany(s => s.Events).ToList();
// Build unique library summaries
var libraryGroups = allEvents
.Where(e => e.Success && !string.IsNullOrEmpty(e.ResolvedPath ?? e.RequestedPath))
.GroupBy(e => NormalizePath(e.ResolvedPath ?? e.RequestedPath))
.Select(g => new RuntimeLibrarySummary(
Path: g.Key,
LoadCount: g.Count(),
FirstSeen: g.Min(e => e.Timestamp),
LastSeen: g.Max(e => e.Timestamp),
LoadTypes: g.Select(e => e.LoadType).Distinct().ToList(),
CallerModules: g
.Where(e => !string.IsNullOrEmpty(e.CallerModule))
.Select(e => e.CallerModule!)
.Distinct()
.ToList()))
.OrderBy(s => s.Path)
.ToList();
// Build dependency edges
var edges = BuildDependencyEdges(allEvents);
return new RuntimeEvidence(
Sessions: sessionList,
UniqueLibraries: libraryGroups,
RuntimeEdges: edges);
}
/// <summary>
/// Merges runtime evidence with static/heuristic analysis results.
/// </summary>
/// <param name="runtimeEvidence">Runtime capture evidence.</param>
/// <param name="staticEdges">Static analysis dependency edges.</param>
/// <param name="heuristicEdges">Heuristic analysis edges.</param>
/// <returns>Merged evidence document.</returns>
public static MergedEvidence MergeWithStaticAnalysis(
RuntimeEvidence runtimeEvidence,
IEnumerable<Observations.NativeObservationDeclaredEdge> staticEdges,
IEnumerable<Observations.NativeObservationHeuristicEdge> heuristicEdges)
{
var staticList = staticEdges.ToList();
var heuristicList = heuristicEdges.ToList();
// Build lookup for runtime edges
var runtimeTargets = new HashSet<string>(
runtimeEvidence.RuntimeEdges.Select(e => NormalizePath(e.Target)),
StringComparer.OrdinalIgnoreCase);
// Categorize edges
var confirmedEdges = new List<MergedEdge>();
var staticOnlyEdges = new List<MergedEdge>();
var runtimeOnlyEdges = new List<MergedEdge>();
var heuristicConfirmedEdges = new List<MergedEdge>();
// Process static edges
foreach (var staticEdge in staticList)
{
var normalizedTarget = NormalizePath(staticEdge.Target);
var hasRuntimeConfirmation = runtimeTargets.Contains(normalizedTarget);
if (hasRuntimeConfirmation)
{
var runtimeEdge = runtimeEvidence.RuntimeEdges
.First(e => NormalizePath(e.Target).Equals(normalizedTarget, StringComparison.OrdinalIgnoreCase));
confirmedEdges.Add(new MergedEdge(
Target: staticEdge.Target,
Sources: [EdgeSource.Static, EdgeSource.Runtime],
StaticReason: staticEdge.Reason,
RuntimeReason: runtimeEdge.ReasonCode,
HeuristicConfidence: null,
FirstRuntimeObservation: runtimeEdge.FirstObserved,
RuntimeObservationCount: runtimeEdge.ObservationCount));
}
else
{
staticOnlyEdges.Add(new MergedEdge(
Target: staticEdge.Target,
Sources: [EdgeSource.Static],
StaticReason: staticEdge.Reason,
RuntimeReason: null,
HeuristicConfidence: null,
FirstRuntimeObservation: null,
RuntimeObservationCount: null));
}
}
// Process runtime-only edges (not in static analysis)
var staticTargets = new HashSet<string>(
staticList.Select(e => NormalizePath(e.Target)),
StringComparer.OrdinalIgnoreCase);
foreach (var runtimeEdge in runtimeEvidence.RuntimeEdges)
{
var normalizedTarget = NormalizePath(runtimeEdge.Target);
if (!staticTargets.Contains(normalizedTarget))
{
// Check if confirmed by heuristics
var heuristicMatch = heuristicList.FirstOrDefault(h =>
NormalizePath(h.Target).Equals(normalizedTarget, StringComparison.OrdinalIgnoreCase));
if (heuristicMatch != null)
{
heuristicConfirmedEdges.Add(new MergedEdge(
Target: runtimeEdge.Target,
Sources: [EdgeSource.Runtime, EdgeSource.Heuristic],
StaticReason: null,
RuntimeReason: runtimeEdge.ReasonCode,
HeuristicConfidence: heuristicMatch.Confidence,
FirstRuntimeObservation: runtimeEdge.FirstObserved,
RuntimeObservationCount: runtimeEdge.ObservationCount));
}
else
{
runtimeOnlyEdges.Add(new MergedEdge(
Target: runtimeEdge.Target,
Sources: [EdgeSource.Runtime],
StaticReason: null,
RuntimeReason: runtimeEdge.ReasonCode,
HeuristicConfidence: null,
FirstRuntimeObservation: runtimeEdge.FirstObserved,
RuntimeObservationCount: runtimeEdge.ObservationCount));
}
}
}
return new MergedEvidence(
ConfirmedEdges: confirmedEdges,
StaticOnlyEdges: staticOnlyEdges,
RuntimeOnlyEdges: runtimeOnlyEdges,
HeuristicConfirmedEdges: heuristicConfirmedEdges,
TotalRuntimeEvents: runtimeEvidence.Sessions.Sum(s => s.Events.Count),
TotalDroppedEvents: runtimeEvidence.Sessions.Sum(s => s.TotalEventsDropped),
CaptureStartTime: runtimeEvidence.Sessions.Min(s => s.StartTime),
CaptureEndTime: runtimeEvidence.Sessions.Max(s => s.EndTime ?? DateTime.UtcNow));
}
/// <summary>
/// Exports runtime evidence to observation format for persistence.
/// </summary>
/// <param name="evidence">Runtime evidence to export.</param>
/// <param name="builder">Observation builder to add edges to.</param>
public static void ExportToObservation(
RuntimeEvidence evidence,
Observations.NativeObservationBuilder builder)
{
foreach (var edge in evidence.RuntimeEdges)
{
builder.AddRuntimeEdge(
target: edge.Target,
reasonCode: edge.ReasonCode,
confidence: edge.Confidence,
firstObserved: edge.FirstObserved,
observationCount: edge.ObservationCount);
}
}
private static IReadOnlyList<RuntimeDependencyEdge> BuildDependencyEdges(List<RuntimeLoadEvent> events)
{
var edgeGroups = events
.Where(e => e.Success && !string.IsNullOrEmpty(e.ResolvedPath ?? e.RequestedPath))
.GroupBy(e => (
Source: e.CallerModule ?? "(main)",
Target: NormalizePath(e.ResolvedPath ?? e.RequestedPath)))
.Select(g =>
{
var reasonCode = DetermineReasonCode(g.First().LoadType);
return new RuntimeDependencyEdge(
Source: g.Key.Source == "(main)" ? null : g.Key.Source,
Target: g.Key.Target,
ReasonCode: reasonCode,
Confidence: HeuristicConfidence.High, // Runtime evidence is always high confidence
FirstObserved: g.Min(e => e.Timestamp),
ObservationCount: g.Count());
})
.OrderBy(e => e.Target)
.ToList();
return edgeGroups;
}
private static string DetermineReasonCode(RuntimeLoadType loadType)
{
return loadType switch
{
RuntimeLoadType.Dlopen => "runtime-dlopen",
RuntimeLoadType.DtNeeded => "runtime-dtneeded",
RuntimeLoadType.LoadLibrary => "runtime-loadlibrary",
RuntimeLoadType.ImportLoad => "runtime-import",
RuntimeLoadType.DelayLoad => "runtime-delayload",
RuntimeLoadType.DylibLoad => "runtime-dylib",
RuntimeLoadType.BundleLoad => "runtime-bundle",
RuntimeLoadType.MacOsDlopen => "runtime-macos-dlopen",
_ => "runtime-unknown"
};
}
private static string NormalizePath(string path)
{
// Normalize path for comparison (handle symlinks, case sensitivity, etc.)
path = path.Trim();
// Remove trailing slashes
path = path.TrimEnd('/', '\\');
// On Windows, normalize drive letter case
if (path.Length >= 2 && path[1] == ':')
{
path = char.ToUpperInvariant(path[0]) + path[1..];
}
return path;
}
}
/// <summary>
/// Source of a dependency edge.
/// </summary>
public enum EdgeSource
{
/// <summary>Edge from static analysis (DT_NEEDED, import table, etc.).</summary>
Static,
/// <summary>Edge from runtime capture (dlopen, LoadLibrary, etc.).</summary>
Runtime,
/// <summary>Edge from heuristic analysis (string scanning, etc.).</summary>
Heuristic,
}
/// <summary>
/// A merged dependency edge with information from multiple sources.
/// </summary>
/// <param name="Target">Target library path or name.</param>
/// <param name="Sources">Which analysis sources confirmed this edge.</param>
/// <param name="StaticReason">Reason code from static analysis, if present.</param>
/// <param name="RuntimeReason">Reason code from runtime capture, if present.</param>
/// <param name="HeuristicConfidence">Confidence level from heuristic analysis, if present.</param>
/// <param name="FirstRuntimeObservation">First time seen at runtime.</param>
/// <param name="RuntimeObservationCount">Number of times observed at runtime.</param>
public sealed record MergedEdge(
string Target,
IReadOnlyList<EdgeSource> Sources,
string? StaticReason,
string? RuntimeReason,
string? HeuristicConfidence,
DateTime? FirstRuntimeObservation,
int? RuntimeObservationCount);
/// <summary>
/// Result of merging runtime evidence with static/heuristic analysis.
/// </summary>
/// <param name="ConfirmedEdges">Edges confirmed by both static and runtime analysis.</param>
/// <param name="StaticOnlyEdges">Edges only found in static analysis.</param>
/// <param name="RuntimeOnlyEdges">Edges only found in runtime capture.</param>
/// <param name="HeuristicConfirmedEdges">Runtime edges also found by heuristics.</param>
/// <param name="TotalRuntimeEvents">Total events captured.</param>
/// <param name="TotalDroppedEvents">Total events dropped due to filtering/overflow.</param>
/// <param name="CaptureStartTime">When capture started.</param>
/// <param name="CaptureEndTime">When capture ended.</param>
public sealed record MergedEvidence(
IReadOnlyList<MergedEdge> ConfirmedEdges,
IReadOnlyList<MergedEdge> StaticOnlyEdges,
IReadOnlyList<MergedEdge> RuntimeOnlyEdges,
IReadOnlyList<MergedEdge> HeuristicConfirmedEdges,
long TotalRuntimeEvents,
long TotalDroppedEvents,
DateTime CaptureStartTime,
DateTime CaptureEndTime);

View File

@@ -0,0 +1,595 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Security.Principal;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.Native.RuntimeCapture;
/// <summary>
/// Windows runtime capture adapter using ETW (Event Tracing for Windows) to trace DLL loads.
/// </summary>
/// <remarks>
/// This adapter uses ETW to capture:
/// - Kernel Image Load events (provider: Microsoft-Windows-Kernel-Process)
/// - LoadLibrary calls via API tracing
///
/// Requires Administrator privileges for kernel events.
/// In sandbox mode, uses mock events instead of actual ETW tracing.
/// </remarks>
[SupportedOSPlatform("windows")]
public sealed class WindowsEtwCaptureAdapter : IRuntimeCaptureAdapter
{
private readonly ConcurrentBag<RuntimeLoadEvent> _events = [];
private readonly object _stateLock = new();
private CaptureState _state = CaptureState.Idle;
private RuntimeCaptureOptions? _options;
private DateTime _startTime;
private CancellationTokenSource? _captureCts;
private Task? _captureTask;
#pragma warning disable CS0649 // Field is never assigned (assigned via Start/Stop ETW)
private Process? _logmanProcess;
#pragma warning restore CS0649
private long _droppedEvents;
private int _redactedPaths;
/// <inheritdoc />
public string AdapterId => "windows-etw-imageload";
/// <inheritdoc />
public string DisplayName => "Windows ETW Image Load Tracer";
/// <inheritdoc />
public string Platform => "windows";
/// <inheritdoc />
public string CaptureMethod => "etw";
/// <inheritdoc />
public CaptureState State
{
get { lock (_stateLock) return _state; }
}
/// <inheritdoc />
public string? SessionId { get; private set; }
/// <inheritdoc />
public event EventHandler<RuntimeLoadEventArgs>? LoadEventCaptured;
/// <inheritdoc />
public event EventHandler<CaptureStateChangedEventArgs>? StateChanged;
/// <inheritdoc />
public Task<AdapterAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
{
if (!OperatingSystem.IsWindows())
{
return Task.FromResult(new AdapterAvailability(
IsAvailable: false,
Reason: "This adapter only works on Windows.",
RequiresElevation: false,
MissingDependencies: []));
}
var missingDeps = new List<string>();
var requiresElevation = false;
// Check Windows version (ETW available since Vista/Server 2008)
if (Environment.OSVersion.Version < new Version(6, 0))
{
return Task.FromResult(new AdapterAvailability(
IsAvailable: false,
Reason: "ETW requires Windows Vista or later.",
RequiresElevation: false,
MissingDependencies: missingDeps));
}
// Check for Administrator privileges
if (!IsRunningAsAdmin())
{
requiresElevation = true;
}
// Check for logman.exe (built-in ETW management tool)
var logmanPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.System),
"logman.exe");
if (!File.Exists(logmanPath))
{
missingDeps.Add("logman.exe");
}
if (missingDeps.Count > 0)
{
return Task.FromResult(new AdapterAvailability(
IsAvailable: false,
Reason: $"Missing dependencies: {string.Join(", ", missingDeps)}",
RequiresElevation: requiresElevation,
MissingDependencies: missingDeps));
}
if (requiresElevation)
{
return Task.FromResult(new AdapterAvailability(
IsAvailable: true,
Reason: "ETW kernel tracing requires Administrator privileges.",
RequiresElevation: true,
MissingDependencies: []));
}
return Task.FromResult(new AdapterAvailability(
IsAvailable: true,
Reason: null,
RequiresElevation: false,
MissingDependencies: []));
}
/// <inheritdoc />
public async Task<string> StartCaptureAsync(RuntimeCaptureOptions options, CancellationToken cancellationToken = default)
{
var validationErrors = options.Validate();
if (validationErrors.Count > 0)
throw new ArgumentException($"Invalid options: {string.Join("; ", validationErrors)}");
lock (_stateLock)
{
if (_state != CaptureState.Idle && _state != CaptureState.Stopped && _state != CaptureState.Faulted)
throw new InvalidOperationException($"Cannot start capture in state {_state}.");
SetState(CaptureState.Starting);
}
_options = options;
_events.Clear();
_droppedEvents = 0;
_redactedPaths = 0;
SessionId = Guid.NewGuid().ToString("N");
_startTime = DateTime.UtcNow;
_captureCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
try
{
if (options.Sandbox.Enabled && !options.Sandbox.AllowSystemTracing)
{
// Sandbox mode - use mock events
_captureTask = RunSandboxCaptureAsync(_captureCts.Token);
}
else
{
// Real ETW capture
_captureTask = RunEtwCaptureAsync(_captureCts.Token);
}
// Start the duration timer
_ = Task.Run(async () =>
{
try
{
await Task.Delay(options.MaxCaptureDuration, _captureCts.Token);
await StopCaptureAsync(CancellationToken.None);
}
catch (OperationCanceledException)
{
// Expected when capture is stopped manually
}
}, _captureCts.Token);
SetState(CaptureState.Running);
return SessionId;
}
catch (Exception ex)
{
SetState(CaptureState.Faulted, ex);
throw;
}
}
/// <inheritdoc />
public async Task<RuntimeCaptureSession> StopCaptureAsync(CancellationToken cancellationToken = default)
{
lock (_stateLock)
{
if (_state != CaptureState.Running)
throw new InvalidOperationException($"Cannot stop capture in state {_state}.");
SetState(CaptureState.Stopping);
}
try
{
// Cancel capture
_captureCts?.Cancel();
// Stop ETW session
await StopEtwSessionAsync(cancellationToken);
// Kill logman process if running
if (_logmanProcess is { HasExited: false })
{
try
{
_logmanProcess.Kill(true);
await _logmanProcess.WaitForExitAsync(cancellationToken);
}
catch
{
// Ignore kill errors
}
}
// Wait for capture task
if (_captureTask != null)
{
try
{
await _captureTask.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken);
}
catch (TimeoutException)
{
// Task didn't complete in time
}
catch (OperationCanceledException)
{
// Expected
}
}
var session = new RuntimeCaptureSession(
SessionId: SessionId ?? "unknown",
StartTime: _startTime,
EndTime: DateTime.UtcNow,
Platform: Platform,
CaptureMethod: CaptureMethod,
TargetProcessId: _options?.TargetProcessId,
Events: [.. _events],
TotalEventsDropped: _droppedEvents,
RedactedPaths: _redactedPaths);
SetState(CaptureState.Stopped);
return session;
}
catch (Exception ex)
{
SetState(CaptureState.Faulted, ex);
throw;
}
}
/// <inheritdoc />
public (int EventCount, int BufferUsed, int BufferCapacity) GetStatistics()
{
var count = _events.Count;
return (count, count, _options?.BufferSize ?? 1000);
}
/// <inheritdoc />
public IReadOnlyList<RuntimeLoadEvent> GetCurrentEvents() => [.. _events];
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_state == CaptureState.Running)
{
try
{
await StopCaptureAsync();
}
catch
{
// Ignore errors during dispose
}
}
_captureCts?.Dispose();
_logmanProcess?.Dispose();
}
private async Task RunSandboxCaptureAsync(CancellationToken cancellationToken)
{
// In sandbox mode, inject mock events
if (_options?.Sandbox.MockEvents is { Count: > 0 } mockEvents)
{
foreach (var evt in mockEvents)
{
cancellationToken.ThrowIfCancellationRequested();
ProcessEvent(evt);
await Task.Delay(10, cancellationToken); // Simulate event timing
}
}
// Wait until cancelled
try
{
await Task.Delay(Timeout.Infinite, cancellationToken);
}
catch (OperationCanceledException)
{
// Expected
}
}
private async Task RunEtwCaptureAsync(CancellationToken cancellationToken)
{
var sessionName = $"StellaOps_ImageLoad_{SessionId}";
var etlPath = Path.Combine(Path.GetTempPath(), $"{sessionName}.etl");
try
{
// Start ETW session using logman
await StartEtwSessionAsync(sessionName, etlPath, cancellationToken);
// Wait until cancelled
try
{
await Task.Delay(Timeout.Infinite, cancellationToken);
}
catch (OperationCanceledException)
{
// Expected when stopping
}
// Parse collected ETL file
await ParseEtlFileAsync(etlPath, cancellationToken);
}
finally
{
// Cleanup ETL file
try
{
if (File.Exists(etlPath))
File.Delete(etlPath);
}
catch
{
// Ignore cleanup errors
}
}
}
private async Task StartEtwSessionAsync(string sessionName, string etlPath, CancellationToken cancellationToken)
{
// Create ETW session for kernel image load events
// Provider GUID for Microsoft-Windows-Kernel-Process: {22FB2CD6-0E7B-422B-A0C7-2FAD1FD0E716}
var args = $"create trace \"{sessionName}\" -o \"{etlPath}\" -p \"Microsoft-Windows-Kernel-Process\" 0x10 -ets";
var psi = new ProcessStartInfo
{
FileName = "logman.exe",
Arguments = args,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
using var process = Process.Start(psi);
if (process == null)
throw new InvalidOperationException("Failed to start logman.exe");
await process.WaitForExitAsync(cancellationToken);
if (process.ExitCode != 0)
{
var error = await process.StandardError.ReadToEndAsync(cancellationToken);
throw new InvalidOperationException($"Failed to create ETW session: {error}");
}
}
private async Task StopEtwSessionAsync(CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(SessionId))
return;
var sessionName = $"StellaOps_ImageLoad_{SessionId}";
var psi = new ProcessStartInfo
{
FileName = "logman.exe",
Arguments = $"stop \"{sessionName}\" -ets",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
try
{
using var process = Process.Start(psi);
if (process != null)
{
await process.WaitForExitAsync(cancellationToken);
}
}
catch
{
// Ignore stop errors
}
}
private async Task ParseEtlFileAsync(string etlPath, CancellationToken cancellationToken)
{
if (!File.Exists(etlPath))
return;
// Use tracerpt to convert ETL to XML for parsing
var xmlPath = Path.ChangeExtension(etlPath, ".xml");
try
{
var psi = new ProcessStartInfo
{
FileName = "tracerpt.exe",
Arguments = $"\"{etlPath}\" -o \"{xmlPath}\" -of XML -summary -report",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
using var process = Process.Start(psi);
if (process != null)
{
await process.WaitForExitAsync(cancellationToken);
if (process.ExitCode == 0 && File.Exists(xmlPath))
{
await ParseTracerptOutputAsync(xmlPath, cancellationToken);
}
}
}
finally
{
// Cleanup
try
{
if (File.Exists(xmlPath))
File.Delete(xmlPath);
}
catch
{
// Ignore cleanup errors
}
}
}
private async Task ParseTracerptOutputAsync(string xmlPath, CancellationToken cancellationToken)
{
var content = await File.ReadAllTextAsync(xmlPath, cancellationToken);
// Parse image load events from XML
// Event ID 5 = Image Load
var imageLoadPattern = new Regex(
@"<Event.*?ImageFileName=""([^""]+)"".*?ProcessId=""(\d+)"".*?ImageBase=""(0x[0-9a-fA-F]+)""",
RegexOptions.Singleline);
foreach (Match match in imageLoadPattern.Matches(content))
{
var imagePath = match.Groups[1].Value;
var processId = int.Parse(match.Groups[2].Value);
var imageBase = Convert.ToUInt64(match.Groups[3].Value, 16);
// Skip if filtering by process and doesn't match
if (_options?.TargetProcessId != null && processId != _options.TargetProcessId)
continue;
var loadType = imagePath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)
? RuntimeLoadType.ImportLoad
: RuntimeLoadType.LoadLibrary;
var evt = new RuntimeLoadEvent(
Timestamp: DateTime.UtcNow,
ProcessId: processId,
ThreadId: 0,
LoadType: loadType,
RequestedPath: imagePath,
ResolvedPath: imagePath,
LoadAddress: imageBase,
Success: true,
ErrorCode: null,
CallerModule: null,
CallerAddress: null);
ProcessEvent(evt);
}
}
private void ProcessEvent(RuntimeLoadEvent evt)
{
// Apply include/exclude filters
if (_options != null)
{
if (_options.IncludePatterns.Count > 0)
{
var matched = _options.IncludePatterns.Any(p =>
MatchGlob(evt.RequestedPath, p) ||
(evt.ResolvedPath != null && MatchGlob(evt.ResolvedPath, p)));
if (!matched)
return;
}
if (_options.ExcludePatterns.Any(p =>
MatchGlob(evt.RequestedPath, p) ||
(evt.ResolvedPath != null && MatchGlob(evt.ResolvedPath, p))))
{
return;
}
// Apply redaction
if (_options.Redaction.Enabled)
{
var requestedRedacted = _options.Redaction.ApplyRedaction(evt.RequestedPath, out var wasRedacted1);
var resolvedRedacted = evt.ResolvedPath != null
? _options.Redaction.ApplyRedaction(evt.ResolvedPath, out var wasRedacted2)
: null;
if (wasRedacted1 || (evt.ResolvedPath != null && _options.Redaction.ApplyRedaction(evt.ResolvedPath, out _) != evt.ResolvedPath))
{
Interlocked.Increment(ref _redactedPaths);
evt = evt with
{
RequestedPath = requestedRedacted,
ResolvedPath = resolvedRedacted
};
}
}
// Check failures filter
if (!_options.CaptureFailures && !evt.Success)
return;
}
// Check buffer capacity
if (_options != null && _events.Count >= _options.BufferSize)
{
Interlocked.Increment(ref _droppedEvents);
return;
}
_events.Add(evt);
LoadEventCaptured?.Invoke(this, new RuntimeLoadEventArgs { Event = evt });
}
private void SetState(CaptureState newState, Exception? error = null)
{
CaptureState previous;
lock (_stateLock)
{
previous = _state;
_state = newState;
}
StateChanged?.Invoke(this, new CaptureStateChangedEventArgs
{
PreviousState = previous,
NewState = newState,
Error = error
});
}
private static bool IsRunningAsAdmin()
{
try
{
using var identity = WindowsIdentity.GetCurrent();
var principal = new WindowsPrincipal(identity);
return principal.IsInRole(WindowsBuiltInRole.Administrator);
}
catch
{
return false;
}
}
private static bool MatchGlob(string path, string pattern)
{
var regexPattern = "^" + Regex.Escape(pattern)
.Replace(@"\*\*", ".*")
.Replace(@"\*", "[^/\\\\]*")
.Replace(@"\?", ".") + "$";
return Regex.IsMatch(path, regexPattern, RegexOptions.IgnoreCase);
}
}

View File

@@ -0,0 +1,187 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Scanner.Analyzers.Native.Plugin;
using StellaOps.Scanner.Analyzers.Native.RuntimeCapture;
namespace StellaOps.Scanner.Analyzers.Native;
/// <summary>
/// Extension methods for registering native analyzer services with DI.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Configuration section name for native analyzer options.
/// </summary>
public const string ConfigSectionName = "Scanner:Analyzers:Native";
/// <summary>
/// Adds the native analyzer services to the service collection.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration for binding options.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddNativeAnalyzer(
this IServiceCollection services,
IConfiguration? configuration = null)
{
return services.AddNativeAnalyzer(configuration, null);
}
/// <summary>
/// Adds the native analyzer services to the service collection.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configure">Optional action to configure options.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddNativeAnalyzer(
this IServiceCollection services,
Action<NativeAnalyzerServiceOptions>? configure)
{
return services.AddNativeAnalyzer(null, configure);
}
/// <summary>
/// Adds the native analyzer services to the service collection.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration for binding options.</param>
/// <param name="configure">Optional action to configure options.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddNativeAnalyzer(
this IServiceCollection services,
IConfiguration? configuration,
Action<NativeAnalyzerServiceOptions>? configure)
{
// Register options
var optionsBuilder = services.AddOptions<NativeAnalyzerServiceOptions>();
if (configuration != null)
{
optionsBuilder.Bind(configuration.GetSection(ConfigSectionName));
}
if (configure != null)
{
optionsBuilder.Configure(configure);
}
// Register core services
services.TryAddSingleton<INativeAnalyzerPluginCatalog, NativeAnalyzerPluginCatalog>();
services.TryAddSingleton<INativeAnalyzer, NativeAnalyzer>();
return services;
}
/// <summary>
/// Adds runtime capture adapter services (optional, requires elevated privileges).
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configure">Optional action to configure runtime capture options.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddNativeRuntimeCapture(
this IServiceCollection services,
Action<RuntimeCaptureOptions>? configure = null)
{
var optionsBuilder = services.AddOptions<RuntimeCaptureOptions>();
if (configure != null)
{
optionsBuilder.Configure(configure);
}
// Register platform-appropriate capture adapter
services.TryAddSingleton<IRuntimeCaptureAdapter>(sp =>
{
var adapter = RuntimeCaptureAdapterFactory.CreateForCurrentPlatform();
if (adapter == null)
{
throw new PlatformNotSupportedException(
"Runtime capture is not supported on this platform.");
}
return adapter;
});
return services;
}
}
/// <summary>
/// Configuration options for native analyzer services.
/// </summary>
public sealed class NativeAnalyzerServiceOptions
{
/// <summary>
/// Directory for loading additional native analyzer plugins.
/// Default: plugins/scanner/analyzers/native
/// </summary>
public string PluginDirectory { get; set; } = "plugins/scanner/analyzers/native";
/// <summary>
/// Whether to enable heuristic scanning by default.
/// Default: true.
/// </summary>
public bool EnableHeuristicScanning { get; set; } = true;
/// <summary>
/// Whether to enable dependency resolution by default.
/// Default: true.
/// </summary>
public bool EnableResolution { get; set; } = true;
/// <summary>
/// Default timeout per binary analysis.
/// Default: 30 seconds.
/// </summary>
public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Default search paths for Linux (ELF).
/// </summary>
public List<string> LinuxDefaultSearchPaths { get; set; } =
[
"/lib",
"/lib64",
"/usr/lib",
"/usr/lib64",
"/usr/local/lib",
"/lib/x86_64-linux-gnu",
"/usr/lib/x86_64-linux-gnu"
];
/// <summary>
/// Default search paths for Windows (PE).
/// </summary>
public List<string> WindowsDefaultSearchPaths { get; set; } =
[
@"C:\Windows\System32",
@"C:\Windows\SysWOW64",
@"C:\Windows"
];
/// <summary>
/// Default search paths for macOS (Mach-O).
/// </summary>
public List<string> MacOSDefaultSearchPaths { get; set; } =
[
"/usr/lib",
"/usr/local/lib",
"/Library/Frameworks",
"/System/Library/Frameworks"
];
/// <summary>
/// Gets the default search paths for the specified format.
/// </summary>
public IReadOnlyList<string> GetDefaultSearchPathsForFormat(NativeFormat format)
{
return format switch
{
NativeFormat.Elf => LinuxDefaultSearchPaths,
NativeFormat.Pe => WindowsDefaultSearchPaths,
NativeFormat.MachO => MacOSDefaultSearchPaths,
_ => []
};
}
}

View File

@@ -8,4 +8,12 @@
<NoWarn>CA2022</NoWarn>
<WarningsNotAsErrors>CA2022</WarningsNotAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0-*" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-*" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-*" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-*" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0-*" />
</ItemGroup>
</Project>