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
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:
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
421
src/Scanner/StellaOps.Scanner.Analyzers.Native/NativeResolver.cs
Normal file
421
src/Scanner/StellaOps.Scanner.Analyzers.Native/NativeResolver.cs
Normal 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('\\', '/');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
579
src/Scanner/StellaOps.Scanner.Analyzers.Native/PeImportParser.cs
Normal file
579
src/Scanner/StellaOps.Scanner.Analyzers.Native/PeImportParser.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
_ => []
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user