using System; using System.Collections.Generic; using System.Collections.Immutable; using System.IO; using System.Linq; using System.Text.Json; using Microsoft.Extensions.Logging; using StellaOps.Scanner.EntryTrace.FileSystem; using StellaOps.Scanner.EntryTrace.Parsing; namespace StellaOps.Scanner.EntryTrace; /// /// Combines OCI configuration and root filesystem data into the context required by the EntryTrace analyzer. /// public static class EntryTraceImageContextFactory { private const string DefaultUser = "root"; public static EntryTraceImageContext Create( OciImageConfig config, IRootFileSystem fileSystem, EntryTraceAnalyzerOptions options, string imageDigest, string scanId, ILogger? logger = null) { ArgumentNullException.ThrowIfNull(config); ArgumentNullException.ThrowIfNull(fileSystem); ArgumentNullException.ThrowIfNull(options); ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest); ArgumentException.ThrowIfNullOrWhiteSpace(scanId); var environment = BuildEnvironment(config.Environment); var path = DeterminePath(environment, options); var workingDir = NormalizeWorkingDirectory(config.WorkingDirectory); var user = NormalizeUser(config.User); var context = new EntryTraceContext( fileSystem, environment, path, workingDir, user, imageDigest, scanId, logger); var candidates = BuildFallbackCandidates(config, fileSystem, logger); context = context with { Candidates = candidates }; var entrypoint = EntrypointSpecification.FromExecForm( config.Entrypoint.IsDefaultOrEmpty ? null : config.Entrypoint, config.Command.IsDefaultOrEmpty ? null : config.Command); return new EntryTraceImageContext(entrypoint, context); } private static ImmutableArray BuildFallbackCandidates( OciImageConfig config, IRootFileSystem fileSystem, ILogger? logger) { if (config.Entrypoint.Length > 0 || config.Command.Length > 0) { return ImmutableArray.Empty; } var builder = ImmutableArray.CreateBuilder(); var seen = new HashSet(StringComparer.Ordinal); void AddCandidate(ImmutableArray command, string source, EntryTraceEvidence? evidence, string? description) { if (command.IsDefaultOrEmpty || command.Length == 0) { return; } var signature = CreateSignature(command); if (!seen.Add(signature)) { return; } builder.Add(new EntryTraceCandidate(command, source, evidence, description)); } if (TryAddHistoryCandidate(config, AddCandidate, logger)) { // Preserve first viable history candidate only. } CollectEntrypointScripts(fileSystem, AddCandidate); CollectSupervisorCommands(fileSystem, AddCandidate); CollectServiceRunScripts(fileSystem, AddCandidate); return builder.ToImmutable(); } private static bool TryAddHistoryCandidate( OciImageConfig config, Action, string, EntryTraceEvidence?, string?> addCandidate, ILogger? logger) { if (config.History.IsDefaultOrEmpty || config.History.Length == 0) { return false; } for (var i = config.History.Length - 1; i >= 0; i--) { var entry = config.History[i]; if (TryExtractHistoryCommand(entry?.CreatedBy, out var command)) { var metadata = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); if (!string.IsNullOrWhiteSpace(entry?.CreatedBy)) { metadata["created_by"] = entry!.CreatedBy!; } var evidence = new EntryTraceEvidence( Path: "/image/history", LayerDigest: null, Source: "history", metadata.Count > 0 ? metadata.ToImmutable() : null); addCandidate(command, "history", evidence, null); return true; } } return false; } private static void CollectEntrypointScripts( IRootFileSystem fileSystem, Action, string, EntryTraceEvidence?, string?> addCandidate) { const string directory = "/usr/local/bin"; if (!fileSystem.DirectoryExists(directory)) { return; } foreach (var entry in fileSystem.EnumerateDirectory(directory)) { if (entry.IsDirectory || !entry.IsExecutable) { continue; } var name = Path.GetFileName(entry.Path); if (!name.Contains("entrypoint", StringComparison.OrdinalIgnoreCase)) { continue; } var evidence = new EntryTraceEvidence(entry.Path, entry.LayerDigest, "entrypoint-script", null); addCandidate(ImmutableArray.Create(entry.Path), "entrypoint-script", evidence, null); } } private static void CollectSupervisorCommands( IRootFileSystem fileSystem, Action, string, EntryTraceEvidence?, string?> addCandidate) { const string root = "/etc/supervisor"; if (!fileSystem.DirectoryExists(root)) { return; } var pending = new Stack(); pending.Push(root); while (pending.Count > 0) { var current = pending.Pop(); foreach (var entry in fileSystem.EnumerateDirectory(current)) { if (entry.IsDirectory) { pending.Push(entry.Path); continue; } if (!entry.Path.EndsWith(".conf", StringComparison.OrdinalIgnoreCase)) { continue; } if (!fileSystem.TryReadAllText(entry.Path, out var descriptor, out var content)) { continue; } foreach (var (command, program) in ExtractSupervisorCommands(content)) { var metadataBuilder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); if (!string.IsNullOrWhiteSpace(program)) { metadataBuilder["program"] = program!; } var evidence = new EntryTraceEvidence( entry.Path, descriptor.LayerDigest, "supervisor", metadataBuilder.Count > 0 ? metadataBuilder.ToImmutable() : null); addCandidate(command, "supervisor", evidence, program); } } } } private static void CollectServiceRunScripts( IRootFileSystem fileSystem, Action, string, EntryTraceEvidence?, string?> addCandidate) { string[] roots = { "/etc/services.d", "/etc/services", "/service", "/s6", "/etc/s6", "/etc/s6-overlay/s6-rc.d" }; foreach (var root in roots) { if (!fileSystem.DirectoryExists(root)) { continue; } var pending = new Stack(); pending.Push(root); while (pending.Count > 0) { var current = pending.Pop(); foreach (var entry in fileSystem.EnumerateDirectory(current)) { if (entry.IsDirectory) { pending.Push(entry.Path); continue; } if (!entry.IsExecutable) { continue; } if (!entry.Path.EndsWith("/run", StringComparison.OrdinalIgnoreCase)) { continue; } var metadata = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); var directory = Path.GetDirectoryName(entry.Path); if (!string.IsNullOrWhiteSpace(directory)) { metadata["service_dir"] = directory!.Replace('\\', '/'); } var evidence = new EntryTraceEvidence( entry.Path, entry.LayerDigest, "service-directory", metadata.Count > 0 ? metadata.ToImmutable() : null); addCandidate(ImmutableArray.Create(entry.Path), "service-directory", evidence, null); } } } } private static IEnumerable<(ImmutableArray Command, string? Program)> ExtractSupervisorCommands(string content) { var results = new List<(ImmutableArray, string?)>(); if (string.IsNullOrWhiteSpace(content)) { return results; } string? currentProgram = null; foreach (var rawLine in content.Split('\n')) { var line = rawLine.Trim(); if (line.Length == 0 || line.StartsWith("#", StringComparison.Ordinal)) { continue; } if (line.StartsWith("[", StringComparison.Ordinal) && line.EndsWith("]", StringComparison.Ordinal)) { var section = line[1..^1].Trim(); if (section.StartsWith("program:", StringComparison.OrdinalIgnoreCase)) { currentProgram = section["program:".Length..].Trim(); } else { currentProgram = null; } continue; } if (!line.StartsWith("command", StringComparison.OrdinalIgnoreCase)) { continue; } var equalsIndex = line.IndexOf('=', StringComparison.Ordinal); if (equalsIndex < 0) { continue; } var commandText = line[(equalsIndex + 1)..].Trim(); if (commandText.Length == 0) { continue; } if (TryTokenizeShellCommand(commandText, out var command)) { results.Add((command, currentProgram)); } } return results; } private static bool TryExtractHistoryCommand(string? createdBy, out ImmutableArray command) { command = ImmutableArray.Empty; if (string.IsNullOrWhiteSpace(createdBy)) { return false; } var text = createdBy.Trim(); if (TryParseHistoryJsonCommand(text, "CMD", out command) || TryParseHistoryJsonCommand(text, "ENTRYPOINT", out command)) { return true; } var shIndex = text.IndexOf("/bin/sh", StringComparison.OrdinalIgnoreCase); if (shIndex >= 0) { var dashC = text.IndexOf("-c", shIndex, StringComparison.OrdinalIgnoreCase); if (dashC >= 0) { var after = text[(dashC + 2)..].Trim(); if (after.StartsWith("#(nop)", StringComparison.OrdinalIgnoreCase)) { after = after[6..].Trim(); } if (after.StartsWith("CMD", StringComparison.OrdinalIgnoreCase)) { after = after[3..].Trim(); } else if (after.StartsWith("ENTRYPOINT", StringComparison.OrdinalIgnoreCase)) { after = after[10..].Trim(); } if (after.StartsWith("[", StringComparison.Ordinal)) { if (TryParseJsonArray(after, out command)) { return true; } } else if (TryTokenizeShellCommand(after, out command)) { return true; } } } return false; } private static bool TryParseHistoryJsonCommand(string text, string keyword, out ImmutableArray command) { command = ImmutableArray.Empty; var index = text.IndexOf(keyword, StringComparison.OrdinalIgnoreCase); if (index < 0) { return false; } var bracket = text.IndexOf('[', index); if (bracket < 0) { return false; } var end = text.IndexOf(']', bracket); if (end < 0) { return false; } var json = text.Substring(bracket, end - bracket + 1); return TryParseJsonArray(json, out command); } private static bool TryParseJsonArray(string json, out ImmutableArray command) { try { using var document = JsonDocument.Parse(json); if (document.RootElement.ValueKind != JsonValueKind.Array) { command = ImmutableArray.Empty; return false; } var builder = ImmutableArray.CreateBuilder(); foreach (var element in document.RootElement.EnumerateArray()) { if (element.ValueKind == JsonValueKind.String) { builder.Add(element.GetString() ?? string.Empty); } } command = builder.ToImmutable(); return command.Length > 0; } catch (JsonException) { command = ImmutableArray.Empty; return false; } } private static bool TryTokenizeShellCommand(string commandText, out ImmutableArray command) { var tokenizer = new ShellTokenizer(); var tokens = tokenizer.Tokenize(commandText); var builder = ImmutableArray.CreateBuilder(); foreach (var token in tokens) { switch (token.Kind) { case ShellTokenKind.Word: case ShellTokenKind.SingleQuoted: case ShellTokenKind.DoubleQuoted: builder.Add(token.Value); break; case ShellTokenKind.Operator: case ShellTokenKind.NewLine: case ShellTokenKind.EndOfFile: if (builder.Count > 0) { command = builder.ToImmutable(); return command.Length > 0; } break; } } command = builder.ToImmutable(); return command.Length > 0; } private static string CreateSignature(ImmutableArray command) => string.Join('\u001F', command); private static ImmutableDictionary BuildEnvironment(ImmutableArray raw) { if (raw.IsDefaultOrEmpty) { return ImmutableDictionary.Empty; } var dictionary = new Dictionary(StringComparer.Ordinal); foreach (var entry in raw) { if (string.IsNullOrWhiteSpace(entry)) { continue; } var separatorIndex = entry.IndexOf('='); if (separatorIndex < 0) { var key = entry.Trim(); if (key.Length > 0) { dictionary[key] = string.Empty; } continue; } var keyPart = entry[..separatorIndex].Trim(); if (keyPart.Length == 0) { continue; } var valuePart = entry[(separatorIndex + 1)..]; dictionary[keyPart] = valuePart; } return ImmutableDictionary.CreateRange(StringComparer.Ordinal, dictionary); } private static ImmutableArray DeterminePath(ImmutableDictionary env, EntryTraceAnalyzerOptions options) { if (env.TryGetValue("PATH", out var pathValue) && !string.IsNullOrWhiteSpace(pathValue)) { return SplitPath(pathValue); } var fallback = string.IsNullOrWhiteSpace(options.DefaultPath) ? EntryTraceDefaults.DefaultPath : options.DefaultPath; return SplitPath(fallback); } private static string NormalizeWorkingDirectory(string? workingDir) { if (string.IsNullOrWhiteSpace(workingDir)) { return "/"; } var text = workingDir.Replace('\\', '/').Trim(); if (!text.StartsWith("/", StringComparison.Ordinal)) { text = "/" + text; } if (text.Length > 1 && text.EndsWith("/", StringComparison.Ordinal)) { text = text.TrimEnd('/'); } return text.Length == 0 ? "/" : text; } private static string NormalizeUser(string? user) { if (string.IsNullOrWhiteSpace(user)) { return DefaultUser; } return user.Trim(); } private static ImmutableArray SplitPath(string value) { if (string.IsNullOrWhiteSpace(value)) { return ImmutableArray.Empty; } var builder = ImmutableArray.CreateBuilder(); foreach (var segment in value.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { if (segment.Length == 0) { continue; } var normalized = segment.Replace('\\', '/'); if (!normalized.StartsWith("/", StringComparison.Ordinal)) { normalized = "/" + normalized; } if (normalized.EndsWith("/", StringComparison.Ordinal) && normalized.Length > 1) { normalized = normalized.TrimEnd('/'); } builder.Add(normalized); } return builder.ToImmutable(); } } /// /// Bundles the resolved entrypoint and context required for the analyzer to operate. /// public sealed record EntryTraceImageContext( EntrypointSpecification Entrypoint, EntryTraceContext Context); internal static class EntryTraceDefaults { public const string DefaultPath = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; }