- 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.
1429 lines
52 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|