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";
}