up the blokcing tasks
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Risk Bundle CI / risk-bundle-build (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Risk Bundle CI / risk-bundle-offline-kit (push) Has been cancelled
Risk Bundle CI / publish-checksums (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-11 02:32:18 +02:00
parent 92bc4d3a07
commit 49922dff5a
474 changed files with 76071 additions and 12411 deletions

View File

@@ -46,7 +46,7 @@ internal static class DenoRuntimeTraceProbe
try
{
using var document = JsonDocument.Parse(line);
using var document = JsonDocument.Parse(line.ToArray());
if (!document.RootElement.TryGetProperty("type", out var typeProp))
{
continue;

View File

@@ -0,0 +1,357 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Runtime;
/// <summary>
/// Resolves runtime edges from parsed Java runtime events.
/// Produces append-only edges for runtime-class, runtime-spi, runtime-load patterns.
/// </summary>
internal static class JavaRuntimeEdgeResolver
{
/// <summary>
/// Resolves runtime edges and entrypoints from parsed events.
/// </summary>
public static JavaRuntimeIngestion ResolveFromEvents(
ImmutableArray<JavaRuntimeEvent> events,
ImmutableArray<JavaRuntimeIngestionWarning> parseWarnings,
string contentHash,
JavaRuntimeIngestionConfig config,
CancellationToken cancellationToken = default)
{
var edges = ImmutableArray.CreateBuilder<JavaRuntimeEdge>();
var entrypoints = ImmutableArray.CreateBuilder<JavaRuntimeEntrypoint>();
var warnings = ImmutableArray.CreateBuilder<JavaRuntimeIngestionWarning>();
warnings.AddRange(parseWarnings);
// Track seen edges for deduplication
var seenEdges = new HashSet<string>();
// Track entrypoint invocation counts
var entrypointCounts = new Dictionary<string, (JavaRuntimeEntrypoint Entry, int Count)>();
// Summary counters
int classLoadCount = 0, serviceLoaderCount = 0, nativeLoadCount = 0;
int reflectionCount = 0, resourceAccessCount = 0, moduleResolveCount = 0;
DateTimeOffset? startTime = null, endTime = null;
foreach (var evt in events)
{
cancellationToken.ThrowIfCancellationRequested();
// Track time bounds
if (startTime is null || evt.Timestamp < startTime)
{
startTime = evt.Timestamp;
}
if (endTime is null || evt.Timestamp > endTime)
{
endTime = evt.Timestamp;
}
switch (evt)
{
case JavaClassLoadEvent classLoad:
classLoadCount++;
ResolveClassLoadEdges(classLoad, edges, seenEdges, config);
break;
case JavaServiceLoaderEvent serviceLoader:
serviceLoaderCount++;
ResolveSpiEdges(serviceLoader, edges, entrypoints, entrypointCounts, seenEdges, config);
break;
case JavaNativeLoadEvent nativeLoad:
nativeLoadCount++;
ResolveNativeLoadEdges(nativeLoad, edges, seenEdges, config);
break;
case JavaReflectionEvent reflection:
reflectionCount++;
ResolveReflectionEdges(reflection, edges, entrypoints, entrypointCounts, seenEdges, config);
break;
case JavaResourceAccessEvent resourceAccess:
resourceAccessCount++;
ResolveResourceEdges(resourceAccess, edges, seenEdges, config);
break;
case JavaModuleResolveEvent moduleResolve:
moduleResolveCount++;
ResolveModuleEdges(moduleResolve, edges, seenEdges, config);
break;
}
}
// Build final entrypoints from tracked counts
var finalEntrypoints = entrypointCounts.Values
.Select(v => v.Entry with { InvocationCount = v.Count })
.ToImmutableArray();
var summary = new JavaRuntimeTraceSummary(
StartTime: startTime ?? DateTimeOffset.MinValue,
EndTime: endTime ?? DateTimeOffset.MinValue,
JavaVersion: null, // Would come from trace metadata if available
JavaVendor: null,
JvmName: null,
JvmArgs: null,
ClassLoadCount: classLoadCount,
ServiceLoaderCount: serviceLoaderCount,
NativeLoadCount: nativeLoadCount,
ReflectionCount: reflectionCount,
ResourceAccessCount: resourceAccessCount,
ModuleResolveCount: moduleResolveCount);
return new JavaRuntimeIngestion(
events,
edges.ToImmutable(),
finalEntrypoints,
summary,
warnings.ToImmutable(),
contentHash);
}
private static void ResolveClassLoadEdges(
JavaClassLoadEvent evt,
ImmutableArray<JavaRuntimeEdge>.Builder edges,
HashSet<string> seenEdges,
JavaRuntimeIngestionConfig config)
{
var edgeKey = $"runtime-class:{evt.InitiatingClass ?? "bootstrap"}:{evt.ClassName}";
if (config.DeduplicateEdges && !seenEdges.Add(edgeKey))
{
return;
}
var reason = evt.ClassLoader switch
{
"bootstrap" => JavaRuntimeEdgeReason.ClassLoadBootstrap,
"platform" or "ext" => JavaRuntimeEdgeReason.ClassLoadPlatform,
"app" or "system" => JavaRuntimeEdgeReason.ClassLoadApplication,
_ => JavaRuntimeEdgeReason.ClassLoadCustom,
};
edges.Add(new JavaRuntimeEdge(
EdgeId: ComputeEdgeId(edgeKey),
SourceClass: evt.InitiatingClass,
TargetClass: evt.ClassName,
EdgeType: JavaRuntimeEdgeType.RuntimeClass,
Reason: reason,
Timestamp: evt.Timestamp,
Source: evt.Source,
SourceHash: evt.SourceHash,
Confidence: 1.0,
Details: $"classloader={evt.ClassLoader}"));
}
private static void ResolveSpiEdges(
JavaServiceLoaderEvent evt,
ImmutableArray<JavaRuntimeEdge>.Builder edges,
ImmutableArray<JavaRuntimeEntrypoint>.Builder entrypoints,
Dictionary<string, (JavaRuntimeEntrypoint Entry, int Count)> entrypointCounts,
HashSet<string> seenEdges,
JavaRuntimeIngestionConfig config)
{
foreach (var provider in evt.Providers)
{
var edgeKey = $"runtime-spi:{evt.ServiceInterface}:{provider.ProviderClass}";
if (!config.DeduplicateEdges || seenEdges.Add(edgeKey))
{
edges.Add(new JavaRuntimeEdge(
EdgeId: ComputeEdgeId(edgeKey),
SourceClass: evt.ServiceInterface,
TargetClass: provider.ProviderClass,
EdgeType: JavaRuntimeEdgeType.RuntimeSpi,
Reason: JavaRuntimeEdgeReason.ServiceLoaderExplicit,
Timestamp: evt.Timestamp,
Source: provider.Source,
SourceHash: provider.SourceHash,
Confidence: 1.0,
Details: $"service={evt.ServiceInterface}"));
}
// Track provider as entrypoint
var entrypointKey = $"spi:{provider.ProviderClass}";
if (entrypointCounts.TryGetValue(entrypointKey, out var existing))
{
entrypointCounts[entrypointKey] = (existing.Entry, existing.Count + 1);
}
else
{
var entrypoint = new JavaRuntimeEntrypoint(
EntrypointId: ComputeEdgeId(entrypointKey),
ClassName: provider.ProviderClass,
MethodName: null,
EntrypointType: JavaRuntimeEntrypointType.ServiceProvider,
FirstSeen: evt.Timestamp,
InvocationCount: 1,
Source: provider.Source,
SourceHash: provider.SourceHash,
Confidence: 1.0);
entrypointCounts[entrypointKey] = (entrypoint, 1);
}
}
}
private static void ResolveNativeLoadEdges(
JavaNativeLoadEvent evt,
ImmutableArray<JavaRuntimeEdge>.Builder edges,
HashSet<string> seenEdges,
JavaRuntimeIngestionConfig config)
{
var edgeKey = $"runtime-native:{evt.InitiatingClass ?? "unknown"}:{evt.LibraryName}";
if (config.DeduplicateEdges && !seenEdges.Add(edgeKey))
{
return;
}
var reason = evt.LoadMethod switch
{
"System.load" => JavaRuntimeEdgeReason.SystemLoad,
"System.loadLibrary" => JavaRuntimeEdgeReason.SystemLoadLibrary,
"Runtime.load" => JavaRuntimeEdgeReason.RuntimeLoad,
"Runtime.loadLibrary" => JavaRuntimeEdgeReason.RuntimeLoadLibrary,
_ => JavaRuntimeEdgeReason.SystemLoadLibrary,
};
if (!evt.Success)
{
reason = JavaRuntimeEdgeReason.NativeLoadFailure;
}
edges.Add(new JavaRuntimeEdge(
EdgeId: ComputeEdgeId(edgeKey),
SourceClass: evt.InitiatingClass,
TargetClass: evt.LibraryName,
EdgeType: JavaRuntimeEdgeType.RuntimeNativeLoad,
Reason: reason,
Timestamp: evt.Timestamp,
Source: evt.ResolvedPath,
SourceHash: evt.PathHash,
Confidence: evt.Success ? 1.0 : 0.5,
Details: evt.Success ? $"resolved={evt.ResolvedPath}" : "load_failed"));
}
private static void ResolveReflectionEdges(
JavaReflectionEvent evt,
ImmutableArray<JavaRuntimeEdge>.Builder edges,
ImmutableArray<JavaRuntimeEntrypoint>.Builder entrypoints,
Dictionary<string, (JavaRuntimeEntrypoint Entry, int Count)> entrypointCounts,
HashSet<string> seenEdges,
JavaRuntimeIngestionConfig config)
{
var edgeKey = $"runtime-reflect:{evt.InitiatingClass ?? "unknown"}:{evt.TargetClass}:{evt.ReflectionMethod}";
if (!config.DeduplicateEdges || seenEdges.Add(edgeKey))
{
var reason = evt.ReflectionMethod switch
{
"Class.forName" => JavaRuntimeEdgeReason.ClassForName,
"Class.newInstance" => JavaRuntimeEdgeReason.ClassNewInstance,
"Constructor.newInstance" => JavaRuntimeEdgeReason.ConstructorNewInstance,
"Method.invoke" => JavaRuntimeEdgeReason.MethodInvoke,
_ => JavaRuntimeEdgeReason.ClassForName,
};
edges.Add(new JavaRuntimeEdge(
EdgeId: ComputeEdgeId(edgeKey),
SourceClass: evt.InitiatingClass,
TargetClass: evt.TargetClass,
EdgeType: JavaRuntimeEdgeType.RuntimeReflection,
Reason: reason,
Timestamp: evt.Timestamp,
Source: null,
SourceHash: null,
Confidence: 0.9, // Reflection edges have slightly lower confidence
Details: $"method={evt.ReflectionMethod}"));
}
// Track reflection target as entrypoint
var entrypointKey = $"reflect:{evt.TargetClass}";
if (entrypointCounts.TryGetValue(entrypointKey, out var existing))
{
entrypointCounts[entrypointKey] = (existing.Entry, existing.Count + 1);
}
else
{
var entrypoint = new JavaRuntimeEntrypoint(
EntrypointId: ComputeEdgeId(entrypointKey),
ClassName: evt.TargetClass,
MethodName: null,
EntrypointType: JavaRuntimeEntrypointType.ReflectionTarget,
FirstSeen: evt.Timestamp,
InvocationCount: 1,
Source: null,
SourceHash: null,
Confidence: 0.9);
entrypointCounts[entrypointKey] = (entrypoint, 1);
}
}
private static void ResolveResourceEdges(
JavaResourceAccessEvent evt,
ImmutableArray<JavaRuntimeEdge>.Builder edges,
HashSet<string> seenEdges,
JavaRuntimeIngestionConfig config)
{
if (!evt.Found)
{
return; // Only track successful resource lookups
}
var edgeKey = $"runtime-resource:{evt.InitiatingClass ?? "unknown"}:{evt.ResourceName}";
if (config.DeduplicateEdges && !seenEdges.Add(edgeKey))
{
return;
}
edges.Add(new JavaRuntimeEdge(
EdgeId: ComputeEdgeId(edgeKey),
SourceClass: evt.InitiatingClass,
TargetClass: evt.ResourceName,
EdgeType: JavaRuntimeEdgeType.RuntimeResource,
Reason: JavaRuntimeEdgeReason.GetResource,
Timestamp: evt.Timestamp,
Source: evt.Source,
SourceHash: evt.SourceHash,
Confidence: 1.0,
Details: null));
}
private static void ResolveModuleEdges(
JavaModuleResolveEvent evt,
ImmutableArray<JavaRuntimeEdge>.Builder edges,
HashSet<string> seenEdges,
JavaRuntimeIngestionConfig config)
{
if (string.IsNullOrEmpty(evt.RequiredBy))
{
return; // Skip root modules without a requiring module
}
var edgeKey = $"runtime-module:{evt.RequiredBy}:{evt.ModuleName}";
if (config.DeduplicateEdges && !seenEdges.Add(edgeKey))
{
return;
}
edges.Add(new JavaRuntimeEdge(
EdgeId: ComputeEdgeId(edgeKey),
SourceClass: evt.RequiredBy,
TargetClass: evt.ModuleName,
EdgeType: JavaRuntimeEdgeType.RuntimeModule,
Reason: JavaRuntimeEdgeReason.ModuleRequires,
Timestamp: evt.Timestamp,
Source: evt.ModuleLocation,
SourceHash: evt.LocationHash,
Confidence: 1.0,
Details: evt.IsOpen ? "open_module" : null));
}
private static string ComputeEdgeId(string input)
{
var bytes = Encoding.UTF8.GetBytes(input);
var hash = SHA256.HashData(bytes);
return $"runtime:{Convert.ToHexString(hash[..8]).ToLowerInvariant()}";
}
}

View File

@@ -0,0 +1,286 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Runtime;
/// <summary>
/// Parses NDJSON runtime trace files produced by Java agent or JFR export.
/// Supports both agent-produced traces and JFR .ndjson exports.
/// </summary>
internal static class JavaRuntimeEventParser
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
};
/// <summary>
/// Parses a runtime trace file and returns all events.
/// </summary>
/// <param name="stream">Stream containing NDJSON trace data.</param>
/// <param name="config">Ingestion configuration.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Parsed events and warnings.</returns>
public static async Task<(ImmutableArray<JavaRuntimeEvent> Events, ImmutableArray<JavaRuntimeIngestionWarning> Warnings, string ContentHash)>
ParseAsync(Stream stream, JavaRuntimeIngestionConfig config, CancellationToken cancellationToken = default)
{
var events = ImmutableArray.CreateBuilder<JavaRuntimeEvent>();
var warnings = ImmutableArray.CreateBuilder<JavaRuntimeIngestionWarning>();
using var hashAlgorithm = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true);
var lineNumber = 0;
var eventCount = 0;
while (await reader.ReadLineAsync(cancellationToken) is { } line)
{
lineNumber++;
// Update content hash
hashAlgorithm.AppendData(Encoding.UTF8.GetBytes(line));
hashAlgorithm.AppendData("\n"u8);
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
// Check max events limit
if (config.MaxEvents > 0 && eventCount >= config.MaxEvents)
{
warnings.Add(new JavaRuntimeIngestionWarning(
"MAX_EVENTS_REACHED",
$"Maximum event limit ({config.MaxEvents}) reached, stopping parse",
lineNumber,
null));
break;
}
try
{
var evt = ParseLine(line, config);
if (evt is not null)
{
events.Add(evt);
eventCount++;
}
}
catch (JsonException ex)
{
warnings.Add(new JavaRuntimeIngestionWarning(
"PARSE_ERROR",
$"Failed to parse JSON: {ex.Message}",
lineNumber,
line.Length > 200 ? line[..200] + "..." : line));
}
}
var hash = Convert.ToHexString(hashAlgorithm.GetCurrentHash()).ToLowerInvariant();
return (events.ToImmutable(), warnings.ToImmutable(), hash);
}
/// <summary>
/// Parses a single NDJSON line into a runtime event.
/// </summary>
private static JavaRuntimeEvent? ParseLine(string line, JavaRuntimeIngestionConfig config)
{
using var doc = JsonDocument.Parse(line);
var root = doc.RootElement;
if (!root.TryGetProperty("type", out var typeElement))
{
return null;
}
var type = typeElement.GetString();
return type switch
{
"java.class.load" => ParseClassLoadEvent(root, config),
"java.service.load" => ParseServiceLoaderEvent(root, config),
"java.native.load" => ParseNativeLoadEvent(root, config),
"java.reflection.access" => ParseReflectionEvent(root, config),
"java.resource.access" => ParseResourceAccessEvent(root, config),
"java.module.resolve" => ParseModuleResolveEvent(root, config),
"java.class.statistics" => config.IncludeStatistics ? ParseClassStatisticsEvent(root) : null,
_ => null, // Unknown event types are silently ignored
};
}
private static JavaClassLoadEvent? ParseClassLoadEvent(JsonElement root, JavaRuntimeIngestionConfig config)
{
var ts = GetTimestamp(root);
var className = root.GetProperty("class_name").GetString() ?? string.Empty;
var classLoader = root.TryGetProperty("class_loader", out var cl) ? cl.GetString() ?? "app" : "app";
var source = root.TryGetProperty("source", out var s) ? s.GetString() : null;
var sourceHash = root.TryGetProperty("source_hash", out var sh) ? sh.GetString() : null;
var initiatingClass = root.TryGetProperty("initiating_class", out var ic) ? ic.GetString() : null;
var threadName = root.TryGetProperty("thread_name", out var tn) ? tn.GetString() : null;
// Filter JDK classes if configured
if (!config.IncludeJdkClasses && IsJdkClass(className))
{
return null;
}
// Compute source hash if not provided and scrubbing is enabled
if (config.ScrubPaths && source is not null && sourceHash is null)
{
sourceHash = ComputePathHash(source);
}
return new JavaClassLoadEvent(ts, className, classLoader, source, sourceHash, initiatingClass, threadName);
}
private static JavaServiceLoaderEvent ParseServiceLoaderEvent(JsonElement root, JavaRuntimeIngestionConfig config)
{
var ts = GetTimestamp(root);
var serviceInterface = root.GetProperty("service_interface").GetString() ?? string.Empty;
var initiatingClass = root.TryGetProperty("initiating_class", out var ic) ? ic.GetString() : null;
var threadName = root.TryGetProperty("thread_name", out var tn) ? tn.GetString() : null;
var providers = ImmutableArray.CreateBuilder<JavaServiceProviderInfo>();
if (root.TryGetProperty("providers", out var providersElement) && providersElement.ValueKind == JsonValueKind.Array)
{
foreach (var p in providersElement.EnumerateArray())
{
var providerClass = p.TryGetProperty("provider_class", out var pc) ? pc.GetString() ?? string.Empty : string.Empty;
var source = p.TryGetProperty("source", out var s) ? s.GetString() : null;
var sourceHash = p.TryGetProperty("source_hash", out var sh) ? sh.GetString() : null;
if (config.ScrubPaths && source is not null && sourceHash is null)
{
sourceHash = ComputePathHash(source);
}
providers.Add(new JavaServiceProviderInfo(providerClass, source, sourceHash));
}
}
return new JavaServiceLoaderEvent(ts, serviceInterface, providers.ToImmutable(), initiatingClass, threadName);
}
private static JavaNativeLoadEvent ParseNativeLoadEvent(JsonElement root, JavaRuntimeIngestionConfig config)
{
var ts = GetTimestamp(root);
var libraryName = root.GetProperty("library_name").GetString() ?? string.Empty;
var resolvedPath = root.TryGetProperty("resolved_path", out var rp) ? rp.GetString() : null;
var pathHash = root.TryGetProperty("path_hash", out var ph) ? ph.GetString() : null;
var loadMethod = root.TryGetProperty("load_method", out var lm) ? lm.GetString() ?? "System.loadLibrary" : "System.loadLibrary";
var initiatingClass = root.TryGetProperty("initiating_class", out var ic) ? ic.GetString() : null;
var threadName = root.TryGetProperty("thread_name", out var tn) ? tn.GetString() : null;
var success = root.TryGetProperty("success", out var sc) && sc.GetBoolean();
if (config.ScrubPaths && resolvedPath is not null && pathHash is null)
{
pathHash = ComputePathHash(resolvedPath);
}
return new JavaNativeLoadEvent(ts, libraryName, resolvedPath, pathHash, loadMethod, initiatingClass, threadName, success);
}
private static JavaReflectionEvent ParseReflectionEvent(JsonElement root, JavaRuntimeIngestionConfig config)
{
var ts = GetTimestamp(root);
var targetClass = root.GetProperty("target_class").GetString() ?? string.Empty;
var reflectionMethod = root.TryGetProperty("reflection_method", out var rm) ? rm.GetString() ?? "Class.forName" : "Class.forName";
var initiatingClass = root.TryGetProperty("initiating_class", out var ic) ? ic.GetString() : null;
var sourceLine = root.TryGetProperty("source_line", out var sl) ? sl.GetString() : null;
var threadName = root.TryGetProperty("thread_name", out var tn) ? tn.GetString() : null;
// Filter JDK classes if configured
if (!config.IncludeJdkClasses && IsJdkClass(targetClass))
{
return new JavaReflectionEvent(ts, targetClass, reflectionMethod, initiatingClass, sourceLine, threadName);
}
return new JavaReflectionEvent(ts, targetClass, reflectionMethod, initiatingClass, sourceLine, threadName);
}
private static JavaResourceAccessEvent ParseResourceAccessEvent(JsonElement root, JavaRuntimeIngestionConfig config)
{
var ts = GetTimestamp(root);
var resourceName = root.GetProperty("resource_name").GetString() ?? string.Empty;
var source = root.TryGetProperty("source", out var s) ? s.GetString() : null;
var sourceHash = root.TryGetProperty("source_hash", out var sh) ? sh.GetString() : null;
var initiatingClass = root.TryGetProperty("initiating_class", out var ic) ? ic.GetString() : null;
var found = root.TryGetProperty("found", out var f) && f.GetBoolean();
if (config.ScrubPaths && source is not null && sourceHash is null)
{
sourceHash = ComputePathHash(source);
}
return new JavaResourceAccessEvent(ts, resourceName, source, sourceHash, initiatingClass, found);
}
private static JavaModuleResolveEvent ParseModuleResolveEvent(JsonElement root, JavaRuntimeIngestionConfig config)
{
var ts = GetTimestamp(root);
var moduleName = root.GetProperty("module_name").GetString() ?? string.Empty;
var moduleLocation = root.TryGetProperty("module_location", out var ml) ? ml.GetString() : null;
var locationHash = root.TryGetProperty("location_hash", out var lh) ? lh.GetString() : null;
var requiredBy = root.TryGetProperty("required_by", out var rb) ? rb.GetString() : null;
var isOpen = root.TryGetProperty("is_open", out var io) && io.GetBoolean();
if (config.ScrubPaths && moduleLocation is not null && locationHash is null)
{
locationHash = ComputePathHash(moduleLocation);
}
return new JavaModuleResolveEvent(ts, moduleName, moduleLocation, locationHash, requiredBy, isOpen);
}
private static JavaClassLoadingStatisticsEvent ParseClassStatisticsEvent(JsonElement root)
{
var ts = GetTimestamp(root);
var loadedClassCount = root.TryGetProperty("loaded_class_count", out var lcc) ? lcc.GetInt64() : 0;
var unloadedClassCount = root.TryGetProperty("unloaded_class_count", out var ucc) ? ucc.GetInt64() : 0;
var classLoaders = root.TryGetProperty("class_loaders", out var cl) ? cl.GetInt32() : 0;
var hiddenClasses = root.TryGetProperty("hidden_classes", out var hc) ? hc.GetInt32() : 0;
return new JavaClassLoadingStatisticsEvent(ts, loadedClassCount, unloadedClassCount, classLoaders, hiddenClasses);
}
private static DateTimeOffset GetTimestamp(JsonElement root)
{
if (root.TryGetProperty("ts", out var ts))
{
if (ts.ValueKind == JsonValueKind.String)
{
return DateTimeOffset.Parse(ts.GetString()!, null, System.Globalization.DateTimeStyles.RoundtripKind);
}
if (ts.ValueKind == JsonValueKind.Number)
{
return DateTimeOffset.FromUnixTimeMilliseconds(ts.GetInt64());
}
}
return DateTimeOffset.UtcNow;
}
private static bool IsJdkClass(string className)
{
// Check for JDK internal packages
return className.StartsWith("java/", StringComparison.Ordinal)
|| className.StartsWith("javax/", StringComparison.Ordinal)
|| className.StartsWith("jdk/", StringComparison.Ordinal)
|| className.StartsWith("sun/", StringComparison.Ordinal)
|| className.StartsWith("com/sun/", StringComparison.Ordinal)
|| className.StartsWith("oracle/", StringComparison.Ordinal);
}
/// <summary>
/// Computes a SHA-256 hash of a path for deterministic path-safe evidence.
/// </summary>
internal static string ComputePathHash(string path)
{
// Normalize path separators to forward slash
var normalized = path.Replace('\\', '/');
var bytes = Encoding.UTF8.GetBytes(normalized);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,172 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Runtime;
/// <summary>
/// Base type for Java runtime events captured via Java agent or JFR.
/// Events are serialized as NDJSON with deterministic key ordering.
/// </summary>
internal abstract record JavaRuntimeEvent(
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("ts")] DateTimeOffset Timestamp);
/// <summary>
/// Class load event captured when a class is loaded by the JVM.
/// </summary>
/// <param name="Ts">Event timestamp (UTC).</param>
/// <param name="ClassName">Fully qualified class name (e.g., "java/lang/String").</param>
/// <param name="ClassLoader">Class loader name (e.g., "app", "platform", "bootstrap").</param>
/// <param name="Source">JAR/location where the class was loaded from.</param>
/// <param name="SourceHash">SHA-256 hash of normalized source path for path-safe evidence.</param>
/// <param name="InitiatingClass">Class that initiated the load (if available).</param>
/// <param name="ThreadName">Name of the thread where load occurred.</param>
internal sealed record JavaClassLoadEvent(
DateTimeOffset Ts,
string ClassName,
string ClassLoader,
string? Source,
string? SourceHash,
string? InitiatingClass,
string? ThreadName) : JavaRuntimeEvent("java.class.load", Ts);
/// <summary>
/// ServiceLoader lookup event captured when ServiceLoader.load() is called.
/// </summary>
/// <param name="Ts">Event timestamp (UTC).</param>
/// <param name="ServiceInterface">Service interface being loaded.</param>
/// <param name="Providers">List of provider classes discovered.</param>
/// <param name="InitiatingClass">Class that called ServiceLoader.load().</param>
/// <param name="ThreadName">Name of the thread where lookup occurred.</param>
internal sealed record JavaServiceLoaderEvent(
DateTimeOffset Ts,
string ServiceInterface,
IReadOnlyList<JavaServiceProviderInfo> Providers,
string? InitiatingClass,
string? ThreadName) : JavaRuntimeEvent("java.service.load", Ts);
/// <summary>
/// Information about a service provider discovered by ServiceLoader.
/// </summary>
/// <param name="ProviderClass">Provider implementation class name.</param>
/// <param name="Source">JAR/module where provider was found.</param>
/// <param name="SourceHash">SHA-256 hash of normalized source path.</param>
internal sealed record JavaServiceProviderInfo(
[property: JsonPropertyName("provider_class")] string ProviderClass,
[property: JsonPropertyName("source")] string? Source,
[property: JsonPropertyName("source_hash")] string? SourceHash);
/// <summary>
/// Native library load event captured when System.load/loadLibrary is called.
/// </summary>
/// <param name="Ts">Event timestamp (UTC).</param>
/// <param name="LibraryName">Library name (from loadLibrary) or path (from load).</param>
/// <param name="ResolvedPath">Actual resolved path to the native library.</param>
/// <param name="PathHash">SHA-256 hash of normalized path for path-safe evidence.</param>
/// <param name="LoadMethod">How the library was loaded: "System.load", "System.loadLibrary", "Runtime.load", "Runtime.loadLibrary".</param>
/// <param name="InitiatingClass">Class that initiated the load.</param>
/// <param name="ThreadName">Name of the thread where load occurred.</param>
/// <param name="Success">Whether the load succeeded.</param>
internal sealed record JavaNativeLoadEvent(
DateTimeOffset Ts,
string LibraryName,
string? ResolvedPath,
string? PathHash,
string LoadMethod,
string? InitiatingClass,
string? ThreadName,
bool Success) : JavaRuntimeEvent("java.native.load", Ts);
/// <summary>
/// Reflection class instantiation event captured via instrumentation.
/// </summary>
/// <param name="Ts">Event timestamp (UTC).</param>
/// <param name="TargetClass">Class being instantiated/accessed via reflection.</param>
/// <param name="ReflectionMethod">Method used: "Class.forName", "Class.newInstance", "Constructor.newInstance", "Method.invoke".</param>
/// <param name="InitiatingClass">Class that performed the reflection call.</param>
/// <param name="SourceLine">Source line information if available (class:line format).</param>
/// <param name="ThreadName">Name of the thread where reflection occurred.</param>
internal sealed record JavaReflectionEvent(
DateTimeOffset Ts,
string TargetClass,
string ReflectionMethod,
string? InitiatingClass,
string? SourceLine,
string? ThreadName) : JavaRuntimeEvent("java.reflection.access", Ts);
/// <summary>
/// Resource access event captured when ClassLoader.getResource* is called.
/// </summary>
/// <param name="Ts">Event timestamp (UTC).</param>
/// <param name="ResourceName">Name of the resource being accessed.</param>
/// <param name="Source">JAR/location where resource was found.</param>
/// <param name="SourceHash">SHA-256 hash of normalized source path.</param>
/// <param name="InitiatingClass">Class that requested the resource.</param>
/// <param name="Found">Whether the resource was found.</param>
internal sealed record JavaResourceAccessEvent(
DateTimeOffset Ts,
string ResourceName,
string? Source,
string? SourceHash,
string? InitiatingClass,
bool Found) : JavaRuntimeEvent("java.resource.access", Ts);
/// <summary>
/// Module resolution event captured when JPMS resolves module dependencies.
/// </summary>
/// <param name="Ts">Event timestamp (UTC).</param>
/// <param name="ModuleName">Name of the module being resolved.</param>
/// <param name="ModuleLocation">Location URI of the module.</param>
/// <param name="LocationHash">SHA-256 hash of normalized location.</param>
/// <param name="RequiredBy">Module that required this module.</param>
/// <param name="IsOpen">Whether this is an open module.</param>
internal sealed record JavaModuleResolveEvent(
DateTimeOffset Ts,
string ModuleName,
string? ModuleLocation,
string? LocationHash,
string? RequiredBy,
bool IsOpen) : JavaRuntimeEvent("java.module.resolve", Ts);
/// <summary>
/// JFR event containing aggregated class loading statistics.
/// </summary>
/// <param name="Ts">Event timestamp (UTC).</param>
/// <param name="LoadedClassCount">Total number of loaded classes.</param>
/// <param name="UnloadedClassCount">Total number of unloaded classes.</param>
/// <param name="ClassLoaders">Number of live class loaders.</param>
/// <param name="HiddenClasses">Number of hidden/anonymous classes.</param>
internal sealed record JavaClassLoadingStatisticsEvent(
DateTimeOffset Ts,
long LoadedClassCount,
long UnloadedClassCount,
int ClassLoaders,
int HiddenClasses) : JavaRuntimeEvent("java.class.statistics", Ts);
/// <summary>
/// Summary metadata for a runtime trace session.
/// </summary>
/// <param name="StartTime">Trace session start time.</param>
/// <param name="EndTime">Trace session end time.</param>
/// <param name="JavaVersion">Java version string.</param>
/// <param name="JavaVendor">Java vendor string.</param>
/// <param name="JvmName">JVM name (e.g., "OpenJDK 64-Bit Server VM").</param>
/// <param name="JvmArgs">Sanitized JVM arguments (secrets redacted).</param>
/// <param name="ClassLoadCount">Total class load events.</param>
/// <param name="ServiceLoaderCount">Total ServiceLoader events.</param>
/// <param name="NativeLoadCount">Total native library load events.</param>
/// <param name="ReflectionCount">Total reflection events.</param>
/// <param name="ResourceAccessCount">Total resource access events.</param>
/// <param name="ModuleResolveCount">Total module resolution events.</param>
internal sealed record JavaRuntimeTraceSummary(
DateTimeOffset StartTime,
DateTimeOffset EndTime,
string? JavaVersion,
string? JavaVendor,
string? JvmName,
IReadOnlyList<string>? JvmArgs,
int ClassLoadCount,
int ServiceLoaderCount,
int NativeLoadCount,
int ReflectionCount,
int ResourceAccessCount,
int ModuleResolveCount);

View File

@@ -0,0 +1,211 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Runtime;
/// <summary>
/// Result of Java runtime trace ingestion per task 21-010.
/// Contains parsed events and derived runtime edges for entrypoint resolution.
/// </summary>
/// <param name="Events">All parsed runtime events.</param>
/// <param name="RuntimeEdges">Edges derived from runtime observation.</param>
/// <param name="RuntimeEntrypoints">Entrypoints discovered through runtime execution.</param>
/// <param name="Summary">Summary metadata for the trace session.</param>
/// <param name="Warnings">Warnings encountered during parsing/ingestion.</param>
/// <param name="ContentHash">SHA-256 hash of the trace content for deterministic identification.</param>
internal sealed record JavaRuntimeIngestion(
ImmutableArray<JavaRuntimeEvent> Events,
ImmutableArray<JavaRuntimeEdge> RuntimeEdges,
ImmutableArray<JavaRuntimeEntrypoint> RuntimeEntrypoints,
JavaRuntimeTraceSummary Summary,
ImmutableArray<JavaRuntimeIngestionWarning> Warnings,
string ContentHash)
{
public static readonly JavaRuntimeIngestion Empty = new(
ImmutableArray<JavaRuntimeEvent>.Empty,
ImmutableArray<JavaRuntimeEdge>.Empty,
ImmutableArray<JavaRuntimeEntrypoint>.Empty,
new JavaRuntimeTraceSummary(
StartTime: DateTimeOffset.MinValue,
EndTime: DateTimeOffset.MinValue,
JavaVersion: null,
JavaVendor: null,
JvmName: null,
JvmArgs: null,
ClassLoadCount: 0,
ServiceLoaderCount: 0,
NativeLoadCount: 0,
ReflectionCount: 0,
ResourceAccessCount: 0,
ModuleResolveCount: 0),
ImmutableArray<JavaRuntimeIngestionWarning>.Empty,
string.Empty);
}
/// <summary>
/// A runtime edge observed during Java execution.
/// These are append-only edges that augment static analysis with runtime evidence.
/// </summary>
/// <param name="EdgeId">Deterministic edge identifier.</param>
/// <param name="SourceClass">Class that initiated the load/lookup.</param>
/// <param name="TargetClass">Class/resource/library that was loaded.</param>
/// <param name="EdgeType">Type of runtime edge.</param>
/// <param name="Reason">Detailed reason code for the edge.</param>
/// <param name="Timestamp">When the edge was observed.</param>
/// <param name="Source">JAR/module where target was loaded from.</param>
/// <param name="SourceHash">SHA-256 hash of source path.</param>
/// <param name="Confidence">Confidence level (runtime edges are typically 1.0).</param>
/// <param name="Details">Additional details about the edge.</param>
internal sealed record JavaRuntimeEdge(
string EdgeId,
string? SourceClass,
string TargetClass,
JavaRuntimeEdgeType EdgeType,
JavaRuntimeEdgeReason Reason,
DateTimeOffset Timestamp,
string? Source,
string? SourceHash,
double Confidence,
string? Details);
/// <summary>
/// An entrypoint discovered through runtime execution.
/// These are classes/methods that were actually invoked during execution.
/// </summary>
/// <param name="EntrypointId">Deterministic identifier.</param>
/// <param name="ClassName">Fully qualified class name.</param>
/// <param name="MethodName">Method name if applicable.</param>
/// <param name="EntrypointType">Type of runtime entrypoint.</param>
/// <param name="FirstSeen">First observation timestamp.</param>
/// <param name="InvocationCount">Number of times this entrypoint was observed.</param>
/// <param name="Source">JAR/module containing the entrypoint.</param>
/// <param name="SourceHash">SHA-256 hash of source path.</param>
/// <param name="Confidence">Confidence level.</param>
internal sealed record JavaRuntimeEntrypoint(
string EntrypointId,
string ClassName,
string? MethodName,
JavaRuntimeEntrypointType EntrypointType,
DateTimeOffset FirstSeen,
int InvocationCount,
string? Source,
string? SourceHash,
double Confidence);
/// <summary>
/// Warning encountered during runtime ingestion.
/// </summary>
/// <param name="WarningCode">Machine-readable warning code.</param>
/// <param name="Message">Human-readable message.</param>
/// <param name="Line">Line number in trace file if applicable.</param>
/// <param name="Details">Additional context.</param>
internal sealed record JavaRuntimeIngestionWarning(
string WarningCode,
string Message,
int? Line,
string? Details);
/// <summary>
/// Types of runtime edges (observed during execution).
/// </summary>
internal enum JavaRuntimeEdgeType
{
/// <summary>Class was loaded during runtime.</summary>
RuntimeClass,
/// <summary>ServiceLoader discovered provider at runtime.</summary>
RuntimeSpi,
/// <summary>Native library was loaded at runtime.</summary>
RuntimeNativeLoad,
/// <summary>Reflection-based class access at runtime.</summary>
RuntimeReflection,
/// <summary>Resource was accessed at runtime.</summary>
RuntimeResource,
/// <summary>Module was resolved at runtime.</summary>
RuntimeModule,
}
/// <summary>
/// Reason codes for runtime edges (more specific than edge type).
/// </summary>
internal enum JavaRuntimeEdgeReason
{
// Class loading reasons
ClassLoadBootstrap,
ClassLoadPlatform,
ClassLoadApplication,
ClassLoadCustom,
// ServiceLoader reasons
ServiceLoaderExplicit,
ServiceLoaderModuleInfo,
ServiceLoaderMetaInf,
// Native load reasons
SystemLoad,
SystemLoadLibrary,
RuntimeLoad,
RuntimeLoadLibrary,
NativeLoadFailure,
// Reflection reasons
ClassForName,
ClassNewInstance,
ConstructorNewInstance,
MethodInvoke,
// Resource reasons
GetResource,
GetResourceAsStream,
GetResources,
// Module reasons
ModuleRequires,
ModuleOpens,
ModuleExports,
}
/// <summary>
/// Types of runtime entrypoints (discovered during execution).
/// </summary>
internal enum JavaRuntimeEntrypointType
{
/// <summary>Main method was executed.</summary>
MainMethod,
/// <summary>ServiceLoader provider was instantiated.</summary>
ServiceProvider,
/// <summary>Reflection target was accessed.</summary>
ReflectionTarget,
/// <summary>Native method was called (JNI callback).</summary>
NativeCallback,
/// <summary>CDI/Spring bean was instantiated.</summary>
ManagedBean,
/// <summary>Servlet/filter was initialized.</summary>
WebComponent,
}
/// <summary>
/// Configuration for runtime ingestion behavior.
/// </summary>
/// <param name="ScrubPaths">Whether to hash/scrub file paths for privacy.</param>
/// <param name="IncludeJdkClasses">Whether to include JDK internal class loads.</param>
/// <param name="IncludeStatistics">Whether to process statistics events.</param>
/// <param name="MaxEvents">Maximum number of events to process (0 = unlimited).</param>
/// <param name="DeduplicateEdges">Whether to deduplicate identical edges.</param>
internal sealed record JavaRuntimeIngestionConfig(
bool ScrubPaths = true,
bool IncludeJdkClasses = false,
bool IncludeStatistics = true,
int MaxEvents = 0,
bool DeduplicateEdges = true)
{
public static readonly JavaRuntimeIngestionConfig Default = new();
}

View File

@@ -0,0 +1,233 @@
using System.Collections.Immutable;
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.Resolver;
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Runtime;
/// <summary>
/// Main entry point for Java runtime trace ingestion (task 21-010).
/// Ingests NDJSON trace files from Java agent or JFR and produces runtime edges.
/// </summary>
internal static class JavaRuntimeIngestor
{
/// <summary>
/// Ingests a runtime trace file and returns runtime edges and entrypoints.
/// </summary>
/// <param name="stream">Stream containing NDJSON trace data.</param>
/// <param name="config">Ingestion configuration.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Ingestion result with runtime edges and entrypoints.</returns>
public static async Task<JavaRuntimeIngestion> IngestAsync(
Stream stream,
JavaRuntimeIngestionConfig? config = null,
CancellationToken cancellationToken = default)
{
config ??= JavaRuntimeIngestionConfig.Default;
// Parse events from NDJSON
var (events, warnings, contentHash) = await JavaRuntimeEventParser.ParseAsync(
stream,
config,
cancellationToken);
// Resolve edges from events
return JavaRuntimeEdgeResolver.ResolveFromEvents(
events,
warnings,
contentHash,
config,
cancellationToken);
}
/// <summary>
/// Ingests a runtime trace file from a file path.
/// </summary>
public static async Task<JavaRuntimeIngestion> IngestFromFileAsync(
string filePath,
JavaRuntimeIngestionConfig? config = null,
CancellationToken cancellationToken = default)
{
await using var stream = File.OpenRead(filePath);
return await IngestAsync(stream, config, cancellationToken);
}
/// <summary>
/// Merges runtime edges into an existing entrypoint resolution.
/// Creates a new resolution with combined static and runtime evidence.
/// </summary>
/// <param name="staticResolution">Resolution from static analysis (21-005/006/007/008).</param>
/// <param name="runtimeIngestion">Ingestion result from runtime trace.</param>
/// <returns>Combined resolution with runtime edges appended.</returns>
public static JavaEntrypointResolution MergeRuntimeEdges(
JavaEntrypointResolution staticResolution,
JavaRuntimeIngestion runtimeIngestion)
{
ArgumentNullException.ThrowIfNull(staticResolution);
ArgumentNullException.ThrowIfNull(runtimeIngestion);
// Convert runtime edges to resolved edges
var convertedEdges = runtimeIngestion.RuntimeEdges
.Select(ConvertRuntimeEdge)
.ToImmutableArray();
// Convert runtime entrypoints to resolved entrypoints
var convertedEntrypoints = runtimeIngestion.RuntimeEntrypoints
.Select(ConvertRuntimeEntrypoint)
.ToImmutableArray();
// Merge edges (runtime edges appended after static)
var allEdges = staticResolution.Edges.AddRange(convertedEdges);
// Merge entrypoints (avoid duplicates by class name)
var existingClasses = staticResolution.Entrypoints
.Select(e => e.ClassFqcn)
.ToHashSet();
var newEntrypoints = convertedEntrypoints
.Where(e => !existingClasses.Contains(e.ClassFqcn))
.ToImmutableArray();
var allEntrypoints = staticResolution.Entrypoints.AddRange(newEntrypoints);
// Add runtime warnings as resolution warnings
var runtimeWarnings = runtimeIngestion.Warnings
.Select(w => new JavaResolutionWarning(
w.WarningCode,
w.Message,
null,
w.Details))
.ToImmutableArray();
var allWarnings = staticResolution.Warnings.AddRange(runtimeWarnings);
// Recalculate statistics
var statistics = RecalculateStatistics(
allEntrypoints,
staticResolution.Components,
allEdges,
staticResolution.Statistics.ResolutionDuration);
return new JavaEntrypointResolution(
allEntrypoints,
staticResolution.Components,
allEdges,
statistics,
allWarnings);
}
private static JavaResolvedEdge ConvertRuntimeEdge(JavaRuntimeEdge edge)
{
var edgeType = edge.EdgeType switch
{
JavaRuntimeEdgeType.RuntimeClass => JavaEdgeType.ReflectionLoad,
JavaRuntimeEdgeType.RuntimeSpi => JavaEdgeType.ServiceProvider,
JavaRuntimeEdgeType.RuntimeNativeLoad => JavaEdgeType.JniNativeLib,
JavaRuntimeEdgeType.RuntimeReflection => JavaEdgeType.ReflectionLoad,
JavaRuntimeEdgeType.RuntimeResource => JavaEdgeType.ResourceBundle,
JavaRuntimeEdgeType.RuntimeModule => JavaEdgeType.JpmsRequires,
_ => JavaEdgeType.ReflectionLoad,
};
var reason = edge.Reason switch
{
JavaRuntimeEdgeReason.ClassLoadBootstrap => JavaEdgeReason.ClassLoaderLoadClass,
JavaRuntimeEdgeReason.ClassLoadPlatform => JavaEdgeReason.ClassLoaderLoadClass,
JavaRuntimeEdgeReason.ClassLoadApplication => JavaEdgeReason.ClassLoaderLoadClass,
JavaRuntimeEdgeReason.ClassLoadCustom => JavaEdgeReason.ClassLoaderLoadClass,
JavaRuntimeEdgeReason.ServiceLoaderExplicit => JavaEdgeReason.MetaInfServices,
JavaRuntimeEdgeReason.ServiceLoaderModuleInfo => JavaEdgeReason.ModuleInfoProvides,
JavaRuntimeEdgeReason.ServiceLoaderMetaInf => JavaEdgeReason.MetaInfServices,
JavaRuntimeEdgeReason.SystemLoad => JavaEdgeReason.SystemLoad,
JavaRuntimeEdgeReason.SystemLoadLibrary => JavaEdgeReason.SystemLoadLibrary,
JavaRuntimeEdgeReason.RuntimeLoad => JavaEdgeReason.RuntimeLoadLibrary,
JavaRuntimeEdgeReason.RuntimeLoadLibrary => JavaEdgeReason.RuntimeLoadLibrary,
JavaRuntimeEdgeReason.ClassForName => JavaEdgeReason.ClassForName,
JavaRuntimeEdgeReason.ClassNewInstance => JavaEdgeReason.ConstructorNewInstance,
JavaRuntimeEdgeReason.ConstructorNewInstance => JavaEdgeReason.ConstructorNewInstance,
JavaRuntimeEdgeReason.MethodInvoke => JavaEdgeReason.MethodInvoke,
JavaRuntimeEdgeReason.GetResource => JavaEdgeReason.ResourceReference,
JavaRuntimeEdgeReason.GetResourceAsStream => JavaEdgeReason.ResourceReference,
JavaRuntimeEdgeReason.GetResources => JavaEdgeReason.ResourceReference,
JavaRuntimeEdgeReason.ModuleRequires => JavaEdgeReason.JpmsRequiresTransitive,
_ => JavaEdgeReason.ClassForName,
};
return new JavaResolvedEdge(
EdgeId: edge.EdgeId,
SourceId: edge.SourceClass ?? "runtime",
TargetId: edge.TargetClass,
EdgeType: edgeType,
Reason: reason,
Confidence: edge.Confidence,
SegmentIdentifier: edge.Source ?? "runtime",
Details: $"[runtime] {edge.Details}");
}
private static JavaResolvedEntrypoint ConvertRuntimeEntrypoint(JavaRuntimeEntrypoint entry)
{
var entrypointType = entry.EntrypointType switch
{
JavaRuntimeEntrypointType.MainMethod => JavaEntrypointType.MainClass,
JavaRuntimeEntrypointType.ServiceProvider => JavaEntrypointType.ServiceProvider,
JavaRuntimeEntrypointType.ReflectionTarget => JavaEntrypointType.ServiceProvider, // Mapped to ServiceProvider for simplicity
JavaRuntimeEntrypointType.NativeCallback => JavaEntrypointType.NativeMethod,
JavaRuntimeEntrypointType.ManagedBean => JavaEntrypointType.CdiObserver,
JavaRuntimeEntrypointType.WebComponent => JavaEntrypointType.Servlet,
_ => JavaEntrypointType.ServiceProvider,
};
return new JavaResolvedEntrypoint(
EntrypointId: entry.EntrypointId,
ClassFqcn: entry.ClassName,
MethodName: entry.MethodName,
MethodDescriptor: null,
EntrypointType: entrypointType,
SegmentIdentifier: entry.Source ?? "runtime",
Framework: null,
Confidence: entry.Confidence,
ResolutionPath: ImmutableArray.Create("runtime-trace"),
Metadata: ImmutableDictionary<string, string>.Empty
.Add("runtime.invocation_count", entry.InvocationCount.ToString())
.Add("runtime.first_seen", entry.FirstSeen.ToString("O")));
}
private static JavaResolutionStatistics RecalculateStatistics(
ImmutableArray<JavaResolvedEntrypoint> entrypoints,
ImmutableArray<JavaResolvedComponent> components,
ImmutableArray<JavaResolvedEdge> edges,
TimeSpan originalDuration)
{
var entrypointsByType = entrypoints
.GroupBy(e => e.EntrypointType)
.ToImmutableDictionary(g => g.Key, g => g.Count());
var edgesByType = edges
.GroupBy(e => e.EdgeType)
.ToImmutableDictionary(g => g.Key, g => g.Count());
var entrypointsByFramework = entrypoints
.Where(e => e.Framework is not null)
.GroupBy(e => e.Framework!)
.ToImmutableDictionary(g => g.Key, g => g.Count());
var highConfidence = entrypoints.Count(e => e.Confidence >= 0.8);
var mediumConfidence = entrypoints.Count(e => e.Confidence >= 0.5 && e.Confidence < 0.8);
var lowConfidence = entrypoints.Count(e => e.Confidence < 0.5);
var signedComponents = components.Count(c => c.IsSigned);
var modularComponents = components.Count(c => c.ModuleInfo is not null);
return new JavaResolutionStatistics(
TotalEntrypoints: entrypoints.Length,
TotalComponents: components.Length,
TotalEdges: edges.Length,
EntrypointsByType: entrypointsByType,
EdgesByType: edgesByType,
EntrypointsByFramework: entrypointsByFramework,
HighConfidenceCount: highConfidence,
MediumConfidenceCount: mediumConfidence,
LowConfidenceCount: lowConfidence,
SignedComponents: signedComponents,
ModularComponents: modularComponents,
ResolutionDuration: originalDuration);
}
}

View File

@@ -142,7 +142,7 @@ internal enum PhpCapabilityKind
/// <summary>
/// Risk levels for capability usage.
/// </summary>
internal enum PhpCapabilityRisk
public enum PhpCapabilityRisk
{
/// <summary>Low risk, common usage patterns.</summary>
Low,

View File

@@ -17,21 +17,26 @@ internal static partial class PhpVersionConflictDetector
{
var conflicts = new List<PhpVersionConflict>();
if (manifest is null || lockData is null || lockData.IsEmpty)
if (manifest is null)
{
return PhpConflictAnalysis.Empty;
}
// Combine all locked packages
var lockedPackages = lockData.Packages
.Concat(lockData.DevPackages)
// Combine all locked packages (may be empty if lockData is null/empty)
var lockedPackages = (lockData?.Packages ?? [])
.Concat(lockData?.DevPackages ?? [])
.ToDictionary(p => p.Name, p => p, StringComparer.OrdinalIgnoreCase);
// Check for missing platform requirements (php version, extensions)
conflicts.AddRange(AnalyzePlatformRequirements(manifest));
// Check for packages in manifest.require that might have constraint issues
conflicts.AddRange(AnalyzeRequireConstraints(manifest, lockedPackages));
// Only check require constraints if we have a valid lock file to compare against
// (LockPath being set indicates a lock file exists, even if empty)
if (lockData is not null && !string.IsNullOrEmpty(lockData.LockPath))
{
// Check for packages in manifest.require that might have constraint issues
conflicts.AddRange(AnalyzeRequireConstraints(manifest, lockedPackages));
}
// Check for packages with unstable versions
conflicts.AddRange(AnalyzeUnstableVersions(lockedPackages.Values));

View File

@@ -0,0 +1,5 @@
global using System.Collections.Immutable;
global using System.Security.Cryptography;
global using System.Text;
global using System.Text.Json;
global using System.Text.Json.Serialization;

View File

@@ -0,0 +1,337 @@
using StellaOps.Scanner.Analyzers.Native.Internal.Elf;
using StellaOps.Scanner.Analyzers.Native.Internal.Graph;
namespace StellaOps.Scanner.Analyzers.Native.Internal.Callgraph;
/// <summary>
/// Builds native reachability graphs from ELF files.
/// Extracts functions, call edges, synthetic roots, and emits unknowns.
/// </summary>
internal sealed class NativeCallgraphBuilder
{
private readonly Dictionary<string, NativeFunctionNode> _functions = new();
private readonly List<NativeCallEdge> _edges = new();
private readonly List<NativeSyntheticRoot> _roots = new();
private readonly List<NativeUnknown> _unknowns = new();
private readonly Dictionary<ulong, string> _addressToSymbolId = new();
private readonly string _layerDigest;
private int _binaryCount;
public NativeCallgraphBuilder(string layerDigest)
{
_layerDigest = layerDigest;
}
/// <summary>
/// Adds an ELF file to the graph.
/// </summary>
public void AddElfFile(ElfFile elf)
{
_binaryCount++;
// Add function symbols
foreach (var sym in elf.Symbols.Concat(elf.DynamicSymbols))
{
if (sym.Type != ElfSymbolType.Func || string.IsNullOrEmpty(sym.Name))
{
continue;
}
AddFunction(sym, elf);
}
// Add synthetic roots for _start, _init, main
AddSyntheticRoots(elf);
// Add edges from relocations
AddRelocationEdges(elf);
// Add edges from init arrays
AddInitArrayEdges(elf);
}
/// <summary>
/// Builds the final reachability graph.
/// </summary>
public NativeReachabilityGraph Build()
{
var functions = _functions.Values
.OrderBy(f => f.BinaryPath)
.ThenBy(f => f.Address)
.ToImmutableArray();
var edges = _edges
.OrderBy(e => e.CallerId)
.ThenBy(e => e.CallSiteOffset)
.ToImmutableArray();
var roots = _roots
.OrderBy(r => r.BinaryPath)
.ThenBy(r => r.Phase)
.ThenBy(r => r.Order)
.ToImmutableArray();
var unknowns = _unknowns
.OrderBy(u => u.BinaryPath)
.ThenBy(u => u.SourceId)
.ToImmutableArray();
var contentHash = NativeGraphIdentifiers.ComputeGraphHash(functions, edges, roots);
var metadata = new NativeGraphMetadata(
GeneratedAt: DateTimeOffset.UtcNow,
GeneratorVersion: NativeGraphIdentifiers.GetGeneratorVersion(),
LayerDigest: _layerDigest,
BinaryCount: _binaryCount,
FunctionCount: functions.Length,
EdgeCount: edges.Length,
UnknownCount: unknowns.Length,
SyntheticRootCount: roots.Length);
return new NativeReachabilityGraph(
_layerDigest,
functions,
edges,
roots,
unknowns,
metadata,
contentHash);
}
private void AddFunction(ElfSymbol sym, ElfFile elf)
{
var binding = sym.Binding.ToString().ToLowerInvariant();
var visibility = sym.Visibility.ToString().ToLowerInvariant();
var symbolId = NativeGraphIdentifiers.ComputeSymbolId(sym.Name, sym.Value, sym.Size, binding);
var symbolDigest = NativeGraphIdentifiers.ComputeSymbolDigest(sym.Name, sym.Value, sym.Size, binding);
// Generate PURL based on binary path (simplified - would use proper package mapping in production)
var purl = GeneratePurl(elf.Path, sym.Name);
var isExported = sym.Binding == ElfSymbolBinding.Global && sym.Visibility == ElfSymbolVisibility.Default;
var func = new NativeFunctionNode(
SymbolId: symbolId,
Name: sym.Name,
Purl: purl,
BinaryPath: elf.Path,
BuildId: elf.BuildId,
Address: sym.Value,
Size: sym.Size,
SymbolDigest: symbolDigest,
Binding: binding,
Visibility: visibility,
IsExported: isExported);
_functions.TryAdd(symbolId, func);
_addressToSymbolId.TryAdd(sym.Value, symbolId);
}
private void AddSyntheticRoots(ElfFile elf)
{
// Find and add _start
AddRootIfExists(elf, "_start", NativeRootType.Start, "load", 0);
// Find and add _init
AddRootIfExists(elf, "_init", NativeRootType.Init, "init", 0);
// Find and add _fini
AddRootIfExists(elf, "_fini", NativeRootType.Fini, "fini", 0);
// Find and add main
AddRootIfExists(elf, "main", NativeRootType.Main, "main", 0);
// Add preinit_array entries
for (var i = 0; i < elf.PreInitArraySymbols.Length; i++)
{
var symName = elf.PreInitArraySymbols[i];
AddRootByName(elf, symName, NativeRootType.PreInitArray, "preinit", i);
}
// Add init_array entries
for (var i = 0; i < elf.InitArraySymbols.Length; i++)
{
var symName = elf.InitArraySymbols[i];
AddRootByName(elf, symName, NativeRootType.InitArray, "init", i);
}
}
private void AddRootIfExists(ElfFile elf, string symbolName, NativeRootType rootType, string phase, int order)
{
var sym = elf.Symbols.Concat(elf.DynamicSymbols)
.FirstOrDefault(s => s.Name == symbolName && s.Type == ElfSymbolType.Func);
if (sym is null)
{
return;
}
var binding = sym.Binding.ToString().ToLowerInvariant();
var symbolId = NativeGraphIdentifiers.ComputeSymbolId(sym.Name, sym.Value, sym.Size, binding);
var rootId = NativeGraphIdentifiers.ComputeRootId(symbolId, rootType, order);
_roots.Add(new NativeSyntheticRoot(
RootId: rootId,
TargetId: symbolId,
RootType: rootType,
BinaryPath: elf.Path,
Phase: phase,
Order: order));
}
private void AddRootByName(ElfFile elf, string symbolName, NativeRootType rootType, string phase, int order)
{
// Check if it's a hex address placeholder
if (symbolName.StartsWith("func_0x", StringComparison.Ordinal))
{
// Create an unknown for unresolved init array entry
var unknownId = NativeGraphIdentifiers.ComputeUnknownId(symbolName, NativeUnknownType.UnresolvedTarget, symbolName);
_unknowns.Add(new NativeUnknown(
UnknownId: unknownId,
UnknownType: NativeUnknownType.UnresolvedTarget,
SourceId: $"{elf.Path}:{phase}:{order}",
Name: symbolName,
Reason: "Init array entry could not be resolved to a symbol",
BinaryPath: elf.Path));
return;
}
AddRootIfExists(elf, symbolName, rootType, phase, order);
}
private void AddRelocationEdges(ElfFile elf)
{
var allSymbols = elf.Symbols.Concat(elf.DynamicSymbols).ToList();
foreach (var reloc in elf.Relocations)
{
if (reloc.SymbolIndex == 0 || reloc.SymbolIndex >= allSymbols.Count)
{
continue;
}
var targetSym = allSymbols[(int)reloc.SymbolIndex];
if (targetSym.Type != ElfSymbolType.Func || string.IsNullOrEmpty(targetSym.Name))
{
continue;
}
// Find the function containing this relocation
var callerSym = FindFunctionContainingAddress(allSymbols, reloc.Offset);
if (callerSym is null)
{
continue;
}
var callerBinding = callerSym.Binding.ToString().ToLowerInvariant();
var targetBinding = targetSym.Binding.ToString().ToLowerInvariant();
var callerId = NativeGraphIdentifiers.ComputeSymbolId(callerSym.Name, callerSym.Value, callerSym.Size, callerBinding);
var calleeId = NativeGraphIdentifiers.ComputeSymbolId(targetSym.Name, targetSym.Value, targetSym.Size, targetBinding);
var calleeDigest = NativeGraphIdentifiers.ComputeSymbolDigest(targetSym.Name, targetSym.Value, targetSym.Size, targetBinding);
var edgeId = NativeGraphIdentifiers.ComputeEdgeId(callerId, calleeId, reloc.Offset);
// Determine if target is resolved (has a defined address)
var isResolved = targetSym.Value != 0 || targetSym.SectionIndex != 0;
var calleePurl = isResolved ? GeneratePurl(elf.Path, targetSym.Name) : null;
_edges.Add(new NativeCallEdge(
EdgeId: edgeId,
CallerId: callerId,
CalleeId: calleeId,
CalleePurl: calleePurl,
CalleeSymbolDigest: calleeDigest,
EdgeType: NativeEdgeType.Relocation,
CallSiteOffset: reloc.Offset,
IsResolved: isResolved,
Confidence: isResolved ? 1.0 : 0.5));
if (!isResolved)
{
// Emit unknown for unresolved external symbol
var unknownId = NativeGraphIdentifiers.ComputeUnknownId(edgeId, NativeUnknownType.UnresolvedTarget, targetSym.Name);
_unknowns.Add(new NativeUnknown(
UnknownId: unknownId,
UnknownType: NativeUnknownType.UnresolvedTarget,
SourceId: edgeId,
Name: targetSym.Name,
Reason: "External symbol not resolved within this layer",
BinaryPath: elf.Path));
}
}
}
private void AddInitArrayEdges(ElfFile elf)
{
var allSymbols = elf.Symbols.Concat(elf.DynamicSymbols).ToList();
// Add edges from synthetic _init root to init_array entries
var initSym = allSymbols.FirstOrDefault(s => s.Name == "_init" && s.Type == ElfSymbolType.Func);
if (initSym is not null)
{
var initBinding = initSym.Binding.ToString().ToLowerInvariant();
var initId = NativeGraphIdentifiers.ComputeSymbolId(initSym.Name, initSym.Value, initSym.Size, initBinding);
foreach (var (symName, idx) in elf.InitArraySymbols.Select((s, i) => (s, i)))
{
if (symName.StartsWith("func_0x", StringComparison.Ordinal))
{
continue; // Already handled as unknown
}
var targetSym = allSymbols.FirstOrDefault(s => s.Name == symName && s.Type == ElfSymbolType.Func);
if (targetSym is null)
{
continue;
}
var targetBinding = targetSym.Binding.ToString().ToLowerInvariant();
var targetId = NativeGraphIdentifiers.ComputeSymbolId(targetSym.Name, targetSym.Value, targetSym.Size, targetBinding);
var targetDigest = NativeGraphIdentifiers.ComputeSymbolDigest(targetSym.Name, targetSym.Value, targetSym.Size, targetBinding);
var edgeId = NativeGraphIdentifiers.ComputeEdgeId(initId, targetId, (ulong)idx);
_edges.Add(new NativeCallEdge(
EdgeId: edgeId,
CallerId: initId,
CalleeId: targetId,
CalleePurl: GeneratePurl(elf.Path, targetSym.Name),
CalleeSymbolDigest: targetDigest,
EdgeType: NativeEdgeType.InitArray,
CallSiteOffset: (ulong)idx,
IsResolved: true,
Confidence: 1.0));
}
}
}
private static ElfSymbol? FindFunctionContainingAddress(IList<ElfSymbol> symbols, ulong address)
{
return symbols
.Where(s => s.Type == ElfSymbolType.Func && s.Size > 0)
.FirstOrDefault(s => address >= s.Value && address < s.Value + s.Size);
}
private static string? GeneratePurl(string binaryPath, string symbolName)
{
// Extract library name from path (simplified)
var fileName = Path.GetFileName(binaryPath);
// Handle common patterns like libfoo.so.1.2.3
if (fileName.StartsWith("lib", StringComparison.Ordinal))
{
var soIndex = fileName.IndexOf(".so", StringComparison.Ordinal);
if (soIndex > 3)
{
var libName = fileName[3..soIndex];
return $"pkg:elf/{libName}#{symbolName}";
}
}
// For executables or other binaries
return $"pkg:elf/{fileName}#{symbolName}";
}
}

View File

@@ -0,0 +1,515 @@
using System.Buffers.Binary;
namespace StellaOps.Scanner.Analyzers.Native.Internal.Elf;
/// <summary>
/// Reads and parses ELF (Executable and Linkable Format) files.
/// Extracts build-id, symbols, relocations, and init arrays for reachability analysis.
/// </summary>
internal static class ElfReader
{
/// <summary>
/// Checks if a file starts with ELF magic bytes.
/// </summary>
public static bool IsElf(ReadOnlySpan<byte> data) =>
data.Length >= ElfMagic.IdentSize && data[..4].SequenceEqual(ElfMagic.Bytes);
/// <summary>
/// Parses an ELF file from a stream.
/// </summary>
public static ElfFile? Parse(Stream stream, string path, string layerDigest)
{
ArgumentNullException.ThrowIfNull(stream);
Span<byte> ident = stackalloc byte[ElfMagic.IdentSize];
if (stream.Read(ident) < ElfMagic.IdentSize || !IsElf(ident))
{
return null;
}
var elfClass = (ElfClass)ident[4];
var elfData = (ElfData)ident[5];
if (elfClass is not (ElfClass.Elf32 or ElfClass.Elf64))
{
return null;
}
var isLittleEndian = elfData == ElfData.Lsb;
var is64Bit = elfClass == ElfClass.Elf64;
stream.Position = 0;
var fileData = new byte[stream.Length];
stream.ReadExactly(fileData);
return Parse(fileData, path, layerDigest, is64Bit, isLittleEndian);
}
private static ElfFile Parse(byte[] data, string path, string layerDigest, bool is64Bit, bool isLittleEndian)
{
var reader = new ElfDataReader(data, isLittleEndian);
// Parse header
var header = ParseHeader(reader, is64Bit);
// Parse section headers
var sections = ParseSectionHeaders(reader, header, is64Bit);
// Get string table for section names
var shStrTab = GetStringTable(data, sections, header.SectionNameStringTableIndex);
// Update section names
sections = sections.Select(s => s with { Name = GetString(shStrTab, s.NameIndex) }).ToImmutableArray();
// Parse symbol tables
var (symbols, symStrTab) = ParseSymbolTable(data, sections, ".symtab", is64Bit, isLittleEndian);
var (dynSymbols, dynStrTab) = ParseSymbolTable(data, sections, ".dynsym", is64Bit, isLittleEndian);
// Update symbol names
symbols = symbols.Select(s => s with { Name = GetString(symStrTab, s.NameIndex) }).ToImmutableArray();
dynSymbols = dynSymbols.Select(s => s with { Name = GetString(dynStrTab, s.NameIndex) }).ToImmutableArray();
// Parse notes (for build-id)
var notes = ParseNotes(data, sections, isLittleEndian);
// Extract build-id from GNU notes
var buildId = ExtractBuildId(notes);
var codeId = buildId is not null ? FormatCodeId(buildId) : null;
// Compute .text section hash as fallback identifier
var textSectionHash = ComputeTextSectionHash(data, sections);
// Parse relocations
var relocations = ParseRelocations(data, sections, is64Bit, isLittleEndian);
// Extract init array symbols
var initArraySymbols = ExtractInitArraySymbols(data, sections, symbols, dynSymbols, is64Bit, isLittleEndian);
var preInitArraySymbols = ExtractPreInitArraySymbols(data, sections, symbols, dynSymbols, is64Bit, isLittleEndian);
// Extract needed libraries from .dynamic section
var neededLibraries = ExtractNeededLibraries(data, sections, is64Bit, isLittleEndian);
return new ElfFile(
path,
layerDigest,
header,
sections,
symbols,
dynSymbols,
notes,
relocations,
buildId,
codeId,
textSectionHash,
initArraySymbols,
preInitArraySymbols,
neededLibraries);
}
private static ElfHeader ParseHeader(ElfDataReader reader, bool is64Bit)
{
reader.Position = 0;
// Skip e_ident (already validated)
reader.Position = ElfMagic.IdentSize;
var type = (ElfType)reader.ReadUInt16();
var machine = (ElfMachine)reader.ReadUInt16();
var version = reader.ReadUInt32();
ulong entry, phOff, shOff;
if (is64Bit)
{
entry = reader.ReadUInt64();
phOff = reader.ReadUInt64();
shOff = reader.ReadUInt64();
}
else
{
entry = reader.ReadUInt32();
phOff = reader.ReadUInt32();
shOff = reader.ReadUInt32();
}
var flags = reader.ReadUInt32();
var ehSize = reader.ReadUInt16();
var phEntSize = reader.ReadUInt16();
var phNum = reader.ReadUInt16();
var shEntSize = reader.ReadUInt16();
var shNum = reader.ReadUInt16();
var shStrNdx = reader.ReadUInt16();
return new ElfHeader(
Class: is64Bit ? ElfClass.Elf64 : ElfClass.Elf32,
Data: reader.IsLittleEndian ? ElfData.Lsb : ElfData.Msb,
OsAbi: (ElfOsAbi)reader.Data[7],
Type: type,
Machine: machine,
EntryPoint: entry,
ProgramHeaderOffset: phOff,
SectionHeaderOffset: shOff,
ProgramHeaderEntrySize: phEntSize,
ProgramHeaderCount: phNum,
SectionHeaderEntrySize: shEntSize,
SectionHeaderCount: shNum,
SectionNameStringTableIndex: shStrNdx);
}
private static ImmutableArray<ElfSectionHeader> ParseSectionHeaders(ElfDataReader reader, ElfHeader header, bool is64Bit)
{
var sections = ImmutableArray.CreateBuilder<ElfSectionHeader>(header.SectionHeaderCount);
var entrySize = is64Bit ? 64 : 40;
for (var i = 0; i < header.SectionHeaderCount; i++)
{
reader.Position = (int)header.SectionHeaderOffset + i * entrySize;
var nameIndex = reader.ReadUInt32();
var type = (ElfSectionType)reader.ReadUInt32();
ulong flags, addr, offset, size;
uint link, info;
ulong addralign, entsize;
if (is64Bit)
{
flags = reader.ReadUInt64();
addr = reader.ReadUInt64();
offset = reader.ReadUInt64();
size = reader.ReadUInt64();
link = reader.ReadUInt32();
info = reader.ReadUInt32();
addralign = reader.ReadUInt64();
entsize = reader.ReadUInt64();
}
else
{
flags = reader.ReadUInt32();
addr = reader.ReadUInt32();
offset = reader.ReadUInt32();
size = reader.ReadUInt32();
link = reader.ReadUInt32();
info = reader.ReadUInt32();
addralign = reader.ReadUInt32();
entsize = reader.ReadUInt32();
}
sections.Add(new ElfSectionHeader(
nameIndex, string.Empty, type, flags, addr, offset, size, link, info, addralign, entsize));
}
return sections.ToImmutable();
}
private static (ImmutableArray<ElfSymbol> Symbols, byte[] StringTable) ParseSymbolTable(
byte[] data, ImmutableArray<ElfSectionHeader> sections, string tableName, bool is64Bit, bool isLittleEndian)
{
var symTab = sections.FirstOrDefault(s => s.Name == tableName);
if (symTab is null || symTab.Type is not (ElfSectionType.SymTab or ElfSectionType.DynSym))
{
return (ImmutableArray<ElfSymbol>.Empty, Array.Empty<byte>());
}
// Get associated string table
var strTab = sections.ElementAtOrDefault((int)symTab.Link);
var strTabData = strTab is not null
? data.AsSpan((int)strTab.Offset, (int)strTab.Size).ToArray()
: Array.Empty<byte>();
var entrySize = is64Bit ? 24 : 16;
var symbolCount = (int)(symTab.Size / (ulong)entrySize);
var symbols = ImmutableArray.CreateBuilder<ElfSymbol>(symbolCount);
var reader = new ElfDataReader(data, isLittleEndian) { Position = (int)symTab.Offset };
for (var i = 0; i < symbolCount; i++)
{
uint nameIdx;
ulong value, size;
byte info, other;
ushort shndx;
if (is64Bit)
{
nameIdx = reader.ReadUInt32();
info = reader.ReadByte();
other = reader.ReadByte();
shndx = reader.ReadUInt16();
value = reader.ReadUInt64();
size = reader.ReadUInt64();
}
else
{
nameIdx = reader.ReadUInt32();
value = reader.ReadUInt32();
size = reader.ReadUInt32();
info = reader.ReadByte();
other = reader.ReadByte();
shndx = reader.ReadUInt16();
}
var binding = (ElfSymbolBinding)(info >> 4);
var type = (ElfSymbolType)(info & 0xF);
var visibility = (ElfSymbolVisibility)(other & 0x3);
symbols.Add(new ElfSymbol(nameIdx, string.Empty, value, size, binding, type, visibility, shndx));
}
return (symbols.ToImmutable(), strTabData);
}
private static ImmutableArray<ElfNote> ParseNotes(byte[] data, ImmutableArray<ElfSectionHeader> sections, bool isLittleEndian)
{
var notes = ImmutableArray.CreateBuilder<ElfNote>();
foreach (var section in sections.Where(s => s.Type == ElfSectionType.Note))
{
var reader = new ElfDataReader(data, isLittleEndian) { Position = (int)section.Offset };
var end = (int)(section.Offset + section.Size);
while (reader.Position < end)
{
var namesz = reader.ReadUInt32();
var descsz = reader.ReadUInt32();
var type = (ElfGnuNoteType)reader.ReadUInt32();
var name = Encoding.ASCII.GetString(data, reader.Position, (int)namesz - 1);
reader.Position += Align4((int)namesz);
var desc = data.AsMemory(reader.Position, (int)descsz);
reader.Position += Align4((int)descsz);
notes.Add(new ElfNote(name, type, desc));
}
}
return notes.ToImmutable();
}
private static ImmutableArray<ElfRelocation> ParseRelocations(
byte[] data, ImmutableArray<ElfSectionHeader> sections, bool is64Bit, bool isLittleEndian)
{
var relocations = ImmutableArray.CreateBuilder<ElfRelocation>();
foreach (var section in sections.Where(s => s.Type is ElfSectionType.Rela or ElfSectionType.Rel))
{
var hasAddend = section.Type == ElfSectionType.Rela;
var entrySize = is64Bit ? (hasAddend ? 24 : 16) : (hasAddend ? 12 : 8);
var count = (int)(section.Size / (ulong)entrySize);
var reader = new ElfDataReader(data, isLittleEndian) { Position = (int)section.Offset };
for (var i = 0; i < count; i++)
{
ulong offset;
uint type, symIdx;
long addend = 0;
if (is64Bit)
{
offset = reader.ReadUInt64();
var info = reader.ReadUInt64();
type = (uint)(info & 0xFFFFFFFF);
symIdx = (uint)(info >> 32);
if (hasAddend) addend = reader.ReadInt64();
}
else
{
offset = reader.ReadUInt32();
var info = reader.ReadUInt32();
type = info & 0xFF;
symIdx = info >> 8;
if (hasAddend) addend = reader.ReadInt32();
}
relocations.Add(new ElfRelocation(offset, type, symIdx, addend));
}
}
return relocations.ToImmutable();
}
private static string? ExtractBuildId(ImmutableArray<ElfNote> notes)
{
var gnuBuildId = notes.FirstOrDefault(n => n.Name == "GNU" && n.Type == ElfGnuNoteType.BuildId);
if (gnuBuildId is null)
{
return null;
}
return Convert.ToHexString(gnuBuildId.Descriptor.Span).ToLowerInvariant();
}
private static string FormatCodeId(string buildId)
{
// Format as ELF code-id (same as build-id for ELF)
return buildId;
}
private static string ComputeTextSectionHash(byte[] data, ImmutableArray<ElfSectionHeader> sections)
{
var textSection = sections.FirstOrDefault(s => s.Name == ".text");
if (textSection is null || textSection.Size == 0)
{
return string.Empty;
}
var textData = data.AsSpan((int)textSection.Offset, (int)textSection.Size);
var hash = SHA256.HashData(textData);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static ImmutableArray<string> ExtractInitArraySymbols(
byte[] data, ImmutableArray<ElfSectionHeader> sections,
ImmutableArray<ElfSymbol> symbols, ImmutableArray<ElfSymbol> dynSymbols,
bool is64Bit, bool isLittleEndian)
{
return ExtractArraySymbols(data, sections, symbols, dynSymbols, ".init_array", is64Bit, isLittleEndian);
}
private static ImmutableArray<string> ExtractPreInitArraySymbols(
byte[] data, ImmutableArray<ElfSectionHeader> sections,
ImmutableArray<ElfSymbol> symbols, ImmutableArray<ElfSymbol> dynSymbols,
bool is64Bit, bool isLittleEndian)
{
return ExtractArraySymbols(data, sections, symbols, dynSymbols, ".preinit_array", is64Bit, isLittleEndian);
}
private static ImmutableArray<string> ExtractArraySymbols(
byte[] data, ImmutableArray<ElfSectionHeader> sections,
ImmutableArray<ElfSymbol> symbols, ImmutableArray<ElfSymbol> dynSymbols,
string sectionName, bool is64Bit, bool isLittleEndian)
{
var section = sections.FirstOrDefault(s => s.Name == sectionName);
if (section is null || section.Size == 0)
{
return ImmutableArray<string>.Empty;
}
var allSymbols = symbols.Concat(dynSymbols).ToList();
var ptrSize = is64Bit ? 8 : 4;
var count = (int)(section.Size / (ulong)ptrSize);
var result = ImmutableArray.CreateBuilder<string>(count);
var reader = new ElfDataReader(data, isLittleEndian) { Position = (int)section.Offset };
for (var i = 0; i < count; i++)
{
var addr = is64Bit ? reader.ReadUInt64() : reader.ReadUInt32();
var sym = allSymbols.FirstOrDefault(s => s.Value == addr && s.Type == ElfSymbolType.Func);
result.Add(sym?.Name ?? $"func_0x{addr:x}");
}
return result.ToImmutable();
}
private static ImmutableArray<string> ExtractNeededLibraries(
byte[] data, ImmutableArray<ElfSectionHeader> sections, bool is64Bit, bool isLittleEndian)
{
var dynSection = sections.FirstOrDefault(s => s.Name == ".dynamic");
if (dynSection is null)
{
return ImmutableArray<string>.Empty;
}
var dynStrSection = sections.FirstOrDefault(s => s.Name == ".dynstr");
if (dynStrSection is null)
{
return ImmutableArray<string>.Empty;
}
var strTab = data.AsSpan((int)dynStrSection.Offset, (int)dynStrSection.Size).ToArray();
var entrySize = is64Bit ? 16 : 8;
var count = (int)(dynSection.Size / (ulong)entrySize);
var result = ImmutableArray.CreateBuilder<string>();
var reader = new ElfDataReader(data, isLittleEndian) { Position = (int)dynSection.Offset };
const ulong DT_NEEDED = 1;
const ulong DT_NULL = 0;
for (var i = 0; i < count; i++)
{
var tag = is64Bit ? reader.ReadUInt64() : reader.ReadUInt32();
var val = is64Bit ? reader.ReadUInt64() : reader.ReadUInt32();
if (tag == DT_NULL) break;
if (tag == DT_NEEDED)
{
result.Add(GetString(strTab, (uint)val));
}
}
return result.ToImmutable();
}
private static byte[] GetStringTable(byte[] data, ImmutableArray<ElfSectionHeader> sections, ushort index)
{
if (index >= sections.Length) return Array.Empty<byte>();
var section = sections[index];
return data.AsSpan((int)section.Offset, (int)section.Size).ToArray();
}
private static string GetString(byte[] strTab, uint offset)
{
if (offset >= strTab.Length) return string.Empty;
var end = Array.IndexOf(strTab, (byte)0, (int)offset);
if (end < 0) end = strTab.Length;
return Encoding.UTF8.GetString(strTab, (int)offset, end - (int)offset);
}
private static int Align4(int value) => (value + 3) & ~3;
/// <summary>
/// Helper for reading binary data with endianness support.
/// </summary>
private sealed class ElfDataReader(byte[] data, bool isLittleEndian)
{
public byte[] Data { get; } = data;
public bool IsLittleEndian { get; } = isLittleEndian;
public int Position { get; set; }
public byte ReadByte() => Data[Position++];
public ushort ReadUInt16()
{
var value = IsLittleEndian
? BinaryPrimitives.ReadUInt16LittleEndian(Data.AsSpan(Position))
: BinaryPrimitives.ReadUInt16BigEndian(Data.AsSpan(Position));
Position += 2;
return value;
}
public uint ReadUInt32()
{
var value = IsLittleEndian
? BinaryPrimitives.ReadUInt32LittleEndian(Data.AsSpan(Position))
: BinaryPrimitives.ReadUInt32BigEndian(Data.AsSpan(Position));
Position += 4;
return value;
}
public ulong ReadUInt64()
{
var value = IsLittleEndian
? BinaryPrimitives.ReadUInt64LittleEndian(Data.AsSpan(Position))
: BinaryPrimitives.ReadUInt64BigEndian(Data.AsSpan(Position));
Position += 8;
return value;
}
public int ReadInt32()
{
var value = IsLittleEndian
? BinaryPrimitives.ReadInt32LittleEndian(Data.AsSpan(Position))
: BinaryPrimitives.ReadInt32BigEndian(Data.AsSpan(Position));
Position += 4;
return value;
}
public long ReadInt64()
{
var value = IsLittleEndian
? BinaryPrimitives.ReadInt64LittleEndian(Data.AsSpan(Position))
: BinaryPrimitives.ReadInt64BigEndian(Data.AsSpan(Position));
Position += 8;
return value;
}
}
}

View File

@@ -0,0 +1,220 @@
namespace StellaOps.Scanner.Analyzers.Native.Internal.Elf;
/// <summary>
/// ELF file class (32-bit or 64-bit).
/// </summary>
internal enum ElfClass : byte
{
None = 0,
Elf32 = 1,
Elf64 = 2,
}
/// <summary>
/// ELF data encoding (endianness).
/// </summary>
internal enum ElfData : byte
{
None = 0,
Lsb = 1, // Little-endian
Msb = 2, // Big-endian
}
/// <summary>
/// ELF OS/ABI.
/// </summary>
internal enum ElfOsAbi : byte
{
None = 0,
Linux = 3,
FreeBsd = 9,
}
/// <summary>
/// ELF file type.
/// </summary>
internal enum ElfType : ushort
{
None = 0,
Rel = 1, // Relocatable
Exec = 2, // Executable
Dyn = 3, // Shared object
Core = 4, // Core dump
}
/// <summary>
/// ELF machine architecture.
/// </summary>
internal enum ElfMachine : ushort
{
None = 0,
I386 = 3,
X86_64 = 62,
Arm = 40,
Aarch64 = 183,
RiscV = 243,
LoongArch = 258,
}
/// <summary>
/// ELF section type.
/// </summary>
internal enum ElfSectionType : uint
{
Null = 0,
ProgBits = 1,
SymTab = 2,
StrTab = 3,
Rela = 4,
Hash = 5,
Dynamic = 6,
Note = 7,
NoBits = 8,
Rel = 9,
ShLib = 10,
DynSym = 11,
InitArray = 14,
FiniArray = 15,
PreInitArray = 16,
Group = 17,
SymTabShndx = 18,
}
/// <summary>
/// ELF symbol binding.
/// </summary>
internal enum ElfSymbolBinding : byte
{
Local = 0,
Global = 1,
Weak = 2,
}
/// <summary>
/// ELF symbol type.
/// </summary>
internal enum ElfSymbolType : byte
{
NoType = 0,
Object = 1,
Func = 2,
Section = 3,
File = 4,
Common = 5,
Tls = 6,
}
/// <summary>
/// ELF symbol visibility.
/// </summary>
internal enum ElfSymbolVisibility : byte
{
Default = 0,
Internal = 1,
Hidden = 2,
Protected = 3,
}
/// <summary>
/// ELF note type for GNU notes.
/// </summary>
internal enum ElfGnuNoteType : uint
{
AbiTag = 1,
Hwcap = 2,
BuildId = 3,
GoldVersion = 4,
Property = 5,
}
/// <summary>
/// Parsed ELF header information.
/// </summary>
internal sealed record ElfHeader(
ElfClass Class,
ElfData Data,
ElfOsAbi OsAbi,
ElfType Type,
ElfMachine Machine,
ulong EntryPoint,
ulong ProgramHeaderOffset,
ulong SectionHeaderOffset,
ushort ProgramHeaderEntrySize,
ushort ProgramHeaderCount,
ushort SectionHeaderEntrySize,
ushort SectionHeaderCount,
ushort SectionNameStringTableIndex);
/// <summary>
/// Parsed ELF section header.
/// </summary>
internal sealed record ElfSectionHeader(
uint NameIndex,
string Name,
ElfSectionType Type,
ulong Flags,
ulong Address,
ulong Offset,
ulong Size,
uint Link,
uint Info,
ulong AddressAlign,
ulong EntrySize);
/// <summary>
/// Parsed ELF symbol.
/// </summary>
internal sealed record ElfSymbol(
uint NameIndex,
string Name,
ulong Value,
ulong Size,
ElfSymbolBinding Binding,
ElfSymbolType Type,
ElfSymbolVisibility Visibility,
ushort SectionIndex);
/// <summary>
/// Parsed ELF note.
/// </summary>
internal sealed record ElfNote(
string Name,
ElfGnuNoteType Type,
ReadOnlyMemory<byte> Descriptor);
/// <summary>
/// ELF relocation entry.
/// </summary>
internal sealed record ElfRelocation(
ulong Offset,
uint Type,
uint SymbolIndex,
long Addend);
/// <summary>
/// Parsed ELF file summary.
/// </summary>
internal sealed record ElfFile(
string Path,
string LayerDigest,
ElfHeader Header,
ImmutableArray<ElfSectionHeader> Sections,
ImmutableArray<ElfSymbol> Symbols,
ImmutableArray<ElfSymbol> DynamicSymbols,
ImmutableArray<ElfNote> Notes,
ImmutableArray<ElfRelocation> Relocations,
string? BuildId,
string? CodeId,
string TextSectionHash,
ImmutableArray<string> InitArraySymbols,
ImmutableArray<string> PreInitArraySymbols,
ImmutableArray<string> NeededLibraries);
/// <summary>
/// Magic bytes for ELF identification.
/// </summary>
internal static class ElfMagic
{
public static ReadOnlySpan<byte> Bytes => "\x7FELF"u8;
public const int IdentSize = 16;
}

View File

@@ -0,0 +1,300 @@
namespace StellaOps.Scanner.Analyzers.Native.Internal.Graph;
/// <summary>
/// Writes native reachability graphs as DSSE bundles (NDJSON format).
/// Per reachability spec: deterministic ordering, UTC timestamps, stable hashes.
/// </summary>
internal static class NativeGraphDsseWriter
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
};
/// <summary>
/// Writes the graph as NDJSON to a stream.
/// </summary>
public static async Task WriteNdjsonAsync(NativeReachabilityGraph graph, Stream stream, CancellationToken cancellationToken = default)
{
await using var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true);
// Write metadata header
var header = new NdjsonGraphHeader(
Type: "native.reachability.graph",
Version: "1.0.0",
LayerDigest: graph.LayerDigest,
ContentHash: graph.ContentHash,
GeneratedAt: graph.Metadata.GeneratedAt.ToString("O"),
GeneratorVersion: graph.Metadata.GeneratorVersion,
BinaryCount: graph.Metadata.BinaryCount,
FunctionCount: graph.Metadata.FunctionCount,
EdgeCount: graph.Metadata.EdgeCount,
UnknownCount: graph.Metadata.UnknownCount,
SyntheticRootCount: graph.Metadata.SyntheticRootCount);
await WriteLineAsync(writer, header, cancellationToken);
// Write functions (sorted by symbol_id for determinism)
foreach (var func in graph.Functions.OrderBy(f => f.SymbolId))
{
cancellationToken.ThrowIfCancellationRequested();
var record = new NdjsonFunctionRecord(
RecordType: "function",
SymbolId: func.SymbolId,
Name: func.Name,
Purl: func.Purl,
BinaryPath: func.BinaryPath,
BuildId: func.BuildId,
Address: $"0x{func.Address:x}",
Size: func.Size,
SymbolDigest: func.SymbolDigest,
Binding: func.Binding,
Visibility: func.Visibility,
IsExported: func.IsExported);
await WriteLineAsync(writer, record, cancellationToken);
}
// Write edges (sorted by edge_id for determinism)
foreach (var edge in graph.Edges.OrderBy(e => e.EdgeId))
{
cancellationToken.ThrowIfCancellationRequested();
var record = new NdjsonEdgeRecord(
RecordType: "edge",
EdgeId: edge.EdgeId,
CallerId: edge.CallerId,
CalleeId: edge.CalleeId,
CalleePurl: edge.CalleePurl,
CalleeSymbolDigest: edge.CalleeSymbolDigest,
EdgeType: edge.EdgeType.ToString().ToLowerInvariant(),
CallSiteOffset: $"0x{edge.CallSiteOffset:x}",
IsResolved: edge.IsResolved,
Confidence: edge.Confidence);
await WriteLineAsync(writer, record, cancellationToken);
}
// Write synthetic roots (sorted by root_id for determinism)
foreach (var root in graph.SyntheticRoots.OrderBy(r => r.RootId))
{
cancellationToken.ThrowIfCancellationRequested();
var record = new NdjsonRootRecord(
RecordType: "synthetic_root",
RootId: root.RootId,
TargetId: root.TargetId,
RootType: root.RootType.ToString().ToLowerInvariant(),
BinaryPath: root.BinaryPath,
Phase: root.Phase,
Order: root.Order);
await WriteLineAsync(writer, record, cancellationToken);
}
// Write unknowns (sorted by unknown_id for determinism)
foreach (var unknown in graph.Unknowns.OrderBy(u => u.UnknownId))
{
cancellationToken.ThrowIfCancellationRequested();
var record = new NdjsonUnknownRecord(
RecordType: "unknown",
UnknownId: unknown.UnknownId,
UnknownType: unknown.UnknownType.ToString().ToLowerInvariant(),
SourceId: unknown.SourceId,
Name: unknown.Name,
Reason: unknown.Reason,
BinaryPath: unknown.BinaryPath);
await WriteLineAsync(writer, record, cancellationToken);
}
await writer.FlushAsync(cancellationToken);
}
/// <summary>
/// Writes the graph as a JSON object (for DSSE payload).
/// </summary>
public static string WriteJson(NativeReachabilityGraph graph)
{
var payload = new NdjsonGraphPayload(
Type: "native.reachability.graph",
Version: "1.0.0",
LayerDigest: graph.LayerDigest,
ContentHash: graph.ContentHash,
Metadata: new NdjsonMetadataPayload(
GeneratedAt: graph.Metadata.GeneratedAt.ToString("O"),
GeneratorVersion: graph.Metadata.GeneratorVersion,
BinaryCount: graph.Metadata.BinaryCount,
FunctionCount: graph.Metadata.FunctionCount,
EdgeCount: graph.Metadata.EdgeCount,
UnknownCount: graph.Metadata.UnknownCount,
SyntheticRootCount: graph.Metadata.SyntheticRootCount),
Functions: graph.Functions.OrderBy(f => f.SymbolId).Select(f => new NdjsonFunctionPayload(
SymbolId: f.SymbolId,
Name: f.Name,
Purl: f.Purl,
BinaryPath: f.BinaryPath,
BuildId: f.BuildId,
Address: $"0x{f.Address:x}",
Size: f.Size,
SymbolDigest: f.SymbolDigest,
Binding: f.Binding,
Visibility: f.Visibility,
IsExported: f.IsExported)).ToArray(),
Edges: graph.Edges.OrderBy(e => e.EdgeId).Select(e => new NdjsonEdgePayload(
EdgeId: e.EdgeId,
CallerId: e.CallerId,
CalleeId: e.CalleeId,
CalleePurl: e.CalleePurl,
CalleeSymbolDigest: e.CalleeSymbolDigest,
EdgeType: e.EdgeType.ToString().ToLowerInvariant(),
CallSiteOffset: $"0x{e.CallSiteOffset:x}",
IsResolved: e.IsResolved,
Confidence: e.Confidence)).ToArray(),
SyntheticRoots: graph.SyntheticRoots.OrderBy(r => r.RootId).Select(r => new NdjsonRootPayload(
RootId: r.RootId,
TargetId: r.TargetId,
RootType: r.RootType.ToString().ToLowerInvariant(),
BinaryPath: r.BinaryPath,
Phase: r.Phase,
Order: r.Order)).ToArray(),
Unknowns: graph.Unknowns.OrderBy(u => u.UnknownId).Select(u => new NdjsonUnknownPayload(
UnknownId: u.UnknownId,
UnknownType: u.UnknownType.ToString().ToLowerInvariant(),
SourceId: u.SourceId,
Name: u.Name,
Reason: u.Reason,
BinaryPath: u.BinaryPath)).ToArray());
return JsonSerializer.Serialize(payload, JsonOptions);
}
private static async Task WriteLineAsync<T>(StreamWriter writer, T record, CancellationToken ct)
{
var json = JsonSerializer.Serialize(record, JsonOptions);
await writer.WriteLineAsync(json.AsMemory(), ct);
}
// NDJSON record types
private sealed record NdjsonGraphHeader(
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("layer_digest")] string LayerDigest,
[property: JsonPropertyName("content_hash")] string ContentHash,
[property: JsonPropertyName("generated_at")] string GeneratedAt,
[property: JsonPropertyName("generator_version")] string GeneratorVersion,
[property: JsonPropertyName("binary_count")] int BinaryCount,
[property: JsonPropertyName("function_count")] int FunctionCount,
[property: JsonPropertyName("edge_count")] int EdgeCount,
[property: JsonPropertyName("unknown_count")] int UnknownCount,
[property: JsonPropertyName("synthetic_root_count")] int SyntheticRootCount);
private sealed record NdjsonFunctionRecord(
[property: JsonPropertyName("record_type")] string RecordType,
[property: JsonPropertyName("symbol_id")] string SymbolId,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("purl")] string? Purl,
[property: JsonPropertyName("binary_path")] string BinaryPath,
[property: JsonPropertyName("build_id")] string? BuildId,
[property: JsonPropertyName("address")] string Address,
[property: JsonPropertyName("size")] ulong Size,
[property: JsonPropertyName("symbol_digest")] string SymbolDigest,
[property: JsonPropertyName("binding")] string Binding,
[property: JsonPropertyName("visibility")] string Visibility,
[property: JsonPropertyName("is_exported")] bool IsExported);
private sealed record NdjsonEdgeRecord(
[property: JsonPropertyName("record_type")] string RecordType,
[property: JsonPropertyName("edge_id")] string EdgeId,
[property: JsonPropertyName("caller_id")] string CallerId,
[property: JsonPropertyName("callee_id")] string CalleeId,
[property: JsonPropertyName("callee_purl")] string? CalleePurl,
[property: JsonPropertyName("callee_symbol_digest")] string? CalleeSymbolDigest,
[property: JsonPropertyName("edge_type")] string EdgeType,
[property: JsonPropertyName("call_site_offset")] string CallSiteOffset,
[property: JsonPropertyName("is_resolved")] bool IsResolved,
[property: JsonPropertyName("confidence")] double Confidence);
private sealed record NdjsonRootRecord(
[property: JsonPropertyName("record_type")] string RecordType,
[property: JsonPropertyName("root_id")] string RootId,
[property: JsonPropertyName("target_id")] string TargetId,
[property: JsonPropertyName("root_type")] string RootType,
[property: JsonPropertyName("binary_path")] string BinaryPath,
[property: JsonPropertyName("phase")] string Phase,
[property: JsonPropertyName("order")] int Order);
private sealed record NdjsonUnknownRecord(
[property: JsonPropertyName("record_type")] string RecordType,
[property: JsonPropertyName("unknown_id")] string UnknownId,
[property: JsonPropertyName("unknown_type")] string UnknownType,
[property: JsonPropertyName("source_id")] string SourceId,
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("reason")] string Reason,
[property: JsonPropertyName("binary_path")] string BinaryPath);
// JSON payload types (for DSSE envelope)
private sealed record NdjsonGraphPayload(
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("layer_digest")] string LayerDigest,
[property: JsonPropertyName("content_hash")] string ContentHash,
[property: JsonPropertyName("metadata")] NdjsonMetadataPayload Metadata,
[property: JsonPropertyName("functions")] NdjsonFunctionPayload[] Functions,
[property: JsonPropertyName("edges")] NdjsonEdgePayload[] Edges,
[property: JsonPropertyName("synthetic_roots")] NdjsonRootPayload[] SyntheticRoots,
[property: JsonPropertyName("unknowns")] NdjsonUnknownPayload[] Unknowns);
private sealed record NdjsonMetadataPayload(
[property: JsonPropertyName("generated_at")] string GeneratedAt,
[property: JsonPropertyName("generator_version")] string GeneratorVersion,
[property: JsonPropertyName("binary_count")] int BinaryCount,
[property: JsonPropertyName("function_count")] int FunctionCount,
[property: JsonPropertyName("edge_count")] int EdgeCount,
[property: JsonPropertyName("unknown_count")] int UnknownCount,
[property: JsonPropertyName("synthetic_root_count")] int SyntheticRootCount);
private sealed record NdjsonFunctionPayload(
[property: JsonPropertyName("symbol_id")] string SymbolId,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("purl")] string? Purl,
[property: JsonPropertyName("binary_path")] string BinaryPath,
[property: JsonPropertyName("build_id")] string? BuildId,
[property: JsonPropertyName("address")] string Address,
[property: JsonPropertyName("size")] ulong Size,
[property: JsonPropertyName("symbol_digest")] string SymbolDigest,
[property: JsonPropertyName("binding")] string Binding,
[property: JsonPropertyName("visibility")] string Visibility,
[property: JsonPropertyName("is_exported")] bool IsExported);
private sealed record NdjsonEdgePayload(
[property: JsonPropertyName("edge_id")] string EdgeId,
[property: JsonPropertyName("caller_id")] string CallerId,
[property: JsonPropertyName("callee_id")] string CalleeId,
[property: JsonPropertyName("callee_purl")] string? CalleePurl,
[property: JsonPropertyName("callee_symbol_digest")] string? CalleeSymbolDigest,
[property: JsonPropertyName("edge_type")] string EdgeType,
[property: JsonPropertyName("call_site_offset")] string CallSiteOffset,
[property: JsonPropertyName("is_resolved")] bool IsResolved,
[property: JsonPropertyName("confidence")] double Confidence);
private sealed record NdjsonRootPayload(
[property: JsonPropertyName("root_id")] string RootId,
[property: JsonPropertyName("target_id")] string TargetId,
[property: JsonPropertyName("root_type")] string RootType,
[property: JsonPropertyName("binary_path")] string BinaryPath,
[property: JsonPropertyName("phase")] string Phase,
[property: JsonPropertyName("order")] int Order);
private sealed record NdjsonUnknownPayload(
[property: JsonPropertyName("unknown_id")] string UnknownId,
[property: JsonPropertyName("unknown_type")] string UnknownType,
[property: JsonPropertyName("source_id")] string SourceId,
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("reason")] string Reason,
[property: JsonPropertyName("binary_path")] string BinaryPath);
}

