257 lines
8.2 KiB
C#
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;
|
|
}
|
|
}
|