Files
git.stella-ops.org/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Native/NativeReachabilityAnalyzer.cs

257 lines
8.2 KiB
C#

using StellaOps.Scanner.Analyzers.Native.Internal.Callgraph;
using StellaOps.Scanner.Analyzers.Native.Internal.Elf;
using StellaOps.Scanner.Analyzers.Native.Internal.Graph;
namespace StellaOps.Scanner.Analyzers.Native;
/// <summary>
/// Analyzes native ELF binaries for reachability graphs.
/// Implements SCAN-NATIVE-REACH-0146-13 requirements:
/// - Call-graph extraction from ELF binaries
/// - Synthetic roots (_init, .init_array, .preinit_array, entry points)
/// - Build-id capture
/// - PURL/symbol digests
/// - Unknowns emission
/// - DSSE graph bundles
/// </summary>
public sealed class NativeReachabilityAnalyzer
{
private readonly TimeProvider _timeProvider;
public NativeReachabilityAnalyzer(TimeProvider timeProvider)
{
ArgumentNullException.ThrowIfNull(timeProvider);
_timeProvider = timeProvider;
}
/// <summary>
/// Analyzes a directory of ELF binaries and produces a reachability graph.
/// </summary>
/// <param name="layerPath">Path to the layer directory.</param>
/// <param name="layerDigest">Digest of the layer.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The native reachability graph.</returns>
public async Task<NativeReachabilityGraph> AnalyzeLayerAsync(
string layerPath,
string layerDigest,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(layerPath);
ArgumentException.ThrowIfNullOrEmpty(layerDigest);
var builder = new NativeCallgraphBuilder(layerDigest, _timeProvider);
// Find all potential ELF files in the layer
await foreach (var filePath in FindElfFilesAsync(layerPath, cancellationToken))
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await using var stream = File.OpenRead(filePath);
var relativePath = Path.GetRelativePath(layerPath, filePath).Replace('\\', '/');
var elf = ElfReader.Parse(stream, relativePath, layerDigest);
if (elf is not null)
{
builder.AddElfFile(elf);
}
}
catch (IOException)
{
// Skip files that can't be read
}
catch (UnauthorizedAccessException)
{
// Skip files without permission
}
}
return builder.Build();
}
/// <summary>
/// Analyzes a single ELF file and produces a reachability graph.
/// </summary>
public async Task<NativeReachabilityGraph> AnalyzeFileAsync(
string filePath,
string layerDigest,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(filePath);
ArgumentException.ThrowIfNullOrEmpty(layerDigest);
var builder = new NativeCallgraphBuilder(layerDigest, _timeProvider);
await using var stream = File.OpenRead(filePath);
var elf = ElfReader.Parse(stream, filePath, layerDigest);
if (elf is not null)
{
builder.AddElfFile(elf);
}
return builder.Build();
}
/// <summary>
/// Analyzes an ELF file from a stream.
/// </summary>
public NativeReachabilityGraph AnalyzeStream(
Stream stream,
string filePath,
string layerDigest)
{
ArgumentNullException.ThrowIfNull(stream);
ArgumentException.ThrowIfNullOrEmpty(filePath);
ArgumentException.ThrowIfNullOrEmpty(layerDigest);
var builder = new NativeCallgraphBuilder(layerDigest, _timeProvider);
var elf = ElfReader.Parse(stream, filePath, layerDigest);
if (elf is not null)
{
builder.AddElfFile(elf);
}
return builder.Build();
}
/// <summary>
/// Writes the graph as NDJSON to a stream.
/// </summary>
public static Task WriteNdjsonAsync(
NativeReachabilityGraph graph,
Stream stream,
CancellationToken cancellationToken = default)
{
return NativeGraphDsseWriter.WriteNdjsonAsync(graph, stream, cancellationToken);
}
/// <summary>
/// Writes the graph as JSON (for DSSE payload).
/// </summary>
public static string WriteJson(NativeReachabilityGraph graph)
{
return NativeGraphDsseWriter.WriteJson(graph);
}
private static async IAsyncEnumerable<string> FindElfFilesAsync(
string rootPath,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
var searchDirs = new Stack<string>();
searchDirs.Push(rootPath);
// Common directories containing ELF binaries
var binaryDirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"bin", "sbin", "lib", "lib64", "lib32", "libx32",
"usr/bin", "usr/sbin", "usr/lib", "usr/lib64", "usr/lib32",
"usr/local/bin", "usr/local/sbin", "usr/local/lib",
"opt"
};
while (searchDirs.Count > 0)
{
cancellationToken.ThrowIfCancellationRequested();
var currentDir = searchDirs.Pop();
IEnumerable<string> files;
try
{
files = Directory.EnumerateFiles(currentDir);
}
catch (Exception) when (IsIgnorableException(default!))
{
continue;
}
foreach (var file in files)
{
cancellationToken.ThrowIfCancellationRequested();
// Quick check: skip obvious non-ELF files
var ext = Path.GetExtension(file);
if (IsSkippableExtension(ext))
{
continue;
}
// Check if file starts with ELF magic
if (await IsElfFileAsync(file, cancellationToken))
{
yield return file;
}
}
// Recurse into subdirectories
IEnumerable<string> subdirs;
try
{
subdirs = Directory.EnumerateDirectories(currentDir);
}
catch (Exception) when (IsIgnorableException(default!))
{
continue;
}
foreach (var subdir in subdirs)
{
var dirName = Path.GetFileName(subdir);
// Skip common non-binary directories
if (IsSkippableDirectory(dirName))
{
continue;
}
searchDirs.Push(subdir);
}
}
}
private static async Task<bool> IsElfFileAsync(string filePath, CancellationToken ct)
{
try
{
var buffer = new byte[4];
await using var stream = File.OpenRead(filePath);
var bytesRead = await stream.ReadAsync(buffer, ct);
return bytesRead >= 4 && ElfReader.IsElf(buffer);
}
catch
{
return false;
}
}
private static bool IsSkippableExtension(string ext)
{
return ext is ".txt" or ".md" or ".json" or ".xml" or ".yaml" or ".yml"
or ".html" or ".css" or ".js" or ".ts" or ".py" or ".rb" or ".php"
or ".java" or ".class" or ".jar" or ".war" or ".ear"
or ".png" or ".jpg" or ".jpeg" or ".gif" or ".svg" or ".ico"
or ".zip" or ".tar" or ".gz" or ".bz2" or ".xz" or ".7z"
or ".deb" or ".rpm" or ".apk"
or ".pem" or ".crt" or ".key" or ".pub"
or ".log" or ".pid" or ".lock";
}
private static bool IsSkippableDirectory(string dirName)
{
return dirName is "." or ".."
or "proc" or "sys" or "dev" or "run" or "tmp" or "var"
or "home" or "root" or "etc" or "boot" or "media" or "mnt"
or "node_modules" or ".git" or ".svn" or ".hg"
or "__pycache__" or ".cache" or ".npm" or ".cargo"
or "share" or "doc" or "man" or "info" or "locale";
}
private static bool IsIgnorableException(Exception ex)
{
return ex is IOException or UnauthorizedAccessException or DirectoryNotFoundException;
}
}