Add tests and implement timeline ingestion options with NATS and Redis subscribers

- Introduced `BinaryReachabilityLifterTests` to validate binary lifting functionality.
- Created `PackRunWorkerOptions` for configuring worker paths and execution persistence.
- Added `TimelineIngestionOptions` for configuring NATS and Redis ingestion transports.
- Implemented `NatsTimelineEventSubscriber` for subscribing to NATS events.
- Developed `RedisTimelineEventSubscriber` for reading from Redis Streams.
- Added `TimelineEnvelopeParser` to normalize incoming event envelopes.
- Created unit tests for `TimelineEnvelopeParser` to ensure correct field mapping.
- Implemented `TimelineAuthorizationAuditSink` for logging authorization outcomes.
This commit is contained in:
StellaOps Bot
2025-12-03 09:46:48 +02:00
parent e923880694
commit 35c8f9216f
520 changed files with 4416 additions and 31492 deletions

View File

@@ -0,0 +1,341 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scanner.Analyzers.Native;
namespace StellaOps.Scanner.Reachability.Lifters;
/// <summary>
/// Reachability lifter for native binaries (ELF/PE/Mach-O).
/// Emits canonical SymbolIDs with file hashes + addresses and anchors code_id attributes
/// so richgraph-v1 payloads can be generated even when symbols are stripped.
/// </summary>
public sealed class BinaryReachabilityLifter : IReachabilityLifter
{
private static readonly string[] CandidateExtensions = { ".so", ".dll", ".dylib", ".exe", ".bin", ".elf" };
public string Language => SymbolId.Lang.Binary;
public async ValueTask LiftAsync(ReachabilityLifterContext context, ReachabilityGraphBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(builder);
if (string.IsNullOrWhiteSpace(context.RootPath) || !Directory.Exists(context.RootPath))
{
return;
}
var candidates = EnumerateCandidates(context.RootPath);
foreach (var path in candidates)
{
cancellationToken.ThrowIfCancellationRequested();
var info = await AnalyzeBinaryAsync(path, context.RootPath, cancellationToken).ConfigureAwait(false);
if (info is null)
{
continue;
}
EmitNode(builder, info);
EmitDependencies(builder, info);
}
}
private static IReadOnlyList<string> EnumerateCandidates(string rootPath)
{
return Directory.EnumerateFiles(rootPath, "*", SearchOption.AllDirectories)
.Where(IsCandidate)
.OrderBy(p => p, StringComparer.Ordinal)
.Take(200) // defensive cap for determinism and performance
.ToList();
}
private static bool IsCandidate(string path)
{
var ext = Path.GetExtension(path);
if (!string.IsNullOrWhiteSpace(ext) && CandidateExtensions.Contains(ext, StringComparer.OrdinalIgnoreCase))
{
return true;
}
try
{
Span<byte> header = stackalloc byte[4];
using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
var read = stream.Read(header);
if (read < 4)
{
return false;
}
return IsKnownMagic(header);
}
catch
{
return false;
}
}
private static bool IsKnownMagic(ReadOnlySpan<byte> header)
{
return (header[0] == 0x7F && header[1] == (byte)'E' && header[2] == (byte)'L' && header[3] == (byte)'F') // ELF
|| (header[0] == (byte)'M' && header[1] == (byte)'Z') // PE
|| header.SequenceEqual(stackalloc byte[] { 0xCA, 0xFE, 0xBA, 0xBE }) // Mach-O fat
|| header.SequenceEqual(stackalloc byte[] { 0xBE, 0xBA, 0xFE, 0xCA })
|| header.SequenceEqual(stackalloc byte[] { 0xFE, 0xED, 0xFA, 0xCF }) // Mach-O 64 LE
|| header.SequenceEqual(stackalloc byte[] { 0xCF, 0xFA, 0xED, 0xFE }) // Mach-O 64 BE
|| header.SequenceEqual(stackalloc byte[] { 0xFE, 0xED, 0xFA, 0xCE }) // Mach-O 32 LE
|| header.SequenceEqual(stackalloc byte[] { 0xCE, 0xFA, 0xED, 0xFE }); // Mach-O 32 BE
}
private static async Task<BinaryInfo?> AnalyzeBinaryAsync(string path, string rootPath, CancellationToken cancellationToken)
{
byte[] data;
try
{
data = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
}
catch
{
return null;
}
var shaHex = ComputeSha256Hex(data);
var fileHash = $"sha256:{shaHex}";
var relativePath = NormalizePath(Path.GetRelativePath(rootPath, path));
var format = "binary";
NativeBinaryIdentity? identity = null;
if (TryDetect(data, out var detected))
{
identity = detected;
format = detected.Format switch
{
NativeFormat.Elf => "elf",
NativeFormat.Pe => "pe",
NativeFormat.MachO => "macho",
_ => "binary"
};
}
var dependencies = ParseDependencies(data, format, relativePath, cancellationToken, out var observedBuildId);
var buildId = observedBuildId ?? identity?.BuildId ?? identity?.Uuid;
var symbolId = SymbolId.ForBinaryAddressed(fileHash, ".text", "0x0", Path.GetFileName(path), "static");
var codeId = CodeId.ForBinarySegment(format, fileHash, "0x0", data.LongLength, ".text");
var attributes = new Dictionary<string, string>(StringComparer.Ordinal)
{
["code_id"] = codeId,
["format"] = format,
["sha256"] = fileHash
};
if (!string.IsNullOrWhiteSpace(buildId))
{
attributes["build_id"] = buildId!;
}
return new BinaryInfo(
SymbolId: symbolId,
CodeId: codeId,
RelativePath: relativePath,
Format: format,
FileHash: fileHash,
BuildId: buildId,
Dependencies: dependencies,
DisplayName: Path.GetFileName(path),
Attributes: attributes);
}
private static IReadOnlyList<BinaryDependency> ParseDependencies(
byte[] data,
string format,
string evidencePath,
CancellationToken cancellationToken,
out string? buildId)
{
buildId = null;
var deps = new List<BinaryDependency>();
switch (format)
{
case "elf":
using (var stream = new MemoryStream(data, writable: false))
{
if (ElfDynamicSectionParser.TryParse(stream, out var elfInfo, cancellationToken))
{
buildId = elfInfo.BinaryId;
foreach (var dep in elfInfo.Dependencies)
{
deps.Add(new BinaryDependency(
dep.Soname,
dep.ReasonCode,
EdgeConfidence.Certain,
"elf-dynamic",
$"file:{evidencePath}:{dep.ReasonCode}"));
}
}
}
break;
case "pe":
using (var stream = new MemoryStream(data, writable: false))
{
if (PeImportParser.TryParse(stream, out var peInfo, cancellationToken))
{
foreach (var dep in peInfo.Dependencies)
{
deps.Add(new BinaryDependency(
dep.DllName,
dep.ReasonCode,
EdgeConfidence.Certain,
"pe-import",
$"file:{evidencePath}:{dep.ReasonCode}"));
}
foreach (var dep in peInfo.DelayLoadDependencies)
{
deps.Add(new BinaryDependency(
dep.DllName,
dep.ReasonCode,
EdgeConfidence.High,
"pe-delayimport",
$"file:{evidencePath}:{dep.ReasonCode}"));
}
}
}
break;
case "macho":
using (var stream = new MemoryStream(data, writable: false))
{
if (MachOLoadCommandParser.TryParse(stream, out var machoInfo, cancellationToken))
{
buildId = machoInfo.Slices.FirstOrDefault(s => !string.IsNullOrWhiteSpace(s.Uuid))?.Uuid;
foreach (var dep in machoInfo.Slices.SelectMany(s => s.Dependencies))
{
deps.Add(new BinaryDependency(
dep.Path,
dep.ReasonCode,
EdgeConfidence.Certain,
"macho-loadcmd",
$"file:{evidencePath}:{dep.ReasonCode}"));
}
}
}
break;
}
var deduped = new Dictionary<string, BinaryDependency>(StringComparer.OrdinalIgnoreCase);
foreach (var dep in deps)
{
var key = $"{dep.Name}:{dep.Reason}";
if (!deduped.TryGetValue(key, out var existing) || existing.Confidence < dep.Confidence)
{
deduped[key] = dep;
}
}
return deduped.Values
.OrderBy(d => d.Name, StringComparer.Ordinal)
.ThenBy(d => d.Reason, StringComparer.Ordinal)
.ToList();
}
private static void EmitNode(ReachabilityGraphBuilder builder, BinaryInfo info)
{
builder.AddNode(
symbolId: info.SymbolId,
lang: SymbolId.Lang.Binary,
kind: "binary",
display: info.DisplayName,
sourceFile: info.RelativePath,
attributes: info.Attributes);
}
private static void EmitDependencies(ReachabilityGraphBuilder builder, BinaryInfo info)
{
if (info.Dependencies.Count == 0)
{
return;
}
foreach (var dep in info.Dependencies)
{
var depSymbolId = BuildDependencySymbolId(info.Format, dep.Name);
var depAttributes = new Dictionary<string, string>(StringComparer.Ordinal)
{
["code_id"] = BuildDependencyCodeId(info.Format, dep.Name),
["reason"] = dep.Reason
};
builder.AddNode(
symbolId: depSymbolId,
lang: SymbolId.Lang.Binary,
kind: "library",
display: dep.Name,
attributes: depAttributes);
builder.AddEdge(
from: info.SymbolId,
to: depSymbolId,
edgeType: EdgeTypes.Import,
confidence: dep.Confidence,
origin: "static",
provenance: dep.Provenance,
evidence: dep.Evidence);
}
}
private static bool TryDetect(byte[] data, out NativeBinaryIdentity identity)
{
using var stream = new MemoryStream(data, writable: false);
return NativeFormatDetector.TryDetect(stream, out identity);
}
private static string BuildDependencySymbolId(string format, string name)
{
var depHash = $"sha256:{ComputeSha256Hex(Encoding.UTF8.GetBytes(name))}";
return SymbolId.ForBinaryAddressed(depHash, ".import", "0x0", name, "import");
}
private static string BuildDependencyCodeId(string format, string name)
{
var depHash = $"sha256:{ComputeSha256Hex(Encoding.UTF8.GetBytes(name))}";
return CodeId.ForBinarySegment(format, depHash, "0x0", null, ".import");
}
private static string ComputeSha256Hex(byte[] data)
{
var hash = SHA256.HashData(data);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static string NormalizePath(string path) => path.Replace('\\', '/');
private sealed record BinaryInfo(
string SymbolId,
string CodeId,
string RelativePath,
string Format,
string FileHash,
string? BuildId,
IReadOnlyList<BinaryDependency> Dependencies,
string DisplayName,
IReadOnlyDictionary<string, string> Attributes);
private sealed record BinaryDependency(
string Name,
string Reason,
EdgeConfidence Confidence,
string Provenance,
string Evidence);
}

View File

@@ -122,6 +122,7 @@ public sealed class ReachabilityLifterRegistry
{
return new IReachabilityLifter[]
{
new BinaryReachabilityLifter(),
new NodeReachabilityLifter(),
new DotNetReachabilityLifter(),
// Future lifters:

View File

@@ -7,6 +7,7 @@
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Cache\StellaOps.Scanner.Cache.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Surface.Env\StellaOps.Scanner.Surface.Env.csproj" />
<ProjectReference Include="..\..\StellaOps.Scanner.Analyzers.Native\StellaOps.Scanner.Analyzers.Native.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Replay.Core\StellaOps.Replay.Core.csproj" />
</ItemGroup>
</Project>