View File

@@ -0,0 +1,293 @@
namespace StellaOps.Scanner.Analyzers.Native.Internal.Graph;
/// <summary>
/// Native reachability graph containing functions, call edges, and metadata.
/// Per SCAN-NATIVE-REACH-0146-13 requirements.
/// </summary>
public sealed record NativeReachabilityGraph(
string LayerDigest,
ImmutableArray<NativeFunctionNode> Functions,
ImmutableArray<NativeCallEdge> Edges,
ImmutableArray<NativeSyntheticRoot> SyntheticRoots,
ImmutableArray<NativeUnknown> Unknowns,
NativeGraphMetadata Metadata,
string ContentHash);
/// <summary>
/// A function node in the native call graph.
/// </summary>
/// <param name="SymbolId">Deterministic symbol identifier (sha256 of purl+name+binding).</param>
/// <param name="Name">Demangled or raw symbol name.</param>
/// <param name="Purl">Package URL if resolvable (e.g., pkg:elf/libc.so.6).</param>
/// <param name="BinaryPath">Path to the containing binary.</param>
/// <param name="BuildId">ELF build-id if available.</param>
/// <param name="Address">Virtual address of the function.</param>
/// <param name="Size">Size of the function in bytes.</param>
/// <param name="SymbolDigest">SHA-256 of (name + addr + size + binding).</param>
/// <param name="Binding">Symbol binding (local/global/weak).</param>
/// <param name="Visibility">Symbol visibility.</param>
/// <param name="IsExported">Whether the symbol is exported (visible externally).</param>
public sealed record NativeFunctionNode(
string SymbolId,
string Name,
string? Purl,
string BinaryPath,
string? BuildId,
ulong Address,
ulong Size,
string SymbolDigest,
string Binding,
string Visibility,
bool IsExported);
/// <summary>
/// A call edge in the native call graph.
/// </summary>
/// <param name="EdgeId">Deterministic edge identifier.</param>
/// <param name="CallerId">SymbolId of the calling function.</param>
/// <param name="CalleeId">SymbolId of the called function (or Unknown placeholder).</param>
/// <param name="CalleePurl">PURL of the callee if resolvable.</param>
/// <param name="CalleeSymbolDigest">Symbol digest of the callee.</param>
/// <param name="EdgeType">Type of edge (direct, plt, got, reloc).</param>
/// <param name="CallSiteOffset">Offset within caller where call occurs.</param>
/// <param name="IsResolved">Whether the callee was successfully resolved.</param>
/// <param name="Confidence">Confidence level (1.0 for resolved, lower for heuristic).</param>
public sealed record NativeCallEdge(
string EdgeId,
string CallerId,
string CalleeId,
string? CalleePurl,
string? CalleeSymbolDigest,
NativeEdgeType EdgeType,
ulong CallSiteOffset,
bool IsResolved,
double Confidence);
/// <summary>
/// Type of call edge.
/// </summary>
public enum NativeEdgeType
{
/// <summary>Direct function call.</summary>
Direct,
/// <summary>Call through PLT (Procedure Linkage Table).</summary>
Plt,
/// <summary>Call through GOT (Global Offset Table).</summary>
Got,
/// <summary>Relocation-based call.</summary>
Relocation,
/// <summary>Indirect call (target unknown).</summary>
Indirect,
/// <summary>Init/preinit array entry.</summary>
InitArray,
/// <summary>Fini array entry.</summary>
FiniArray,
}
/// <summary>
/// A synthetic root in the call graph (entry points that don't have callers).
/// </summary>
/// <param name="RootId">Deterministic root identifier.</param>
/// <param name="TargetId">SymbolId of the target function.</param>
/// <param name="RootType">Type of synthetic root.</param>
/// <param name="BinaryPath">Path to the containing binary.</param>
/// <param name="Phase">Execution phase (load, init, main, fini).</param>
/// <param name="Order">Order within the phase (for init arrays).</param>
public sealed record NativeSyntheticRoot(
string RootId,
string TargetId,
NativeRootType RootType,
string BinaryPath,
string Phase,
int Order);
/// <summary>
/// Type of synthetic root.
/// </summary>
public enum NativeRootType
{
/// <summary>_start entry point.</summary>
Start,
/// <summary>_init function.</summary>
Init,
/// <summary>.preinit_array entry.</summary>
PreInitArray,
/// <summary>.init_array entry.</summary>
InitArray,
/// <summary>.fini_array entry.</summary>
FiniArray,
/// <summary>_fini function.</summary>
Fini,
/// <summary>main function.</summary>
Main,
/// <summary>Constructor (C++).</summary>
Constructor,
/// <summary>Destructor (C++).</summary>
Destructor,
}
/// <summary>
/// An unknown/unresolved reference in the call graph.
/// Per docs/signals/unknowns-registry.md specification.
/// </summary>
/// <param name="UnknownId">Deterministic identifier.</param>
/// <param name="UnknownType">Type of unknown reference.</param>
/// <param name="SourceId">SymbolId or EdgeId that references this unknown.</param>
/// <param name="Name">Symbol name if available.</param>
/// <param name="Reason">Why resolution failed.</param>
/// <param name="BinaryPath">Binary where the reference occurs.</param>
public sealed record NativeUnknown(
string UnknownId,
NativeUnknownType UnknownType,
string SourceId,
string? Name,
string Reason,
string BinaryPath);
/// <summary>
/// Type of unknown reference.
/// </summary>
public enum NativeUnknownType
{
/// <summary>Symbol could not be resolved to a PURL.</summary>
UnresolvedPurl,
/// <summary>Call target could not be determined.</summary>
UnresolvedTarget,
/// <summary>Symbol hash could not be computed.</summary>
UnresolvedHash,
/// <summary>Binary could not be identified.</summary>
UnresolvedBinary,
/// <summary>Indirect call target is ambiguous.</summary>
AmbiguousTarget,
}
/// <summary>
/// Metadata for the native reachability graph.
/// </summary>
/// <param name="GeneratedAt">UTC timestamp of generation.</param>
/// <param name="GeneratorVersion">Version of the generator.</param>
/// <param name="LayerDigest">Digest of the layer.</param>
/// <param name="BinaryCount">Number of binaries analyzed.</param>
/// <param name="FunctionCount">Number of functions discovered.</param>
/// <param name="EdgeCount">Number of edges discovered.</param>
/// <param name="UnknownCount">Number of unknown references.</param>
/// <param name="SyntheticRootCount">Number of synthetic roots.</param>
public sealed record NativeGraphMetadata(
DateTimeOffset GeneratedAt,
string GeneratorVersion,
string LayerDigest,
int BinaryCount,
int FunctionCount,
int EdgeCount,
int UnknownCount,
int SyntheticRootCount);
/// <summary>
/// Helper methods for creating deterministic identifiers.
/// </summary>
internal static class NativeGraphIdentifiers
{
private const string GeneratorVersion = "1.0.0";
/// <summary>
/// Computes a deterministic symbol ID from name, address, size, and binding.
/// </summary>
public static string ComputeSymbolId(string name, ulong address, ulong size, string binding)
{
var input = $"{name}:{address:x}:{size}:{binding}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return $"sym:{Convert.ToHexString(hash[..8]).ToLowerInvariant()}";
}
/// <summary>
/// Computes a deterministic symbol digest.
/// </summary>
public static string ComputeSymbolDigest(string name, ulong address, ulong size, string binding)
{
var input = $"{name}:{address:x}:{size}:{binding}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(hash).ToLowerInvariant();
}
/// <summary>
/// Computes a deterministic edge ID.
/// </summary>
public static string ComputeEdgeId(string callerId, string calleeId, ulong callSiteOffset)
{
var input = $"{callerId}:{calleeId}:{callSiteOffset:x}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return $"edge:{Convert.ToHexString(hash[..8]).ToLowerInvariant()}";
}
/// <summary>
/// Computes a deterministic root ID.
/// </summary>
public static string ComputeRootId(string targetId, NativeRootType rootType, int order)
{
var input = $"{targetId}:{rootType}:{order}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return $"root:{Convert.ToHexString(hash[..8]).ToLowerInvariant()}";
}
/// <summary>
/// Computes a deterministic unknown ID.
/// </summary>
public static string ComputeUnknownId(string sourceId, NativeUnknownType unknownType, string? name)
{
var input = $"{sourceId}:{unknownType}:{name ?? ""}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return $"unk:{Convert.ToHexString(hash[..8]).ToLowerInvariant()}";
}
/// <summary>
/// Computes content hash for the entire graph.
/// </summary>
public static string ComputeGraphHash(
ImmutableArray<NativeFunctionNode> functions,
ImmutableArray<NativeCallEdge> edges,
ImmutableArray<NativeSyntheticRoot> roots)
{
using var sha = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
foreach (var f in functions.OrderBy(f => f.SymbolId))
{
sha.AppendData(Encoding.UTF8.GetBytes(f.SymbolId));
sha.AppendData(Encoding.UTF8.GetBytes(f.SymbolDigest));
}
foreach (var e in edges.OrderBy(e => e.EdgeId))
{
sha.AppendData(Encoding.UTF8.GetBytes(e.EdgeId));
}
foreach (var r in roots.OrderBy(r => r.RootId))
{
sha.AppendData(Encoding.UTF8.GetBytes(r.RootId));
}
return Convert.ToHexString(sha.GetCurrentHash()).ToLowerInvariant();
}
/// <summary>
/// Gets the current generator version.
/// </summary>
public static string GetGeneratorVersion() => GeneratorVersion;
}

View File

@@ -0,0 +1,248 @@
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
{
/// <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);
// 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);
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);
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;
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<EnableDefaultItems>false</EnableDefaultItems>
</PropertyGroup>
<ItemGroup>
<Compile Include="**\*.cs" Exclude="obj\**;bin\**" />
<EmbeddedResource Include="**\*.json" Exclude="obj\**;bin\**" />
<None Include="**\*" Exclude="**\*.cs;**\*.json;bin\**;obj\**" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" />
</ItemGroup>
</Project>