Files
git.stella-ops.org/src/Scanner/__Libraries/StellaOps.Scanner.EntryTrace/EntryTraceAnalyzer.cs
master f98cea3bcf Add Authority Advisory AI and API Lifecycle Configuration
- Introduced AuthorityAdvisoryAiOptions and related classes for managing advisory AI configurations, including remote inference options and tenant-specific settings.
- Added AuthorityApiLifecycleOptions to control API lifecycle settings, including legacy OAuth endpoint configurations.
- Implemented validation and normalization methods for both advisory AI and API lifecycle options to ensure proper configuration.
- Created AuthorityNotificationsOptions and its related classes for managing notification settings, including ack tokens, webhooks, and escalation options.
- Developed IssuerDirectoryClient and related models for interacting with the issuer directory service, including caching mechanisms and HTTP client configurations.
- Added support for dependency injection through ServiceCollectionExtensions for the Issuer Directory Client.
- Updated project file to include necessary package references for the new Issuer Directory Client library.
2025-11-02 13:50:25 +02:00

1429 lines
52 KiB
C#

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<EntryTraceAnalyzer> _logger;
public EntryTraceAnalyzer(
IOptions<EntryTraceAnalyzerOptions> options,
EntryTraceMetrics metrics,
ILogger<EntryTraceAnalyzer> 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<EntryTraceGraph> 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<string> _pathEntries;
private readonly ImmutableArray<EntryTraceCandidate> _candidates;
private readonly List<EntryTraceNode> _nodes = new();
private readonly List<EntryTraceEdge> _edges = new();
private readonly List<EntryTraceDiagnostic> _diagnostics = new();
private readonly List<EntryTracePlan> _plans = new();
private readonly List<EntryTraceTerminal> _terminals = new();
private readonly HashSet<string> _terminalKeys = new(StringComparer.Ordinal);
private readonly HashSet<string> _visitedScripts = new(StringComparer.Ordinal);
private readonly HashSet<string> _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<string> 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<string>.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<string> 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<string>.Empty;
}
private void ResolveCommand(
ImmutableArray<string> 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<string>(), 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<string, string>
{
["command"] = commandName
});
return true;
}
descriptor = null!;
return false;
}
private bool TryFollowInterpreter(
EntryTraceNode node,
RootFileDescriptor descriptor,
ImmutableArray<string> 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<string> 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<string> 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<string, string>
{
["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<string>.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<string> 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<string>.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<string> 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<string>(), 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<string>.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<string, string>
{
["class"] = mainClass
}));
ClassifyTerminal(node, descriptor, arguments);
return true;
}
return false;
}
private enum ShellFlavor
{
None,
Posix,
Windows
}
private bool TryFollowShell(
EntryTraceNode node,
RootFileDescriptor descriptor,
ImmutableArray<string> 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<string> 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<string> 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<string>.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<string>.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<string> 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<string>.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<string> 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<string>.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<string>.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<string>.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<string> MaterializeArguments(ImmutableArray<ShellToken> tokens)
{
var builder = ImmutableArray.CreateBuilder<string>(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<string> arguments,
EntryTraceInterpreterKind interpreterKind,
EntryTraceEvidence? evidence,
EntryTraceSpan? span,
ImmutableDictionary<string, string>? 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<string> 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<string, string>(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<string>.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<string> 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<string> 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<byte> span, ImmutableDictionary<string, string>.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<byte> span, string value)
{
var bytes = Encoding.ASCII.GetBytes(value);
return span.IndexOf(bytes) >= 0;
}
private bool TryClassifyPe(ReadOnlySpan<byte> span, ImmutableDictionary<string, string>.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<byte> span, string path, ImmutableDictionary<string, string>.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<byte> 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<string>();
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);
}
}
}