Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
609 lines
19 KiB
C#
609 lines
19 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Combines OCI configuration and root filesystem data into the context required by the EntryTrace analyzer.
|
|
/// </summary>
|
|
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<EntryTraceCandidate> BuildFallbackCandidates(
|
|
OciImageConfig config,
|
|
IRootFileSystem fileSystem,
|
|
ILogger? logger)
|
|
{
|
|
if (config.Entrypoint.Length > 0 || config.Command.Length > 0)
|
|
{
|
|
return ImmutableArray<EntryTraceCandidate>.Empty;
|
|
}
|
|
|
|
var builder = ImmutableArray.CreateBuilder<EntryTraceCandidate>();
|
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
|
|
|
void AddCandidate(ImmutableArray<string> 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<ImmutableArray<string>, 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<string, string>(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<ImmutableArray<string>, 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<ImmutableArray<string>, string, EntryTraceEvidence?, string?> addCandidate)
|
|
{
|
|
const string root = "/etc/supervisor";
|
|
if (!fileSystem.DirectoryExists(root))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var pending = new Stack<string>();
|
|
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<string, string>(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<ImmutableArray<string>, 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<string>();
|
|
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<string, string>(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<string> Command, string? Program)> ExtractSupervisorCommands(string content)
|
|
{
|
|
var results = new List<(ImmutableArray<string>, 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<string> command)
|
|
{
|
|
command = ImmutableArray<string>.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<string> command)
|
|
{
|
|
command = ImmutableArray<string>.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<string> command)
|
|
{
|
|
try
|
|
{
|
|
using var document = JsonDocument.Parse(json);
|
|
if (document.RootElement.ValueKind != JsonValueKind.Array)
|
|
{
|
|
command = ImmutableArray<string>.Empty;
|
|
return false;
|
|
}
|
|
|
|
var builder = ImmutableArray.CreateBuilder<string>();
|
|
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<string>.Empty;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static bool TryTokenizeShellCommand(string commandText, out ImmutableArray<string> command)
|
|
{
|
|
var tokenizer = new ShellTokenizer();
|
|
var tokens = tokenizer.Tokenize(commandText);
|
|
var builder = ImmutableArray.CreateBuilder<string>();
|
|
|
|
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<string> command)
|
|
=> string.Join('\u001F', command);
|
|
|
|
private static ImmutableDictionary<string, string> BuildEnvironment(ImmutableArray<string> raw)
|
|
{
|
|
if (raw.IsDefaultOrEmpty)
|
|
{
|
|
return ImmutableDictionary<string, string>.Empty;
|
|
}
|
|
|
|
var dictionary = new Dictionary<string, string>(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<string> DeterminePath(ImmutableDictionary<string, string> 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<string> SplitPath(string value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return ImmutableArray<string>.Empty;
|
|
}
|
|
|
|
var builder = ImmutableArray.CreateBuilder<string>();
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bundles the resolved entrypoint and context required for the analyzer to operate.
|
|
/// </summary>
|
|
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";
|
|
}
|