using System.Collections.Generic; using System.Collections.Immutable; using System.IO; using System.IO.Compression; using System.Linq; using System.Text; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Scanner.EntryTrace.Diagnostics; using StellaOps.Scanner.EntryTrace.FileSystem; using StellaOps.Scanner.EntryTrace.Parsing; namespace StellaOps.Scanner.EntryTrace; public sealed class EntryTraceAnalyzer : IEntryTraceAnalyzer { private readonly EntryTraceAnalyzerOptions _options; private readonly EntryTraceMetrics _metrics; private readonly ILogger _logger; public EntryTraceAnalyzer( IOptions options, EntryTraceMetrics metrics, ILogger logger) { _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); _metrics = metrics ?? throw new ArgumentNullException(nameof(metrics)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); if (_options.MaxDepth <= 0) { _options.MaxDepth = 32; } if (string.IsNullOrWhiteSpace(_options.DefaultPath)) { _options.DefaultPath = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; } } public ValueTask ResolveAsync( EntrypointSpecification entrypoint, EntryTraceContext context, CancellationToken cancellationToken = default) { if (entrypoint is null) { throw new ArgumentNullException(nameof(entrypoint)); } if (context is null) { throw new ArgumentNullException(nameof(context)); } cancellationToken.ThrowIfCancellationRequested(); var builder = new Builder( entrypoint, context, _options, _metrics, _logger); var graph = builder.BuildGraph(); _metrics.RecordOutcome(context.ImageDigest, context.ScanId, graph.Outcome); foreach (var diagnostic in graph.Diagnostics) { _metrics.RecordUnknown(context.ImageDigest, context.ScanId, diagnostic.Reason); } return ValueTask.FromResult(graph); } private sealed class Builder { private readonly EntrypointSpecification _entrypoint; private readonly EntryTraceContext _context; private readonly EntryTraceAnalyzerOptions _options; private readonly EntryTraceMetrics _metrics; private readonly ILogger _logger; private readonly ImmutableArray _pathEntries; private readonly ImmutableArray _candidates; private readonly List _nodes = new(); private readonly List _edges = new(); private readonly List _diagnostics = new(); private readonly List _plans = new(); private readonly List _terminals = new(); private readonly HashSet _terminalKeys = new(StringComparer.Ordinal); private readonly HashSet _visitedScripts = new(StringComparer.Ordinal); private readonly HashSet _visitedCommands = new(StringComparer.Ordinal); private int _nextNodeId = 1; public Builder( EntrypointSpecification entrypoint, EntryTraceContext context, EntryTraceAnalyzerOptions options, EntryTraceMetrics metrics, ILogger logger) { _entrypoint = entrypoint; _context = context; _options = options; _metrics = metrics; _logger = logger; _pathEntries = DeterminePath(context); _candidates = context.Candidates; } private static ImmutableArray DeterminePath(EntryTraceContext context) { if (context.Path.Length > 0) { return context.Path; } if (context.Environment.TryGetValue("PATH", out var raw) && !string.IsNullOrWhiteSpace(raw)) { return raw.Split(':').Select(p => p.Trim()).Where(p => p.Length > 0).ToImmutableArray(); } return ImmutableArray.Empty; } public EntryTraceGraph BuildGraph() { var initialArgs = ComposeInitialCommand(_entrypoint); if (initialArgs.Length == 0) { if (_candidates.Length == 0) { _diagnostics.Add(new EntryTraceDiagnostic( EntryTraceDiagnosticSeverity.Warning, EntryTraceUnknownReason.CommandNotFound, "No ENTRYPOINT/CMD declared and no fallback candidates were discovered.", Span: null, RelatedPath: null)); return ToGraph(DetermineOutcome()); } foreach (var candidate in _candidates) { _diagnostics.Add(new EntryTraceDiagnostic( EntryTraceDiagnosticSeverity.Info, MapCandidateReason(candidate.Source), CreateCandidateMessage(candidate), Span: null, RelatedPath: candidate.Evidence?.Path)); ResolveCommand(candidate.Command, parent: null, originSpan: null, depth: 0, relationship: candidate.Source); } return ToGraph(DetermineOutcome()); } ResolveCommand(initialArgs, parent: null, originSpan: null, depth: 0, relationship: "entrypoint"); var outcome = DetermineOutcome(); return ToGraph(outcome); } private EntryTraceOutcome DetermineOutcome() { var hasErrors = _diagnostics.Any(d => d.Severity == EntryTraceDiagnosticSeverity.Error); if (hasErrors) { return EntryTraceOutcome.Unresolved; } var hasWarnings = _diagnostics.Any(d => d.Severity == EntryTraceDiagnosticSeverity.Warning); return hasWarnings ? EntryTraceOutcome.PartiallyResolved : EntryTraceOutcome.Resolved; } private EntryTraceGraph ToGraph(EntryTraceOutcome outcome) { return new EntryTraceGraph( outcome, _nodes.ToImmutableArray(), _edges.ToImmutableArray(), _diagnostics.ToImmutableArray(), _plans.ToImmutableArray(), _terminals.ToImmutableArray()); } private ImmutableArray ComposeInitialCommand(EntrypointSpecification specification) { if (specification.Entrypoint.Length > 0) { if (specification.Command.Length > 0) { return specification.Entrypoint.Concat(specification.Command).ToImmutableArray(); } return specification.Entrypoint; } if (specification.Command.Length > 0) { return specification.Command; } if (!string.IsNullOrWhiteSpace(specification.EntrypointShell)) { return ImmutableArray.Create("/bin/sh", "-c", specification.EntrypointShell!); } if (!string.IsNullOrWhiteSpace(specification.CommandShell)) { return ImmutableArray.Create("/bin/sh", "-c", specification.CommandShell!); } return ImmutableArray.Empty; } private void ResolveCommand( ImmutableArray arguments, EntryTraceNode? parent, EntryTraceSpan? originSpan, int depth, string relationship) { if (arguments.Length == 0) { return; } if (depth >= _options.MaxDepth) { _diagnostics.Add(new EntryTraceDiagnostic( EntryTraceDiagnosticSeverity.Warning, EntryTraceUnknownReason.RecursionLimitReached, $"Recursion depth limit {_options.MaxDepth} reached while resolving '{arguments[0]}'.", originSpan, RelatedPath: null)); return; } var commandName = arguments[0]; var evidence = default(EntryTraceEvidence?); var descriptor = default(RootFileDescriptor); if (!TryResolveExecutable(commandName, out descriptor, out evidence)) { _diagnostics.Add(new EntryTraceDiagnostic( EntryTraceDiagnosticSeverity.Warning, EntryTraceUnknownReason.CommandNotFound, $"Command '{commandName}' not found in PATH.", originSpan, RelatedPath: null)); return; } var node = AddNode( EntryTraceNodeKind.Command, commandName, arguments, DetermineInterpreterKind(descriptor), evidence, originSpan); if (parent is not null) { _edges.Add(new EntryTraceEdge(parent.Id, node.Id, relationship, Metadata: null)); } if (!_visitedCommands.Add(descriptor.Path)) { // Prevent infinite loops when scripts call themselves recursively. return; } if (TryFollowInterpreter(node, descriptor, arguments, depth)) { return; } if (TryFollowShell(node, descriptor, arguments, depth)) { return; } ClassifyTerminal(node, descriptor, arguments); // Terminal executable. } private bool TryResolveExecutable( string commandName, out RootFileDescriptor descriptor, out EntryTraceEvidence? evidence) { evidence = null; if (commandName.Contains('/', StringComparison.Ordinal)) { if (_context.FileSystem.TryReadAllText(commandName, out descriptor, out _)) { evidence = new EntryTraceEvidence(commandName, descriptor.LayerDigest, "path", null); return true; } if (_context.FileSystem.TryResolveExecutable(commandName, Array.Empty(), out descriptor)) { evidence = new EntryTraceEvidence(descriptor.Path, descriptor.LayerDigest, "path", null); return true; } } if (_context.FileSystem.TryResolveExecutable(commandName, _pathEntries, out descriptor)) { evidence = new EntryTraceEvidence(descriptor.Path, descriptor.LayerDigest, "path-search", new Dictionary { ["command"] = commandName }); return true; } descriptor = null!; return false; } private bool TryFollowInterpreter( EntryTraceNode node, RootFileDescriptor descriptor, ImmutableArray arguments, int depth) { var interpreter = DetermineInterpreterKind(descriptor); if (interpreter == EntryTraceInterpreterKind.None) { interpreter = DetectInterpreterFromCommand(arguments); } if (interpreter == EntryTraceInterpreterKind.None) { return false; } switch (interpreter) { case EntryTraceInterpreterKind.Python: return HandlePython(node, arguments, descriptor, depth); case EntryTraceInterpreterKind.Node: return HandleNode(node, arguments, descriptor, depth); case EntryTraceInterpreterKind.Java: return HandleJava(node, arguments, descriptor, depth); default: return false; } } private EntryTraceInterpreterKind DetermineInterpreterKind(RootFileDescriptor descriptor) { if (descriptor.ShebangInterpreter is null) { return EntryTraceInterpreterKind.None; } var shebang = descriptor.ShebangInterpreter.ToLowerInvariant(); if (shebang.Contains("python", StringComparison.Ordinal)) { return EntryTraceInterpreterKind.Python; } if (shebang.Contains("node", StringComparison.Ordinal)) { return EntryTraceInterpreterKind.Node; } if (shebang.Contains("java", StringComparison.Ordinal)) { return EntryTraceInterpreterKind.Java; } if (shebang.Contains("sh", StringComparison.Ordinal) || shebang.Contains("bash", StringComparison.Ordinal)) { return EntryTraceInterpreterKind.None; } return EntryTraceInterpreterKind.None; } private EntryTraceInterpreterKind DetectInterpreterFromCommand(ImmutableArray arguments) { if (arguments.Length == 0) { return EntryTraceInterpreterKind.None; } var command = arguments[0]; if (command.Equals("python", StringComparison.OrdinalIgnoreCase) || command.StartsWith("python", StringComparison.OrdinalIgnoreCase)) { return EntryTraceInterpreterKind.Python; } if (command.Equals("node", StringComparison.OrdinalIgnoreCase) || command.Equals("nodejs", StringComparison.OrdinalIgnoreCase)) { return EntryTraceInterpreterKind.Node; } if (command.Equals("java", StringComparison.OrdinalIgnoreCase)) { return EntryTraceInterpreterKind.Java; } return EntryTraceInterpreterKind.None; } private bool HandlePython( EntryTraceNode node, ImmutableArray arguments, RootFileDescriptor descriptor, int depth) { if (arguments.Length < 2) { return false; } var argIndex = 1; var moduleMode = false; string? moduleName = null; string? scriptPath = null; while (argIndex < arguments.Length) { var current = arguments[argIndex]; if (current == "-m" && argIndex + 1 < arguments.Length) { moduleMode = true; moduleName = arguments[argIndex + 1]; break; } if (!current.StartsWith("-", StringComparison.Ordinal)) { scriptPath = current; break; } argIndex++; } if (moduleMode && moduleName is not null) { _edges.Add(new EntryTraceEdge(node.Id, node.Id, "python-module", new Dictionary { ["module"] = moduleName })); return true; } if (scriptPath is null) { return false; } if (!_context.FileSystem.TryReadAllText(scriptPath, out var scriptDescriptor, out var content)) { _diagnostics.Add(new EntryTraceDiagnostic( EntryTraceDiagnosticSeverity.Warning, EntryTraceUnknownReason.MissingFile, $"Python script '{scriptPath}' was not found.", Span: null, RelatedPath: scriptPath)); return true; } var scriptNode = AddNode( EntryTraceNodeKind.Script, scriptPath, ImmutableArray.Empty, EntryTraceInterpreterKind.Python, new EntryTraceEvidence(scriptDescriptor.Path, scriptDescriptor.LayerDigest, "script", null), null); _edges.Add(new EntryTraceEdge(node.Id, scriptNode.Id, "executes", null)); if (IsLikelyShell(content)) { ResolveShellScript(content, scriptDescriptor.Path, scriptNode, depth + 1); } return true; } private bool HandleNode( EntryTraceNode node, ImmutableArray arguments, RootFileDescriptor descriptor, int depth) { if (arguments.Length < 2) { return false; } var scriptArg = arguments.Skip(1).FirstOrDefault(a => !a.StartsWith("-", StringComparison.Ordinal)); if (string.IsNullOrWhiteSpace(scriptArg)) { return false; } if (!_context.FileSystem.TryReadAllText(scriptArg, out var scriptDescriptor, out var content)) { _diagnostics.Add(new EntryTraceDiagnostic( EntryTraceDiagnosticSeverity.Warning, EntryTraceUnknownReason.MissingFile, $"Node script '{scriptArg}' was not found.", Span: null, RelatedPath: scriptArg)); return true; } var scriptNode = AddNode( EntryTraceNodeKind.Script, scriptArg, ImmutableArray.Empty, EntryTraceInterpreterKind.Node, new EntryTraceEvidence(scriptDescriptor.Path, scriptDescriptor.LayerDigest, "script", null), null); _edges.Add(new EntryTraceEdge(node.Id, scriptNode.Id, "executes", null)); return true; } private bool HandleJava( EntryTraceNode node, ImmutableArray arguments, RootFileDescriptor descriptor, int depth) { if (arguments.Length < 2) { return false; } string? jar = null; string? mainClass = null; for (var i = 1; i < arguments.Length; i++) { var arg = arguments[i]; if (arg == "-jar" && i + 1 < arguments.Length) { jar = arguments[i + 1]; break; } if (!arg.StartsWith("-", StringComparison.Ordinal) && mainClass is null) { mainClass = arg; } } if (jar is not null) { if (!_context.FileSystem.TryResolveExecutable(jar, _pathEntries, out var jarDescriptor) && !_context.FileSystem.TryResolveExecutable(jar, Array.Empty(), out jarDescriptor)) { _diagnostics.Add(new EntryTraceDiagnostic( EntryTraceDiagnosticSeverity.Warning, EntryTraceUnknownReason.JarNotFound, $"Java JAR '{jar}' not found.", Span: null, RelatedPath: jar)); return true; } var jarNode = AddNode( EntryTraceNodeKind.Executable, jarDescriptor.Path, ImmutableArray.Empty, EntryTraceInterpreterKind.Java, new EntryTraceEvidence(jarDescriptor.Path, jarDescriptor.LayerDigest, "jar", null), null); _edges.Add(new EntryTraceEdge(node.Id, jarNode.Id, "executes", null)); ClassifyTerminal(jarNode, jarDescriptor, arguments); return true; } if (mainClass is not null) { _edges.Add(new EntryTraceEdge(node.Id, node.Id, "java-main", new Dictionary { ["class"] = mainClass })); ClassifyTerminal(node, descriptor, arguments); return true; } return false; } private enum ShellFlavor { None, Posix, Windows } private bool TryFollowShell( EntryTraceNode node, RootFileDescriptor descriptor, ImmutableArray arguments, int depth) { var flavor = DetermineShellFlavor(descriptor, arguments); return flavor switch { ShellFlavor.Posix => HandlePosixShell(node, descriptor, arguments, depth), ShellFlavor.Windows => HandleWindowsShell(node, descriptor, arguments), _ => false }; } private static ShellFlavor DetermineShellFlavor(RootFileDescriptor descriptor, ImmutableArray arguments) { if (descriptor.ShebangInterpreter is { } shebang) { if (shebang.Contains("sh", StringComparison.OrdinalIgnoreCase) || shebang.Contains("bash", StringComparison.OrdinalIgnoreCase)) { return ShellFlavor.Posix; } if (shebang.Contains("cmd", StringComparison.OrdinalIgnoreCase) || shebang.Contains("powershell", StringComparison.OrdinalIgnoreCase)) { return ShellFlavor.Windows; } } if (arguments.IsDefaultOrEmpty) { return ShellFlavor.None; } var command = arguments[0]; if (IsPosixShellCommand(command)) { return ShellFlavor.Posix; } if (IsWindowsShellCommand(command)) { return ShellFlavor.Windows; } return ShellFlavor.None; } private static bool IsPosixShellCommand(string command) => command is "/bin/sh" or "sh" or "bash" or "/bin/bash"; private static bool IsWindowsShellCommand(string command) { if (string.IsNullOrWhiteSpace(command)) { return false; } var normalized = command.Trim().Trim('"'); return normalized.EndsWith("cmd.exe", StringComparison.OrdinalIgnoreCase) || normalized.Equals("cmd", StringComparison.OrdinalIgnoreCase) || normalized.EndsWith("powershell.exe", StringComparison.OrdinalIgnoreCase) || normalized.Equals("powershell", StringComparison.OrdinalIgnoreCase); } private bool HandlePosixShell( EntryTraceNode node, RootFileDescriptor descriptor, ImmutableArray arguments, int depth) { if (arguments.Length >= 2 && arguments[1] == "-c" && arguments.Length >= 3) { var scriptText = arguments[2]; ResolveShellScript(scriptText, descriptor.Path, node, depth + 1); return true; } if (arguments.Length >= 2) { var candidate = arguments[1]; if (_context.FileSystem.TryReadAllText(candidate, out var scriptDescriptor, out var content)) { var scriptNode = AddNode( EntryTraceNodeKind.Script, candidate, ImmutableArray.Empty, EntryTraceInterpreterKind.None, new EntryTraceEvidence(scriptDescriptor.Path, scriptDescriptor.LayerDigest, "script", null), null); _edges.Add(new EntryTraceEdge(node.Id, scriptNode.Id, "executes", null)); ResolveShellScript(content, scriptDescriptor.Path, scriptNode, depth + 1); return true; } } if (arguments.Length == 1) { if (_context.FileSystem.TryReadAllText(descriptor.Path, out var scriptDescriptor, out var content)) { var scriptNode = AddNode( EntryTraceNodeKind.Script, descriptor.Path, ImmutableArray.Empty, EntryTraceInterpreterKind.None, new EntryTraceEvidence(scriptDescriptor.Path, scriptDescriptor.LayerDigest, "script", null), null); _edges.Add(new EntryTraceEdge(node.Id, scriptNode.Id, "executes", null)); ResolveShellScript(content, scriptDescriptor.Path, scriptNode, depth + 1); return true; } } return false; } private bool HandleWindowsShell(EntryTraceNode node, RootFileDescriptor descriptor, ImmutableArray arguments) { var scriptIndex = FindWindowsScriptArgumentIndex(arguments); if (scriptIndex < 0) { return false; } var scriptCandidate = NormalizeWindowsScriptPath(arguments[scriptIndex]); if (string.IsNullOrWhiteSpace(scriptCandidate)) { return false; } if (!_context.FileSystem.TryReadAllText(scriptCandidate, out var scriptDescriptor, out _)) { _diagnostics.Add(new EntryTraceDiagnostic( EntryTraceDiagnosticSeverity.Warning, EntryTraceUnknownReason.MissingFile, $"Windows shell script '{scriptCandidate}' was not found.", Span: null, RelatedPath: scriptCandidate)); return true; } var scriptNode = AddNode( EntryTraceNodeKind.Script, scriptDescriptor.Path, ImmutableArray.Empty, EntryTraceInterpreterKind.None, new EntryTraceEvidence(scriptDescriptor.Path, scriptDescriptor.LayerDigest, "script", null), null); _edges.Add(new EntryTraceEdge(node.Id, scriptNode.Id, "executes", null)); _diagnostics.Add(new EntryTraceDiagnostic( EntryTraceDiagnosticSeverity.Info, EntryTraceUnknownReason.UnsupportedSyntax, $"Windows batch script '{scriptDescriptor.Path}' recorded without detailed parsing.", Span: null, RelatedPath: scriptDescriptor.Path)); return true; } private static int FindWindowsScriptArgumentIndex(ImmutableArray arguments) { if (arguments.Length <= 1) { return -1; } for (var i = 1; i < arguments.Length; i++) { var arg = arguments[i]; if (string.Equals(arg, "/c", StringComparison.OrdinalIgnoreCase) || string.Equals(arg, "-c", StringComparison.OrdinalIgnoreCase)) { return i + 1 < arguments.Length ? i + 1 : -1; } if (!arg.StartsWith("/", StringComparison.Ordinal) && !arg.StartsWith("-", StringComparison.Ordinal)) { return i; } } return -1; } private static string NormalizeWindowsScriptPath(string value) { var normalized = value.Trim().Trim('"').Replace('\\', '/'); if (string.IsNullOrWhiteSpace(normalized)) { return string.Empty; } return normalized.StartsWith("/", StringComparison.Ordinal) ? normalized : "/" + normalized.TrimStart('/'); } private void ResolveShellScript( string scriptContent, string scriptPath, EntryTraceNode parent, int depth) { if (_visitedScripts.Contains(scriptPath)) { return; } _visitedScripts.Add(scriptPath); ShellScript ast; try { ast = ShellParser.Parse(scriptContent); } catch (Exception ex) { _diagnostics.Add(new EntryTraceDiagnostic( EntryTraceDiagnosticSeverity.Warning, EntryTraceUnknownReason.UnsupportedSyntax, $"Failed to parse shell script '{scriptPath}': {ex.Message}", Span: null, RelatedPath: scriptPath)); return; } foreach (var node in ast.Nodes) { HandleShellNode(node, parent, scriptPath, depth); } } private void HandleShellNode( ShellNode node, EntryTraceNode parent, string scriptPath, int depth) { switch (node) { case ShellExecNode execNode: { var args = MaterializeArguments(execNode.Arguments); if (args.Length <= 1) { break; } var execArgs = args.RemoveAt(0); ResolveCommand(execArgs, parent, ToEntryTraceSpan(execNode.Span, scriptPath), depth + 1, "executes"); break; } case ShellIncludeNode includeNode: { var includeArg = includeNode.PathExpression; var includePath = ResolveScriptPath(scriptPath, includeArg); if (!_context.FileSystem.TryReadAllText(includePath, out var descriptor, out var content)) { _diagnostics.Add(new EntryTraceDiagnostic( EntryTraceDiagnosticSeverity.Warning, EntryTraceUnknownReason.MissingFile, $"Included script '{includePath}' not found.", ToEntryTraceSpan(includeNode.Span, scriptPath), includePath)); break; } var includeTraceNode = AddNode( EntryTraceNodeKind.Include, includePath, ImmutableArray.Empty, EntryTraceInterpreterKind.None, new EntryTraceEvidence(descriptor.Path, descriptor.LayerDigest, "include", null), ToEntryTraceSpan(includeNode.Span, scriptPath)); _edges.Add(new EntryTraceEdge(parent.Id, includeTraceNode.Id, "includes", null)); ResolveShellScript(content, descriptor.Path, includeTraceNode, depth + 1); break; } case ShellRunPartsNode runPartsNode when _options.FollowRunParts: { var directory = ResolveScriptPath(scriptPath, runPartsNode.DirectoryExpression); if (!_context.FileSystem.DirectoryExists(directory)) { _diagnostics.Add(new EntryTraceDiagnostic( EntryTraceDiagnosticSeverity.Warning, EntryTraceUnknownReason.MissingFile, $"run-parts directory '{directory}' not found.", ToEntryTraceSpan(runPartsNode.Span, scriptPath), directory)); break; } var entries = _context.FileSystem.EnumerateDirectory(directory) .Where(e => !e.IsDirectory && e.IsExecutable) .OrderBy(e => e.Path, StringComparer.Ordinal) .Take(_options.RunPartsLimit) .ToList(); if (entries.Count == 0) { _diagnostics.Add(new EntryTraceDiagnostic( EntryTraceDiagnosticSeverity.Info, EntryTraceUnknownReason.RunPartsEmpty, $"run-parts directory '{directory}' contained no executable files.", ToEntryTraceSpan(runPartsNode.Span, scriptPath), directory)); break; } var dirNode = AddNode( EntryTraceNodeKind.RunPartsDirectory, directory, ImmutableArray.Empty, EntryTraceInterpreterKind.None, new EntryTraceEvidence(directory, null, "run-parts", null), ToEntryTraceSpan(runPartsNode.Span, scriptPath)); _edges.Add(new EntryTraceEdge(parent.Id, dirNode.Id, "run-parts", null)); foreach (var entry in entries) { var childNode = AddNode( EntryTraceNodeKind.RunPartsScript, entry.Path, ImmutableArray.Empty, EntryTraceInterpreterKind.None, new EntryTraceEvidence(entry.Path, entry.LayerDigest, "run-parts", null), null); _edges.Add(new EntryTraceEdge(dirNode.Id, childNode.Id, "executes", null)); if (_context.FileSystem.TryReadAllText(entry.Path, out var childDescriptor, out var content)) { ResolveShellScript(content, childDescriptor.Path, childNode, depth + 1); } } break; } case ShellIfNode ifNode: { foreach (var branch in ifNode.Branches) { foreach (var inner in branch.Body) { HandleShellNode(inner, parent, scriptPath, depth + 1); } } break; } case ShellCaseNode caseNode: { foreach (var arm in caseNode.Arms) { foreach (var inner in arm.Body) { HandleShellNode(inner, parent, scriptPath, depth + 1); } } break; } case ShellCommandNode commandNode: { var args = MaterializeArguments(commandNode.Arguments); if (args.Length == 0) { break; } // Skip shell built-in wrappers. if (args[0] is "command" or "env") { var sliced = args.Skip(1).ToImmutableArray(); ResolveCommand(sliced, parent, ToEntryTraceSpan(commandNode.Span, scriptPath), depth + 1, "calls"); } else { ResolveCommand(args, parent, ToEntryTraceSpan(commandNode.Span, scriptPath), depth + 1, "calls"); } break; } default: break; } } private static EntryTraceSpan? ToEntryTraceSpan(ShellSpan span, string path) => new(path, span.StartLine, span.StartColumn, span.EndLine, span.EndColumn); private static ImmutableArray MaterializeArguments(ImmutableArray tokens) { var builder = ImmutableArray.CreateBuilder(tokens.Length); foreach (var token in tokens) { builder.Add(token.Value); } return builder.ToImmutable(); } private string ResolveScriptPath(string currentScript, string candidate) { if (string.IsNullOrWhiteSpace(candidate)) { return candidate; } if (candidate.StartsWith("/", StringComparison.Ordinal)) { return NormalizeUnixPath(candidate); } if (candidate.StartsWith("$", StringComparison.Ordinal)) { _diagnostics.Add(new EntryTraceDiagnostic( EntryTraceDiagnosticSeverity.Warning, EntryTraceUnknownReason.DynamicEnvironmentReference, $"Path '{candidate}' depends on environment variable expansion and cannot be resolved statically.", Span: null, RelatedPath: candidate)); return candidate; } var normalizedScript = NormalizeUnixPath(currentScript); var lastSlash = normalizedScript.LastIndexOf('/'); var baseDirectory = lastSlash <= 0 ? "/" : normalizedScript[..lastSlash]; return CombineUnixPath(baseDirectory, candidate); } private static bool IsLikelyShell(string content) { if (string.IsNullOrEmpty(content)) { return false; } if (content.StartsWith("#!", StringComparison.Ordinal)) { return content.Contains("sh", StringComparison.OrdinalIgnoreCase); } return content.Contains("#!/bin/sh", StringComparison.Ordinal); } private EntryTraceNode AddNode( EntryTraceNodeKind kind, string displayName, ImmutableArray arguments, EntryTraceInterpreterKind interpreterKind, EntryTraceEvidence? evidence, EntryTraceSpan? span, ImmutableDictionary? metadata = null) { var node = new EntryTraceNode( _nextNodeId++, kind, displayName, arguments, interpreterKind, evidence, span, metadata); _nodes.Add(node); return node; } private void ClassifyTerminal( EntryTraceNode node, RootFileDescriptor descriptor, ImmutableArray arguments) { var signature = CreateCommandSignature(arguments, node.DisplayName); var key = $"{descriptor.Path}|{_context.User}|{_context.WorkingDirectory}|{signature}"; if (!_terminalKeys.Add(key)) { return; } var evidence = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); double score = descriptor.IsExecutable ? 50d : 40d; string? runtime = null; var type = EntryTraceTerminalType.Unknown; if (!string.IsNullOrWhiteSpace(descriptor.ShebangInterpreter)) { var shebang = descriptor.ShebangInterpreter!; evidence["shebang"] = shebang; runtime = InferRuntimeFromShebang(shebang); type = EntryTraceTerminalType.Script; score += 15d; } if (_context.FileSystem.TryReadBytes(descriptor.Path, 2_097_152, out _, out var binaryContent)) { var span = binaryContent.Span; if (TryClassifyElf(span, evidence, ref runtime)) { type = EntryTraceTerminalType.Native; score += 15d; } else if (TryClassifyPe(span, evidence, ref runtime)) { type = EntryTraceTerminalType.Managed; score += 15d; } else if (IsZipArchive(span)) { runtime ??= "java"; type = EntryTraceTerminalType.Managed; score += 10d; if (TryReadJarManifest(span, descriptor.Path, evidence)) { score += 5d; } } } runtime ??= InferRuntimeFromCommand(node.DisplayName, arguments); if (runtime is "go" or "rust") { type = EntryTraceTerminalType.Native; score += 10d; } else if (runtime is ".net" or "java" or "python" or "node" or "ruby" or "php" or "php-fpm") { if (type == EntryTraceTerminalType.Unknown) { type = EntryTraceTerminalType.Managed; } score += 5d; } if (runtime is "shell") { type = EntryTraceTerminalType.Script; } if (type == EntryTraceTerminalType.Unknown) { type = EntryTraceTerminalType.Native; } var boundedScore = Math.Min(95d, score); var terminal = new EntryTraceTerminal( descriptor.Path, type, runtime, boundedScore, evidence.ToImmutable(), _context.User, _context.WorkingDirectory, arguments.IsDefault ? ImmutableArray.Empty : arguments); var plan = new EntryTracePlan( terminal.Arguments, _context.Environment, _context.WorkingDirectory, _context.User, terminal.Path, terminal.Type, terminal.Runtime, terminal.Confidence, terminal.Evidence); _terminals.Add(terminal); _plans.Add(plan); } private static string CreateCommandSignature(ImmutableArray command, string displayName) { if (command.IsDefaultOrEmpty || command.Length == 0) { return displayName; } return string.Join('\u001F', command); } private static EntryTraceUnknownReason MapCandidateReason(string source) => source switch { "history" => EntryTraceUnknownReason.InferredEntrypointFromHistory, "service-directory" => EntryTraceUnknownReason.InferredEntrypointFromServices, "supervisor" => EntryTraceUnknownReason.InferredEntrypointFromSupervisor, "entrypoint-script" => EntryTraceUnknownReason.InferredEntrypointFromEntrypointScript, _ => EntryTraceUnknownReason.CommandNotFound }; private static string CreateCandidateMessage(EntryTraceCandidate candidate) { var primary = candidate.Command.Length > 0 ? candidate.Command[0] : candidate.Source; return candidate.Source switch { "history" => "Inferred entrypoint from image history.", "service-directory" => $"Inferred service run script '{primary}'.", "supervisor" => candidate.Description is null ? "Inferred supervisor command." : $"Inferred supervisor program '{candidate.Description}'.", "entrypoint-script" => $"Inferred entrypoint script '{primary}'.", _ => "Inferred entrypoint candidate." }; } private static string? InferRuntimeFromShebang(string shebang) { var normalized = shebang.ToLowerInvariant(); if (normalized.Contains("python")) { return "python"; } if (normalized.Contains("node")) { return "node"; } if (normalized.Contains("ruby")) { return "ruby"; } if (normalized.Contains("php-fpm")) { return "php-fpm"; } if (normalized.Contains("php")) { return "php"; } if (normalized.Contains("sh") || normalized.Contains("bash")) { return "shell"; } return null; } private static string? InferRuntimeFromCommand(string commandName, ImmutableArray arguments) { var normalized = commandName.ToLowerInvariant(); if (normalized == "java" || arguments.Any(arg => arg.Equals("-jar", StringComparison.OrdinalIgnoreCase))) { return "java"; } if (normalized.Contains("dotnet") || normalized.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) { return ".net"; } if (normalized.Contains("python") || normalized.EndsWith(".py", StringComparison.OrdinalIgnoreCase)) { return "python"; } if (normalized.Contains("node") || normalized.EndsWith(".js", StringComparison.OrdinalIgnoreCase)) { return "node"; } if (normalized.Contains("go")) { return "go"; } return null; } private bool TryClassifyElf(ReadOnlySpan span, ImmutableDictionary.Builder evidence, ref string? runtime) { if (span.Length < 4 || span[0] != 0x7F || span[1] != (byte)'E' || span[2] != (byte)'L' || span[3] != (byte)'F') { return false; } evidence["binary.format"] = "ELF"; if (ContainsAscii(span, "Go build ID") || ContainsAscii(span, ".gopclntab")) { runtime = "go"; evidence["runtime"] = "go"; } else if (ContainsAscii(span, "rust_eh_personality") || ContainsAscii(span, ".rustc")) { runtime = "rust"; evidence["runtime"] = "rust"; } return true; } private static bool ContainsAscii(ReadOnlySpan span, string value) { var bytes = Encoding.ASCII.GetBytes(value); return span.IndexOf(bytes) >= 0; } private bool TryClassifyPe(ReadOnlySpan span, ImmutableDictionary.Builder evidence, ref string? runtime) { if (span.Length < 2 || span[0] != 'M' || span[1] != 'Z') { return false; } evidence["binary.format"] = "PE"; if (ContainsAscii(span, "BSJB") || ContainsAscii(span, "CLR")) { runtime = ".net"; evidence["pe.cli"] = "true"; } return true; } private bool TryReadJarManifest(ReadOnlySpan span, string path, ImmutableDictionary.Builder evidence) { try { using var stream = new MemoryStream(span.ToArray(), writable: false); using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: false); var manifestEntry = archive.GetEntry("META-INF/MANIFEST.MF"); if (manifestEntry is null) { return false; } using var reader = new StreamReader(manifestEntry.Open(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); var content = reader.ReadToEnd(); evidence["jar.manifest"] = "true"; foreach (var line in content.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) { var separator = line.IndexOf(':'); if (separator <= 0) { continue; } var key = line[..separator].Trim(); var value = line[(separator + 1)..].Trim(); if (key.Equals("Main-Class", StringComparison.OrdinalIgnoreCase)) { evidence["jar.main-class"] = value; } } return true; } catch (InvalidDataException ex) { _logger.LogDebug(ex, "Failed to read jar manifest for {JarPath}.", path); return false; } } private static bool IsZipArchive(ReadOnlySpan span) => span.Length >= 4 && span[0] == 0x50 && span[1] == 0x4B && span[2] == 0x03 && span[3] == 0x04; private static string CombineUnixPath(string baseDirectory, string relative) { var normalizedBase = NormalizeUnixPath(baseDirectory); var trimmedRelative = relative.Replace('\\', '/').Trim(); if (string.IsNullOrEmpty(trimmedRelative)) { return normalizedBase; } if (trimmedRelative.StartsWith('/')) { return NormalizeUnixPath(trimmedRelative); } if (!normalizedBase.EndsWith('/')) { normalizedBase += "/"; } return NormalizeUnixPath(normalizedBase + trimmedRelative); } private static string NormalizeUnixPath(string path) { if (string.IsNullOrWhiteSpace(path)) { return "/"; } var text = path.Replace('\\', '/').Trim(); if (!text.StartsWith('/')) { text = "/" + text; } var segments = new List(); foreach (var part in text.Split('/', StringSplitOptions.RemoveEmptyEntries)) { if (part == ".") { continue; } if (part == "..") { if (segments.Count > 0) { segments.RemoveAt(segments.Count - 1); } continue; } segments.Add(part); } return segments.Count == 0 ? "/" : "/" + string.Join('/', segments); } } }