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.
This commit is contained in:
master
2025-11-02 13:40:38 +02:00
parent 66cb6c4b8a
commit f98cea3bcf
516 changed files with 68157 additions and 24754 deletions

View File

@@ -1,11 +1,14 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.EntryTrace.Diagnostics;
using StellaOps.Scanner.EntryTrace.Parsing;
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;
@@ -73,13 +76,17 @@ public sealed class EntryTraceAnalyzer : IEntryTraceAnalyzer
{
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 List<EntryTraceNode> _nodes = new();
private readonly List<EntryTraceEdge> _edges = new();
private readonly List<EntryTraceDiagnostic> _diagnostics = new();
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;
@@ -89,15 +96,16 @@ public sealed class EntryTraceAnalyzer : IEntryTraceAnalyzer
EntryTraceContext context,
EntryTraceAnalyzerOptions options,
EntryTraceMetrics metrics,
ILogger logger)
{
_entrypoint = entrypoint;
_context = context;
_options = options;
_metrics = metrics;
_logger = logger;
_pathEntries = DeterminePath(context);
}
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)
{
@@ -114,46 +122,65 @@ public sealed class EntryTraceAnalyzer : IEntryTraceAnalyzer
return ImmutableArray<string>.Empty;
}
public EntryTraceGraph BuildGraph()
{
var initialArgs = ComposeInitialCommand(_entrypoint);
if (initialArgs.Length == 0)
{
_diagnostics.Add(new EntryTraceDiagnostic(
EntryTraceDiagnosticSeverity.Error,
EntryTraceUnknownReason.CommandNotFound,
"ENTRYPOINT/CMD yielded no executable command.",
Span: null,
RelatedPath: null));
return ToGraph(EntryTraceOutcome.Unresolved);
}
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;
}
ResolveCommand(initialArgs, parent: null, originSpan: null, depth: 0, relationship: "entrypoint");
var outcome = DetermineOutcome();
return ToGraph(outcome);
}
private EntryTraceOutcome DetermineOutcome()
{
if (_diagnostics.Count == 0)
{
return EntryTraceOutcome.Resolved;
}
return _diagnostics.Any(d => d.Severity == EntryTraceDiagnosticSeverity.Error)
? EntryTraceOutcome.Unresolved
: EntryTraceOutcome.PartiallyResolved;
}
private EntryTraceGraph ToGraph(EntryTraceOutcome outcome)
{
return new EntryTraceGraph(
outcome,
_nodes.ToImmutableArray(),
_edges.ToImmutableArray(),
_diagnostics.ToImmutableArray());
}
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)
{
@@ -185,17 +212,17 @@ public sealed class EntryTraceAnalyzer : IEntryTraceAnalyzer
return ImmutableArray<string>.Empty;
}
private void ResolveCommand(
ImmutableArray<string> arguments,
EntryTraceNode? parent,
EntryTraceSpan? originSpan,
int depth,
string relationship)
{
if (arguments.Length == 0)
{
return;
}
private void ResolveCommand(
ImmutableArray<string> arguments,
EntryTraceNode? parent,
EntryTraceSpan? originSpan,
int depth,
string relationship)
{
if (arguments.Length == 0)
{
return;
}
if (depth >= _options.MaxDepth)
{
@@ -242,18 +269,19 @@ public sealed class EntryTraceAnalyzer : IEntryTraceAnalyzer
return;
}
if (TryFollowInterpreter(node, descriptor, arguments, depth))
{
return;
}
if (TryFollowShell(node, descriptor, arguments, depth))
{
return;
}
// Terminal executable.
}
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,
@@ -497,16 +525,16 @@ public sealed class EntryTraceAnalyzer : IEntryTraceAnalyzer
return true;
}
private bool HandleJava(
EntryTraceNode node,
ImmutableArray<string> arguments,
RootFileDescriptor descriptor,
int depth)
{
if (arguments.Length < 2)
{
return false;
}
private bool HandleJava(
EntryTraceNode node,
ImmutableArray<string> arguments,
RootFileDescriptor descriptor,
int depth)
{
if (arguments.Length < 2)
{
return false;
}
string? jar = null;
string? mainClass = null;
@@ -526,40 +554,42 @@ public sealed class EntryTraceAnalyzer : IEntryTraceAnalyzer
}
}
if (jar is not null)
{
if (!_context.FileSystem.TryResolveExecutable(jar, Array.Empty<string>(), out var jarDescriptor))
{
_diagnostics.Add(new EntryTraceDiagnostic(
EntryTraceDiagnosticSeverity.Warning,
EntryTraceUnknownReason.JarNotFound,
$"Java JAR '{jar}' not found.",
Span: null,
RelatedPath: jar));
}
else
{
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));
}
return true;
}
if (mainClass is not null)
{
_edges.Add(new EntryTraceEdge(node.Id, node.Id, "java-main", new Dictionary<string, string>
{
["class"] = mainClass
}));
return true;
}
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;
}
@@ -1018,26 +1048,325 @@ public sealed class EntryTraceAnalyzer : IEntryTraceAnalyzer
return content.Contains("#!/bin/sh", StringComparison.Ordinal);
}
private EntryTraceNode AddNode(
EntryTraceNodeKind kind,
string displayName,
ImmutableArray<string> arguments,
EntryTraceInterpreterKind interpreterKind,
EntryTraceEvidence? evidence,
EntryTraceSpan? span)
{
var node = new EntryTraceNode(
_nextNodeId++,
kind,
displayName,
arguments,
interpreterKind,
evidence,
span);
_nodes.Add(node);
return node;
}
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);

View File

@@ -0,0 +1,8 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.EntryTrace;
public sealed record EntryTraceCacheEnvelope(
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("options")] string OptionsFingerprint,
[property: JsonPropertyName("graph")] EntryTraceGraph Graph);

View File

@@ -0,0 +1,35 @@
using System;
using System.Text.Json;
namespace StellaOps.Scanner.EntryTrace;
internal static class EntryTraceCacheSerializer
{
public const string CurrentVersion = "entrytrace.v1";
public static byte[] Serialize(EntryTraceCacheEnvelope envelope)
{
if (envelope is null)
{
throw new ArgumentNullException(nameof(envelope));
}
return JsonSerializer.SerializeToUtf8Bytes(envelope);
}
public static EntryTraceCacheEnvelope Deserialize(byte[] payload)
{
if (payload is null || payload.Length == 0)
{
throw new ArgumentException("Payload cannot be empty.", nameof(payload));
}
var envelope = JsonSerializer.Deserialize<EntryTraceCacheEnvelope>(payload);
if (envelope is null)
{
throw new InvalidOperationException("Failed to deserialize entry trace cache envelope.");
}
return envelope;
}
}

View File

@@ -1,7 +1,8 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.EntryTrace;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.EntryTrace.FileSystem;
namespace StellaOps.Scanner.EntryTrace;
/// <summary>
/// Provides runtime context for entry trace analysis.
@@ -14,4 +15,7 @@ public sealed record EntryTraceContext(
string User,
string ImageDigest,
string ScanId,
ILogger? Logger);
ILogger? Logger)
{
public ImmutableArray<EntryTraceCandidate> Candidates { get; init; } = ImmutableArray<EntryTraceCandidate>.Empty;
}

View File

@@ -1,9 +1,14 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.EntryTrace;
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.
@@ -31,22 +36,447 @@ public static class EntryTraceImageContextFactory
var workingDir = NormalizeWorkingDirectory(config.WorkingDirectory);
var user = NormalizeUser(config.User);
var context = new EntryTraceContext(
fileSystem,
environment,
path,
workingDir,
user,
imageDigest,
scanId,
logger);
var entrypoint = EntrypointSpecification.FromExecForm(
config.Entrypoint.IsDefaultOrEmpty ? null : config.Entrypoint,
config.Command.IsDefaultOrEmpty ? null : config.Command);
return new EntryTraceImageContext(entrypoint, context);
}
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)
{

View File

@@ -0,0 +1,32 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.EntryTrace;
public sealed record EntryTraceResult(
string ScanId,
string ImageDigest,
DateTimeOffset GeneratedAtUtc,
EntryTraceGraph Graph,
ImmutableArray<string> Ndjson);
public interface IEntryTraceResultStore
{
Task StoreAsync(EntryTraceResult result, CancellationToken cancellationToken);
Task<EntryTraceResult?> GetAsync(string scanId, CancellationToken cancellationToken);
}
internal sealed class NullEntryTraceResultStore : IEntryTraceResultStore
{
public Task StoreAsync(EntryTraceResult result, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(result);
return Task.CompletedTask;
}
public Task<EntryTraceResult?> GetAsync(string scanId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
return Task.FromResult<EntryTraceResult?>(null);
}
}

View File

@@ -16,19 +16,19 @@ public enum EntryTraceOutcome
/// <summary>
/// Logical classification for nodes in the entry trace graph.
/// </summary>
public enum EntryTraceNodeKind
{
Command,
Script,
Include,
public enum EntryTraceNodeKind
{
Command,
Script,
Include,
Interpreter,
Executable,
RunPartsDirectory,
RunPartsScript
}
/// <summary>
/// Interpreter categories supported by the analyzer.
RunPartsScript
}
/// <summary>
/// Interpreter categories supported by the analyzer.
/// </summary>
public enum EntryTraceInterpreterKind
{
@@ -55,18 +55,45 @@ public enum EntryTraceUnknownReason
{
CommandNotFound,
MissingFile,
DynamicEnvironmentReference,
UnsupportedSyntax,
RecursionLimitReached,
InterpreterNotSupported,
ModuleNotFound,
JarNotFound,
RunPartsEmpty,
PermissionDenied
}
/// <summary>
/// Represents a span within a script file.
DynamicEnvironmentReference,
UnsupportedSyntax,
RecursionLimitReached,
InterpreterNotSupported,
ModuleNotFound,
JarNotFound,
RunPartsEmpty,
PermissionDenied,
WrapperMissingCommand,
SupervisorConfigMissing,
SupervisorUnsupported,
SupervisorProgramNotFound,
DynamicEvaluation,
RunPartsLimitExceeded,
WindowsShimUnsupported,
RuntimeSnapshotUnavailable,
RuntimeProcessNotFound,
RuntimeMatch,
RuntimeMismatch,
InferredEntrypointFromHistory,
InferredEntrypointFromServices,
InferredEntrypointFromSupervisor,
InferredEntrypointFromEntrypointScript
}
/// <summary>
/// Categorises terminal executable kinds.
/// </summary>
public enum EntryTraceTerminalType
{
Unknown,
Script,
Native,
Managed,
Service
}
/// <summary>
/// Represents a span within a script file.
/// </summary>
public readonly record struct EntryTraceSpan(
string? Path,
@@ -87,14 +114,15 @@ public sealed record EntryTraceEvidence(
/// <summary>
/// Represents a node in the entry trace graph.
/// </summary>
public sealed record EntryTraceNode(
int Id,
EntryTraceNodeKind Kind,
string DisplayName,
ImmutableArray<string> Arguments,
EntryTraceInterpreterKind InterpreterKind,
EntryTraceEvidence? Evidence,
EntryTraceSpan? Span);
public sealed record EntryTraceNode(
int Id,
EntryTraceNodeKind Kind,
string DisplayName,
ImmutableArray<string> Arguments,
EntryTraceInterpreterKind InterpreterKind,
EntryTraceEvidence? Evidence,
EntryTraceSpan? Span,
ImmutableDictionary<string, string>? Metadata);
/// <summary>
/// Represents a directed edge in the entry trace graph.
@@ -116,10 +144,48 @@ public sealed record EntryTraceDiagnostic(
string? RelatedPath);
/// <summary>
/// Final graph output produced by the analyzer.
/// </summary>
public sealed record EntryTraceGraph(
EntryTraceOutcome Outcome,
ImmutableArray<EntryTraceNode> Nodes,
ImmutableArray<EntryTraceEdge> Edges,
ImmutableArray<EntryTraceDiagnostic> Diagnostics);
/// Final graph output produced by the analyzer.
/// </summary>
public sealed record EntryTraceGraph(
EntryTraceOutcome Outcome,
ImmutableArray<EntryTraceNode> Nodes,
ImmutableArray<EntryTraceEdge> Edges,
ImmutableArray<EntryTraceDiagnostic> Diagnostics,
ImmutableArray<EntryTracePlan> Plans,
ImmutableArray<EntryTraceTerminal> Terminals);
/// <summary>
/// Describes a classified terminal executable.
/// </summary>
public sealed record EntryTracePlan(
ImmutableArray<string> Command,
ImmutableDictionary<string, string> Environment,
string WorkingDirectory,
string User,
string TerminalPath,
EntryTraceTerminalType Type,
string? Runtime,
double Confidence,
ImmutableDictionary<string, string> Evidence);
/// <summary>
/// Describes a classified terminal executable.
/// </summary>
public sealed record EntryTraceTerminal(
string Path,
EntryTraceTerminalType Type,
string? Runtime,
double Confidence,
ImmutableDictionary<string, string> Evidence,
string User,
string WorkingDirectory,
ImmutableArray<string> Arguments);
/// <summary>
/// Represents a fallback entrypoint candidate inferred from image metadata or filesystem.
/// </summary>
public sealed record EntryTraceCandidate(
ImmutableArray<string> Command,
string Source,
EntryTraceEvidence? Evidence,
string? Description);

View File

@@ -0,0 +1,325 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Text;
namespace StellaOps.Scanner.EntryTrace.FileSystem;
/// <summary>
/// <see cref="IRootFileSystem"/> implementation backed by a single on-disk directory.
/// </summary>
public sealed class DirectoryRootFileSystem : IRootFileSystem
{
private readonly DirectoryInfo _root;
public DirectoryRootFileSystem(string rootPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
var fullPath = Path.GetFullPath(rootPath);
_root = new DirectoryInfo(fullPath);
if (!_root.Exists)
{
throw new DirectoryNotFoundException($"Root directory '{fullPath}' does not exist.");
}
}
public bool TryResolveExecutable(string name, IReadOnlyList<string> searchPaths, out RootFileDescriptor descriptor)
{
descriptor = null!;
if (string.IsNullOrWhiteSpace(name))
{
return false;
}
if (name.Contains('/', StringComparison.Ordinal))
{
return TryResolveExecutableByPath(name, out descriptor);
}
if (searchPaths is null)
{
return false;
}
foreach (var searchPath in searchPaths)
{
if (string.IsNullOrWhiteSpace(searchPath))
{
continue;
}
var candidate = Combine(searchPath, name);
if (TryResolveExecutableByPath(candidate, out descriptor))
{
return true;
}
}
return false;
}
public bool TryReadAllText(string path, out RootFileDescriptor descriptor, out string content)
{
if (!TryResolveFile(path, out descriptor, out var fullPath))
{
content = string.Empty;
return false;
}
try
{
content = File.ReadAllText(fullPath);
return true;
}
catch
{
content = string.Empty;
return false;
}
}
public bool TryReadBytes(string path, int maxBytes, out RootFileDescriptor descriptor, out ReadOnlyMemory<byte> content)
{
if (!TryResolveFile(path, out descriptor, out var fullPath))
{
content = ReadOnlyMemory<byte>.Empty;
return false;
}
try
{
var fileInfo = new FileInfo(fullPath);
if (maxBytes > 0 && fileInfo.Length > maxBytes)
{
content = ReadOnlyMemory<byte>.Empty;
return false;
}
var buffer = File.ReadAllBytes(fullPath);
content = new ReadOnlyMemory<byte>(buffer);
return true;
}
catch
{
content = ReadOnlyMemory<byte>.Empty;
return false;
}
}
public ImmutableArray<RootFileDescriptor> EnumerateDirectory(string path)
{
var normalized = Normalize(path);
var fullPath = GetFullPath(normalized, allowDirectory: true);
if (!Directory.Exists(fullPath))
{
return ImmutableArray<RootFileDescriptor>.Empty;
}
var builder = ImmutableArray.CreateBuilder<RootFileDescriptor>();
foreach (var entry in Directory.EnumerateFileSystemEntries(fullPath))
{
var isDirectory = Directory.Exists(entry);
builder.Add(CreateDescriptor(entry, isDirectory));
}
return builder.ToImmutable().Sort(static (left, right) => string.CompareOrdinal(left.Path, right.Path));
}
public bool DirectoryExists(string path)
{
var normalized = Normalize(path);
var fullPath = GetFullPath(normalized, allowDirectory: true);
return Directory.Exists(fullPath);
}
private bool TryResolveExecutableByPath(string path, out RootFileDescriptor descriptor)
{
if (!TryResolveFile(path, out descriptor, out _))
{
return false;
}
return descriptor.IsExecutable;
}
private bool TryResolveFile(string path, out RootFileDescriptor descriptor, out string fullPath)
{
descriptor = null!;
fullPath = string.Empty;
var normalized = Normalize(path);
fullPath = GetFullPath(normalized);
if (!File.Exists(fullPath))
{
return false;
}
descriptor = CreateDescriptor(fullPath, isDirectory: false);
return true;
}
private RootFileDescriptor CreateDescriptor(string fullPath, bool isDirectory)
{
var relative = ToRelativePath(fullPath);
if (isDirectory)
{
return new RootFileDescriptor(relative, null, false, true, null);
}
var info = new FileInfo(fullPath);
var executable = InferExecutable(info);
var shebang = ExtractShebang(fullPath);
return new RootFileDescriptor(relative, null, executable, false, shebang);
}
private string GetFullPath(string normalizedPath, bool allowDirectory = false)
{
var relative = normalizedPath.TrimStart('/');
var combined = Path.GetFullPath(Path.Combine(_root.FullName, relative.Replace('/', Path.DirectorySeparatorChar)));
if (!combined.StartsWith(_root.FullName, StringComparison.Ordinal))
{
throw new InvalidOperationException($"Path '{normalizedPath}' escapes the root directory.");
}
if (!allowDirectory && Directory.Exists(combined))
{
throw new InvalidOperationException($"Path '{normalizedPath}' refers to a directory; a file was expected.");
}
return combined;
}
private static string Normalize(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return "/";
}
var text = path.Replace('\\', '/').Trim();
if (!text.StartsWith("/", StringComparison.Ordinal))
{
text = "/" + text;
}
var segments = new Stack<string>();
foreach (var segment in text.Split('/', StringSplitOptions.RemoveEmptyEntries))
{
if (segment == ".")
{
continue;
}
if (segment == "..")
{
if (segments.Count > 0)
{
segments.Pop();
}
continue;
}
segments.Push(segment);
}
if (segments.Count == 0)
{
return "/";
}
var builder = new StringBuilder();
foreach (var segment in segments.Reverse())
{
builder.Append('/').Append(segment);
}
return builder.ToString();
}
private static string Combine(string basePath, string name)
{
var normalizedBase = Normalize(basePath);
if (normalizedBase == "/")
{
return "/" + name;
}
return normalizedBase.EndsWith("/", StringComparison.Ordinal)
? normalizedBase + name
: normalizedBase + "/" + name;
}
private string ToRelativePath(string fullPath)
{
var relative = Path.GetRelativePath(_root.FullName, fullPath)
.Replace(Path.DirectorySeparatorChar, '/')
.Replace(Path.AltDirectorySeparatorChar, '/');
if (!relative.StartsWith("/", StringComparison.Ordinal))
{
relative = "/" + relative;
}
return relative;
}
private static bool InferExecutable(FileInfo info)
{
if (OperatingSystem.IsWindows())
{
var extension = info.Extension.ToLowerInvariant();
return extension is ".exe" or ".bat" or ".cmd" or ".com" or ".ps1" or ".sh" or ".py" or ".rb" or ".js";
}
try
{
#if NET8_0_OR_GREATER
var mode = File.GetUnixFileMode(info.FullName);
return mode.HasFlag(UnixFileMode.UserExecute) ||
mode.HasFlag(UnixFileMode.GroupExecute) ||
mode.HasFlag(UnixFileMode.OtherExecute);
#else
return true;
#endif
}
catch
{
return true;
}
}
private static string? ExtractShebang(string fullPath)
{
try
{
using var stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read);
Span<byte> buffer = stackalloc byte[256];
var read = stream.Read(buffer);
if (read < 2)
{
return null;
}
if (buffer[0] != (byte)'#' || buffer[1] != (byte)'!')
{
return null;
}
var text = Encoding.UTF8.GetString(buffer[..read]);
var newlineIndex = text.IndexOfAny(new[] { '\r', '\n' });
var shebang = newlineIndex >= 0 ? text[2..newlineIndex] : text[2..];
return shebang.Trim();
}
catch
{
return null;
}
}
}

View File

@@ -1,6 +1,6 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.EntryTrace;
namespace StellaOps.Scanner.EntryTrace.FileSystem;
/// <summary>
/// Represents a layered read-only filesystem snapshot built from container layers.
@@ -12,15 +12,20 @@ public interface IRootFileSystem
/// </summary>
bool TryResolveExecutable(string name, IReadOnlyList<string> searchPaths, out RootFileDescriptor descriptor);
/// <summary>
/// Attempts to read the contents of a file as UTF-8 text.
/// </summary>
bool TryReadAllText(string path, out RootFileDescriptor descriptor, out string content);
/// <summary>
/// Returns descriptors for entries contained within a directory.
/// </summary>
ImmutableArray<RootFileDescriptor> EnumerateDirectory(string path);
/// <summary>
/// Attempts to read the contents of a file as UTF-8 text.
/// </summary>
bool TryReadAllText(string path, out RootFileDescriptor descriptor, out string content);
/// <summary>
/// Attempts to read up to <paramref name="maxBytes"/> bytes from the file.
/// </summary>
bool TryReadBytes(string path, int maxBytes, out RootFileDescriptor descriptor, out ReadOnlyMemory<byte> content);
/// <summary>
/// Returns descriptors for entries contained within a directory.
/// </summary>
ImmutableArray<RootFileDescriptor> EnumerateDirectory(string path);
/// <summary>
/// Checks whether a directory exists.

View File

@@ -7,7 +7,7 @@ using System.Text;
using System.Threading;
using IOPath = System.IO.Path;
namespace StellaOps.Scanner.EntryTrace;
namespace StellaOps.Scanner.EntryTrace.FileSystem;
/// <summary>
/// Represents an <see cref="IRootFileSystem"/> backed by OCI image layers.
@@ -15,7 +15,7 @@ namespace StellaOps.Scanner.EntryTrace;
public sealed class LayeredRootFileSystem : IRootFileSystem
{
private const int MaxSymlinkDepth = 32;
private const int MaxCachedTextBytes = 1_048_576; // 1 MiB
private const int MaxCachedBytes = 1_048_576; // 1 MiB
private readonly ImmutableDictionary<string, FileEntry> _entries;
@@ -118,14 +118,33 @@ public sealed class LayeredRootFileSystem : IRootFileSystem
return false;
}
descriptor = entry.ToDescriptor(resolvedPath);
return true;
}
public ImmutableArray<RootFileDescriptor> EnumerateDirectory(string path)
{
var normalizedDirectory = NormalizeDirectory(path);
var results = ImmutableArray.CreateBuilder<RootFileDescriptor>();
descriptor = entry.ToDescriptor(resolvedPath);
return true;
}
public bool TryReadBytes(string path, int maxBytes, out RootFileDescriptor descriptor, out ReadOnlyMemory<byte> content)
{
descriptor = null!;
content = default;
if (!TryResolveFile(path, out var entry, out var resolvedPath))
{
return false;
}
if (!entry.TryReadBytes(maxBytes, out content))
{
return false;
}
descriptor = entry.ToDescriptor(resolvedPath);
return true;
}
public ImmutableArray<RootFileDescriptor> EnumerateDirectory(string path)
{
var normalizedDirectory = NormalizeDirectory(path);
var results = ImmutableArray.CreateBuilder<RootFileDescriptor>();
foreach (var entry in _entries.Values)
{
@@ -362,7 +381,7 @@ public sealed class LayeredRootFileSystem : IRootFileSystem
var isExecutable = InferExecutable(entryPath, attributes);
var contentProvider = FileContentProvider.FromFile(entryPath);
var shebang = ExtractShebang(contentProvider.Peek(MaxCachedTextBytes));
var shebang = ExtractShebang(contentProvider.Peek(MaxCachedBytes));
EnsureAncestry(normalized, layer.Digest);
_entries[normalized] = FileEntry.File(
@@ -409,7 +428,7 @@ public sealed class LayeredRootFileSystem : IRootFileSystem
case TarEntryType.ContiguousFile:
{
var contentProvider = FileContentProvider.FromTarEntry(entry);
var preview = contentProvider.Peek(MaxCachedTextBytes);
var preview = contentProvider.Peek(MaxCachedBytes);
var shebang = ExtractShebang(preview);
var isExecutable = InferExecutable(entry);
@@ -661,16 +680,27 @@ public sealed class LayeredRootFileSystem : IRootFileSystem
Kind == FileEntryKind.Directory,
Shebang);
public bool TryReadText(out string content)
{
if (Kind != FileEntryKind.File || _content is null)
{
content = string.Empty;
return false;
}
return _content.TryRead(out content);
}
public bool TryReadText(out string content)
{
if (Kind != FileEntryKind.File || _content is null)
{
content = string.Empty;
return false;
}
return _content.TryReadText(out content);
}
public bool TryReadBytes(int maxBytes, out ReadOnlyMemory<byte> bytes)
{
if (Kind != FileEntryKind.File || _content is null)
{
bytes = default;
return false;
}
return _content.TryReadBytes(maxBytes, out bytes);
}
public static FileEntry File(
string path,
@@ -698,74 +728,191 @@ public sealed class LayeredRootFileSystem : IRootFileSystem
Symlink
}
private sealed class FileContentProvider
{
private readonly Func<string?> _factory;
private readonly Lazy<string?> _cached;
private FileContentProvider(Func<string?> factory)
{
_factory = factory;
_cached = new Lazy<string?>(() => _factory(), LazyThreadSafetyMode.ExecutionAndPublication);
}
public static FileContentProvider FromFile(string path)
=> new(() =>
{
try
{
return File.ReadAllText(path);
}
catch
{
return null;
}
});
public static FileContentProvider FromTarEntry(TarEntry entry)
{
return new FileContentProvider(() =>
{
using var stream = new MemoryStream();
entry.DataStream?.CopyTo(stream);
if (stream.Length > MaxCachedTextBytes)
{
return null;
}
stream.Position = 0;
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: true);
return reader.ReadToEnd();
});
}
public string? Peek(int maxBytes)
{
var content = _cached.Value;
if (content is null)
{
return null;
}
if (content.Length * sizeof(char) <= maxBytes)
{
return content;
}
return content[..Math.Min(content.Length, maxBytes / sizeof(char))];
}
public bool TryRead(out string content)
{
var value = _cached.Value;
if (value is null)
{
content = string.Empty;
return false;
}
content = value;
return true;
}
}
}
private sealed class FileContentProvider
{
private readonly Func<byte[]?>? _binaryFactory;
private readonly Func<string?>? _textFactory;
private readonly Lazy<byte[]?> _cachedBytes;
private readonly Lazy<string?> _cachedText;
private FileContentProvider(
Func<byte[]?>? binaryFactory,
Func<string?>? textFactory)
{
_binaryFactory = binaryFactory;
_textFactory = textFactory;
_cachedBytes = new Lazy<byte[]?>(() => _binaryFactory?.Invoke(), LazyThreadSafetyMode.ExecutionAndPublication);
_cachedText = new Lazy<string?>(() =>
{
if (_textFactory is not null)
{
return _textFactory();
}
var bytes = _cachedBytes.Value;
if (bytes is null)
{
return null;
}
try
{
return Encoding.UTF8.GetString(bytes);
}
catch
{
return null;
}
}, LazyThreadSafetyMode.ExecutionAndPublication);
}
public static FileContentProvider FromFile(string path)
{
return new FileContentProvider(
() => ReadFileBytes(path, MaxCachedBytes),
() =>
{
try
{
return File.ReadAllText(path);
}
catch
{
return null;
}
});
}
public static FileContentProvider FromTarEntry(TarEntry entry)
{
byte[]? cached = null;
return new FileContentProvider(
() =>
{
if (cached is not null)
{
return cached;
}
cached = ReadTarEntryBytes(entry, MaxCachedBytes);
return cached;
},
() =>
{
if (cached is null)
{
cached = ReadTarEntryBytes(entry, MaxCachedBytes);
}
if (cached is null)
{
return null;
}
try
{
return Encoding.UTF8.GetString(cached);
}
catch
{
return null;
}
});
}
public string? Peek(int maxBytes)
{
var text = _cachedText.Value;
if (text is null)
{
return null;
}
if (text.Length * sizeof(char) <= maxBytes)
{
return text;
}
return text[..Math.Min(text.Length, maxBytes / sizeof(char))];
}
public bool TryReadText(out string content)
{
var text = _cachedText.Value;
if (text is null)
{
content = string.Empty;
return false;
}
content = text;
return true;
}
public bool TryReadBytes(int maxBytes, out ReadOnlyMemory<byte> bytes)
{
var data = _cachedBytes.Value;
if (data is null || data.Length == 0)
{
bytes = default;
return false;
}
var length = Math.Min(maxBytes, data.Length);
bytes = new ReadOnlyMemory<byte>(data, 0, length);
return true;
}
private static byte[]? ReadFileBytes(string path, int maxBytes)
{
try
{
using var stream = File.OpenRead(path);
return ReadStreamWithLimit(stream, maxBytes);
}
catch
{
return null;
}
}
private static byte[]? ReadTarEntryBytes(TarEntry entry, int maxBytes)
{
if (entry.DataStream is null)
{
return null;
}
try
{
entry.DataStream.Position = 0;
return ReadStreamWithLimit(entry.DataStream, maxBytes);
}
catch
{
return null;
}
}
private static byte[]? ReadStreamWithLimit(Stream stream, int maxBytes)
{
using var buffer = new MemoryStream();
var remaining = maxBytes;
var temp = new byte[8192];
int read;
while (remaining > 0 && (read = stream.Read(temp, 0, Math.Min(temp.Length, remaining))) > 0)
{
buffer.Write(temp, 0, read);
remaining -= read;
}
if (buffer.Length == 0 && stream.ReadByte() == -1)
{
return Array.Empty<byte>();
}
return buffer.ToArray();
}
}
}

View File

@@ -8,14 +8,17 @@ namespace StellaOps.Scanner.EntryTrace;
/// <summary>
/// Represents the deserialized OCI image config document.
/// </summary>
internal sealed class OciImageConfiguration
{
[JsonPropertyName("config")]
public OciImageConfig? Config { get; init; }
[JsonPropertyName("container_config")]
public OciImageConfig? ContainerConfig { get; init; }
}
internal sealed class OciImageConfiguration
{
[JsonPropertyName("config")]
public OciImageConfig? Config { get; init; }
[JsonPropertyName("container_config")]
public OciImageConfig? ContainerConfig { get; init; }
[JsonPropertyName("history")]
public ImmutableArray<OciHistoryEntry> History { get; init; } = ImmutableArray<OciHistoryEntry>.Empty;
}
/// <summary>
/// Logical representation of the OCI image config fields used by EntryTrace.
@@ -34,12 +37,15 @@ public sealed class OciImageConfig
[JsonConverter(typeof(FlexibleStringListConverter))]
public ImmutableArray<string> Command { get; init; } = ImmutableArray<string>.Empty;
[JsonPropertyName("WorkingDir")]
public string? WorkingDirectory { get; init; }
[JsonPropertyName("User")]
public string? User { get; init; }
}
[JsonPropertyName("WorkingDir")]
public string? WorkingDirectory { get; init; }
[JsonPropertyName("User")]
public string? User { get; init; }
[JsonIgnore]
public ImmutableArray<OciHistoryEntry> History { get; init; } = ImmutableArray<OciHistoryEntry>.Empty;
}
/// <summary>
/// Loads <see cref="OciImageConfig"/> instances from OCI config JSON.
@@ -65,19 +71,20 @@ public static class OciImageConfigLoader
var configuration = JsonSerializer.Deserialize<OciImageConfiguration>(stream, SerializerOptions)
?? throw new InvalidDataException("OCI image config is empty or invalid.");
if (configuration.Config is not null)
{
return configuration.Config;
}
if (configuration.ContainerConfig is not null)
{
return configuration.ContainerConfig;
}
throw new InvalidDataException("OCI image config does not include a config section.");
}
}
var baseConfig = configuration.Config ?? configuration.ContainerConfig
?? throw new InvalidDataException("OCI image config does not include a config section.");
return new OciImageConfig
{
Environment = baseConfig.Environment,
Entrypoint = baseConfig.Entrypoint,
Command = baseConfig.Command,
WorkingDirectory = baseConfig.WorkingDirectory,
User = baseConfig.User,
History = configuration.History
};
}
}
internal sealed class FlexibleStringListConverter : JsonConverter<ImmutableArray<string>>
{
@@ -116,14 +123,18 @@ internal sealed class FlexibleStringListConverter : JsonConverter<ImmutableArray
throw new JsonException($"Unsupported JSON token {reader.TokenType} for string array.");
}
public override void Write(Utf8JsonWriter writer, ImmutableArray<string> value, JsonSerializerOptions options)
{
writer.WriteStartArray();
foreach (var entry in value)
{
writer.WriteStringValue(entry);
}
writer.WriteEndArray();
}
}
public override void Write(Utf8JsonWriter writer, ImmutableArray<string> value, JsonSerializerOptions options)
{
writer.WriteStartArray();
foreach (var entry in value)
{
writer.WriteStringValue(entry);
}
writer.WriteEndArray();
}
}
public sealed record OciHistoryEntry(
[property: JsonPropertyName("created_by")] string? CreatedBy,
[property: JsonPropertyName("empty_layer")] bool EmptyLayer);

View File

@@ -0,0 +1,321 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
namespace StellaOps.Scanner.EntryTrace.Runtime;
public sealed class EntryTraceRuntimeReconciler
{
private static readonly HashSet<string> WrapperNames = new(StringComparer.OrdinalIgnoreCase)
{
"tini",
"tini-static",
"tini64",
"dumb-init",
"dumbinit",
"gosu",
"su-exec",
"chpst",
"s6-supervise",
"s6-svscan",
"s6-svscanctl",
"s6-rc-init",
"runsv",
"runsvdir",
"supervisord",
"sh",
"bash",
"dash",
"ash",
"env"
};
public EntryTraceGraph Reconcile(EntryTraceGraph graph, ProcGraph? procGraph)
{
if (graph is null)
{
throw new ArgumentNullException(nameof(graph));
}
var diagnostics = graph.Diagnostics.ToBuilder();
diagnostics.RemoveAll(d =>
d.Reason is EntryTraceUnknownReason.RuntimeSnapshotUnavailable
or EntryTraceUnknownReason.RuntimeProcessNotFound
or EntryTraceUnknownReason.RuntimeMatch
or EntryTraceUnknownReason.RuntimeMismatch);
if (procGraph is null || procGraph.Processes.Count == 0)
{
diagnostics.Add(new EntryTraceDiagnostic(
EntryTraceDiagnosticSeverity.Info,
EntryTraceUnknownReason.RuntimeSnapshotUnavailable,
"Runtime process snapshot unavailable; static confidence retained.",
Span: null,
RelatedPath: null));
return graph with { Diagnostics = diagnostics.ToImmutable() };
}
var runtimeTerminals = BuildRuntimeTerminals(procGraph);
if (runtimeTerminals.Length == 0)
{
diagnostics.Add(new EntryTraceDiagnostic(
EntryTraceDiagnosticSeverity.Warning,
EntryTraceUnknownReason.RuntimeProcessNotFound,
"Runtime process snapshot did not reveal a terminal executable.",
Span: null,
RelatedPath: null));
return graph with { Diagnostics = diagnostics.ToImmutable() };
}
var planBuilder = ImmutableArray.CreateBuilder<EntryTracePlan>(graph.Plans.Length);
var terminalBuilder = ImmutableArray.CreateBuilder<EntryTraceTerminal>(graph.Terminals.Length);
var terminalIndexMap = BuildTerminalIndexMap(graph.Terminals, terminalBuilder);
foreach (var plan in graph.Plans)
{
var match = SelectBestRuntimeMatch(plan.TerminalPath, runtimeTerminals);
var confidence = EvaluateConfidence(plan.TerminalPath, match?.Path);
planBuilder.Add(plan with { Confidence = confidence.Score });
if (terminalIndexMap.TryGetValue(plan.TerminalPath, out var indices) && indices.Count > 0)
{
var index = indices.Dequeue();
terminalBuilder[index] = terminalBuilder[index] with { Confidence = confidence.Score };
}
diagnostics.Add(BuildDiagnostic(confidence, plan.TerminalPath));
}
// Update any terminals that were not tied to plans.
for (var i = 0; i < terminalBuilder.Count; i++)
{
if (terminalBuilder[i].Confidence > 0)
{
continue;
}
var terminal = terminalBuilder[i];
var match = SelectBestRuntimeMatch(terminal.Path, runtimeTerminals);
var confidence = EvaluateConfidence(terminal.Path, match?.Path);
terminalBuilder[i] = terminal with { Confidence = confidence.Score };
}
return graph with
{
Plans = planBuilder.ToImmutable(),
Terminals = terminalBuilder.ToImmutable(),
Diagnostics = diagnostics.ToImmutable()
};
}
private static ImmutableArray<RuntimeTerminal> BuildRuntimeTerminals(ProcGraph graph)
{
var allCandidates = new List<RuntimeTerminal>();
foreach (var process in graph.Processes.Values
.OrderBy(p => p.StartTimeTicks == 0 ? ulong.MaxValue : p.StartTimeTicks)
.ThenBy(p => p.Pid))
{
var normalizedPath = NormalizeRuntimePath(process);
if (string.IsNullOrWhiteSpace(normalizedPath))
{
continue;
}
allCandidates.Add(new RuntimeTerminal(normalizedPath, process));
}
if (allCandidates.Count == 0)
{
return ImmutableArray<RuntimeTerminal>.Empty;
}
var preferred = allCandidates.Where(candidate => !IsWrapper(candidate.Process)).ToList();
var source = preferred.Count > 0 ? preferred : allCandidates;
var unique = new Dictionary<string, RuntimeTerminal>(StringComparer.Ordinal);
foreach (var candidate in source)
{
if (!unique.ContainsKey(candidate.Path))
{
unique[candidate.Path] = candidate;
}
}
return unique.Values.OrderBy(candidate => candidate.Process.StartTimeTicks == 0 ? ulong.MaxValue : candidate.Process.StartTimeTicks)
.ThenBy(candidate => candidate.Process.Pid)
.ToImmutableArray();
}
private static RuntimeTerminal? SelectBestRuntimeMatch(string predictedPath, ImmutableArray<RuntimeTerminal> runtimeTerminals)
{
if (runtimeTerminals.IsDefaultOrEmpty)
{
return null;
}
RuntimeTerminal? best = null;
double bestScore = double.MinValue;
foreach (var runtime in runtimeTerminals)
{
var (score, _, _) = EvaluateConfidence(predictedPath, runtime.Path);
if (score > bestScore)
{
bestScore = score;
best = runtime;
if (score >= 95d)
{
break;
}
}
}
return best;
}
private static Dictionary<string, Queue<int>> BuildTerminalIndexMap(
ImmutableArray<EntryTraceTerminal> terminals,
ImmutableArray<EntryTraceTerminal>.Builder terminalBuilder)
{
var map = new Dictionary<string, Queue<int>>(StringComparer.Ordinal);
for (var i = 0; i < terminals.Length; i++)
{
var terminal = terminals[i];
terminalBuilder.Add(terminal);
if (!map.TryGetValue(terminal.Path, out var indices))
{
indices = new Queue<int>();
map[terminal.Path] = indices;
}
indices.Enqueue(i);
}
return map;
}
private static ConfidenceResult EvaluateConfidence(string predictedPath, string? runtimePath)
{
if (string.IsNullOrWhiteSpace(runtimePath))
{
return new ConfidenceResult(60d, ConfidenceLevel.Low, string.Empty);
}
var normalizedPredicted = NormalizeComparisonPath(predictedPath);
var normalizedRuntime = NormalizeComparisonPath(runtimePath);
if (string.Equals(normalizedPredicted, normalizedRuntime, StringComparison.Ordinal))
{
return new ConfidenceResult(95d, ConfidenceLevel.High, runtimePath);
}
var predictedName = Path.GetFileName(normalizedPredicted);
var runtimeName = Path.GetFileName(normalizedRuntime);
if (!string.IsNullOrEmpty(predictedName) &&
string.Equals(predictedName, runtimeName, StringComparison.Ordinal))
{
return new ConfidenceResult(90d, ConfidenceLevel.High, runtimePath);
}
if (!string.IsNullOrEmpty(predictedName) &&
string.Equals(predictedName, runtimeName, StringComparison.OrdinalIgnoreCase))
{
return new ConfidenceResult(80d, ConfidenceLevel.Medium, runtimePath);
}
return new ConfidenceResult(60d, ConfidenceLevel.Low, runtimePath);
}
private static EntryTraceDiagnostic BuildDiagnostic(ConfidenceResult result, string predictedPath)
{
var runtimePath = string.IsNullOrWhiteSpace(result.RuntimePath) ? "<unknown>" : result.RuntimePath;
var severity = result.Level == ConfidenceLevel.High
? EntryTraceDiagnosticSeverity.Info
: EntryTraceDiagnosticSeverity.Warning;
var reason = result.Level == ConfidenceLevel.High
? EntryTraceUnknownReason.RuntimeMatch
: EntryTraceUnknownReason.RuntimeMismatch;
var message = result.Level == ConfidenceLevel.High
? $"Runtime process '{runtimePath}' matches EntryTrace prediction '{predictedPath}'."
: $"Runtime process '{runtimePath}' diverges from EntryTrace prediction '{predictedPath}'.";
return new EntryTraceDiagnostic(
severity,
reason,
message,
Span: null,
RelatedPath: string.IsNullOrWhiteSpace(result.RuntimePath) ? null : result.RuntimePath);
}
private static bool IsWrapper(ProcProcess process)
{
var command = GetCommandName(process);
return command.Length > 0 && WrapperNames.Contains(command);
}
private static string GetCommandName(ProcProcess process)
{
if (!string.IsNullOrWhiteSpace(process.CommandName))
{
return process.CommandName;
}
if (!process.CommandLine.IsDefaultOrEmpty && process.CommandLine.Length > 0)
{
return Path.GetFileName(process.CommandLine[0]);
}
if (!string.IsNullOrWhiteSpace(process.ExecutablePath))
{
return Path.GetFileName(process.ExecutablePath);
}
return string.Empty;
}
private static string NormalizeRuntimePath(ProcProcess process)
{
if (!string.IsNullOrWhiteSpace(process.ExecutablePath))
{
return process.ExecutablePath;
}
if (!process.CommandLine.IsDefaultOrEmpty && process.CommandLine.Length > 0)
{
return process.CommandLine[0];
}
return process.CommandName;
}
private static string NormalizeComparisonPath(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return string.Empty;
}
var trimmed = path.Trim();
var normalized = trimmed.Replace('\\', '/');
return normalized;
}
private readonly record struct RuntimeTerminal(string Path, ProcProcess Process);
private readonly record struct ConfidenceResult(double Score, ConfidenceLevel Level, string RuntimePath);
private enum ConfidenceLevel
{
High,
Medium,
Low
}
}

View File

@@ -0,0 +1,230 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
namespace StellaOps.Scanner.EntryTrace.Runtime;
public sealed class ProcFileSystemSnapshot : IProcSnapshotProvider
{
private readonly string _rootPath;
public ProcFileSystemSnapshot(string? rootPath = null)
{
_rootPath = string.IsNullOrWhiteSpace(rootPath) ? "/proc" : rootPath;
}
public IEnumerable<int> EnumerateProcessIds()
{
if (!Directory.Exists(_rootPath))
{
yield break;
}
foreach (var directory in Directory.EnumerateDirectories(_rootPath).OrderBy(d => d, StringComparer.Ordinal))
{
var name = Path.GetFileName(directory);
if (int.TryParse(name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var pid))
{
yield return pid;
}
}
}
public bool TryReadProcess(int pid, out ProcProcess process)
{
process = null!;
try
{
var statPath = Path.Combine(_rootPath, pid.ToString(CultureInfo.InvariantCulture), "stat");
var cmdPath = Path.Combine(_rootPath, pid.ToString(CultureInfo.InvariantCulture), "cmdline");
var exePath = Path.Combine(_rootPath, pid.ToString(CultureInfo.InvariantCulture), "exe");
if (!File.Exists(statPath))
{
return false;
}
var statContent = File.ReadAllText(statPath);
var commandName = ParseCommandName(statContent);
var parentPid = ParseParentPid(statContent);
var startTime = ParseStartTime(statContent);
var commandLine = ReadCommandLine(cmdPath, statContent);
var executable = ResolveExecutablePath(exePath, commandLine);
process = new ProcProcess(pid, parentPid, executable, commandLine, commandName, startTime);
return true;
}
catch
{
return false;
}
}
private static string ParseCommandName(string statContent)
{
if (string.IsNullOrWhiteSpace(statContent))
{
return string.Empty;
}
var openIndex = statContent.IndexOf('(');
var closeIndex = statContent.LastIndexOf(')');
if (openIndex >= 0 && closeIndex > openIndex)
{
return statContent.Substring(openIndex + 1, closeIndex - openIndex - 1);
}
return string.Empty;
}
private static int ParseParentPid(string statContent)
{
if (string.IsNullOrWhiteSpace(statContent))
{
return 0;
}
var closeIndex = statContent.LastIndexOf(')');
if (closeIndex < 0 || closeIndex + 2 >= statContent.Length)
{
return 0;
}
var after = statContent.Substring(closeIndex + 2);
var parts = after.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2)
{
return 0;
}
return int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parentPid)
? parentPid
: 0;
}
private static ulong ParseStartTime(string statContent)
{
if (string.IsNullOrWhiteSpace(statContent))
{
return 0;
}
var closeIndex = statContent.LastIndexOf(')');
if (closeIndex < 0 || closeIndex + 2 >= statContent.Length)
{
return 0;
}
var after = statContent.Substring(closeIndex + 2);
var parts = after.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 19)
{
return 0;
}
return ulong.TryParse(parts[18], NumberStyles.Integer, CultureInfo.InvariantCulture, out var startTime)
? startTime
: 0;
}
private static ImmutableArray<string> ReadCommandLine(string path, string statContent)
{
try
{
if (File.Exists(path))
{
var bytes = File.ReadAllBytes(path);
if (bytes.Length > 0)
{
var segments = SplitNullTerminated(bytes)
.Where(segment => segment.Length > 0)
.ToImmutableArray();
if (!segments.IsDefaultOrEmpty)
{
return segments;
}
}
}
}
catch
{
// ignore
}
var openIndex = statContent.IndexOf('(');
var closeIndex = statContent.LastIndexOf(')');
if (openIndex >= 0 && closeIndex > openIndex)
{
var command = statContent.Substring(openIndex + 1, closeIndex - openIndex - 1);
return ImmutableArray.Create(command);
}
return ImmutableArray<string>.Empty;
}
private static string ResolveExecutablePath(string path, ImmutableArray<string> commandLine)
{
try
{
if (File.Exists(path))
{
try
{
var info = File.ResolveLinkTarget(path, returnFinalTarget: true);
if (info is not null)
{
return info.FullName;
}
}
catch
{
// fall through to command line fallback
}
var fullPath = Path.GetFullPath(path);
if (!string.IsNullOrWhiteSpace(fullPath))
{
return fullPath;
}
}
}
catch
{
// ignore failures to resolve symlinks
}
if (!commandLine.IsDefaultOrEmpty && commandLine[0].Contains('/'))
{
return commandLine[0];
}
return string.Empty;
}
private static IEnumerable<string> SplitNullTerminated(byte[] buffer)
{
var start = 0;
for (var i = 0; i < buffer.Length; i++)
{
if (buffer[i] == 0)
{
var length = i - start;
if (length > 0)
{
yield return Encoding.UTF8.GetString(buffer, start, length);
}
start = i + 1;
}
}
if (start < buffer.Length)
{
yield return Encoding.UTF8.GetString(buffer, start, buffer.Length - start);
}
}
}

View File

@@ -0,0 +1,8 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.EntryTrace.Runtime;
public sealed record ProcGraph(
int RootPid,
ImmutableDictionary<int, ProcProcess> Processes,
ImmutableDictionary<int, ImmutableArray<int>> Children);

View File

@@ -0,0 +1,104 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Scanner.EntryTrace.Runtime;
public interface IProcSnapshotProvider
{
IEnumerable<int> EnumerateProcessIds();
bool TryReadProcess(int pid, out ProcProcess process);
}
public static class ProcGraphBuilder
{
public static ProcGraph? Build(IProcSnapshotProvider provider)
{
if (provider is null)
{
throw new ArgumentNullException(nameof(provider));
}
var processes = new Dictionary<int, ProcProcess>();
foreach (var pid in provider.EnumerateProcessIds().OrderBy(pid => pid))
{
if (pid <= 0)
{
continue;
}
if (provider.TryReadProcess(pid, out var process))
{
processes[pid] = process;
}
}
if (processes.Count == 0)
{
return null;
}
var rootPid = DetermineRootPid(processes);
var children = BuildChildren(processes);
return new ProcGraph(
rootPid,
processes.ToImmutableDictionary(pair => pair.Key, pair => pair.Value),
children);
}
private static int DetermineRootPid(Dictionary<int, ProcProcess> processes)
{
if (processes.ContainsKey(1))
{
return 1;
}
var childPids = new HashSet<int>();
foreach (var process in processes.Values)
{
if (processes.ContainsKey(process.ParentPid))
{
childPids.Add(process.Pid);
}
}
var rootCandidates = processes.Keys.Where(pid => !childPids.Contains(pid));
return rootCandidates.OrderBy(pid => pid).FirstOrDefault(processes.Keys.Min());
}
private static ImmutableDictionary<int, ImmutableArray<int>> BuildChildren(Dictionary<int, ProcProcess> processes)
{
var map = new Dictionary<int, List<int>>(processes.Count);
foreach (var process in processes.Values)
{
if (!processes.ContainsKey(process.ParentPid))
{
continue;
}
if (!map.TryGetValue(process.ParentPid, out var list))
{
list = new List<int>();
map[process.ParentPid] = list;
}
list.Add(process.Pid);
}
return map.ToImmutableDictionary(
pair => pair.Key,
pair => pair.Value
.OrderBy(pid => NormalizeStartTime(processes[pid]))
.ThenBy(pid => pid)
.Select(pid => pid)
.ToImmutableArray());
}
private static ulong NormalizeStartTime(ProcProcess process)
{
return process.StartTimeTicks == 0 ? ulong.MaxValue : process.StartTimeTicks;
}
}

View File

@@ -0,0 +1,11 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.EntryTrace.Runtime;
public sealed record ProcProcess(
int Pid,
int ParentPid,
string ExecutablePath,
ImmutableArray<string> CommandLine,
string CommandName,
ulong StartTimeTicks);

View File

@@ -0,0 +1,309 @@
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.EntryTrace.Serialization;
public static class EntryTraceGraphSerializer
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
static EntryTraceGraphSerializer()
{
SerializerOptions.Converters.Add(new JsonStringEnumConverter());
}
public static string Serialize(EntryTraceGraph graph)
{
ArgumentNullException.ThrowIfNull(graph);
var contract = EntryTraceGraphContract.FromGraph(graph);
return JsonSerializer.Serialize(contract, SerializerOptions);
}
public static EntryTraceGraph Deserialize(string json)
{
ArgumentException.ThrowIfNullOrWhiteSpace(json);
var contract = JsonSerializer.Deserialize<EntryTraceGraphContract>(json, SerializerOptions)
?? throw new InvalidOperationException("Failed to deserialize EntryTrace graph.");
return contract.ToGraph();
}
private sealed class EntryTraceGraphContract
{
public EntryTraceOutcome Outcome { get; set; }
public List<EntryTraceNodeContract> Nodes { get; set; } = new();
public List<EntryTraceEdgeContract> Edges { get; set; } = new();
public List<EntryTraceDiagnosticContract> Diagnostics { get; set; } = new();
public List<EntryTracePlanContract> Plans { get; set; } = new();
public List<EntryTraceTerminalContract> Terminals { get; set; } = new();
public static EntryTraceGraphContract FromGraph(EntryTraceGraph graph)
{
return new EntryTraceGraphContract
{
Outcome = graph.Outcome,
Nodes = graph.Nodes.Select(EntryTraceNodeContract.FromNode).ToList(),
Edges = graph.Edges.Select(EntryTraceEdgeContract.FromEdge).ToList(),
Diagnostics = graph.Diagnostics.Select(EntryTraceDiagnosticContract.FromDiagnostic).ToList(),
Plans = graph.Plans.Select(EntryTracePlanContract.FromPlan).ToList(),
Terminals = graph.Terminals.Select(EntryTraceTerminalContract.FromTerminal).ToList()
};
}
public EntryTraceGraph ToGraph()
{
return new EntryTraceGraph(
Outcome,
Nodes.Select(n => n.ToNode()).ToImmutableArray(),
Edges.Select(e => e.ToEdge()).ToImmutableArray(),
Diagnostics.Select(d => d.ToDiagnostic()).ToImmutableArray(),
Plans.Select(p => p.ToPlan()).ToImmutableArray(),
Terminals.Select(t => t.ToTerminal()).ToImmutableArray());
}
}
private sealed class EntryTraceNodeContract
{
public int Id { get; set; }
public EntryTraceNodeKind Kind { get; set; }
public string DisplayName { get; set; } = string.Empty;
public List<string> Arguments { get; set; } = new();
public EntryTraceInterpreterKind InterpreterKind { get; set; }
public EntryTraceEvidenceContract? Evidence { get; set; }
public EntryTraceSpanContract? Span { get; set; }
public Dictionary<string, string>? Metadata { get; set; }
public static EntryTraceNodeContract FromNode(EntryTraceNode node)
{
return new EntryTraceNodeContract
{
Id = node.Id,
Kind = node.Kind,
DisplayName = node.DisplayName,
Arguments = node.Arguments.ToList(),
InterpreterKind = node.InterpreterKind,
Evidence = node.Evidence is null ? null : EntryTraceEvidenceContract.FromEvidence(node.Evidence),
Span = node.Span is null ? null : EntryTraceSpanContract.FromSpan(node.Span.Value),
Metadata = node.Metadata?.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal)
};
}
public EntryTraceNode ToNode()
{
return new EntryTraceNode(
Id,
Kind,
DisplayName,
Arguments.ToImmutableArray(),
InterpreterKind,
Evidence?.ToEvidence(),
Span?.ToSpan(),
Metadata is null ? null : Metadata.ToImmutableDictionary(StringComparer.Ordinal));
}
}
private sealed class EntryTraceEdgeContract
{
public int From { get; set; }
public int To { get; set; }
public string Relationship { get; set; } = string.Empty;
public Dictionary<string, string>? Metadata { get; set; }
public static EntryTraceEdgeContract FromEdge(EntryTraceEdge edge)
{
return new EntryTraceEdgeContract
{
From = edge.FromNodeId,
To = edge.ToNodeId,
Relationship = edge.Relationship,
Metadata = edge.Metadata?.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal)
};
}
public EntryTraceEdge ToEdge()
{
return new EntryTraceEdge(
From,
To,
Relationship,
Metadata is null ? null : Metadata.ToImmutableDictionary(StringComparer.Ordinal));
}
}
private sealed class EntryTraceDiagnosticContract
{
public EntryTraceDiagnosticSeverity Severity { get; set; }
public EntryTraceUnknownReason Reason { get; set; }
public string Message { get; set; } = string.Empty;
public EntryTraceSpanContract? Span { get; set; }
public string? RelatedPath { get; set; }
public static EntryTraceDiagnosticContract FromDiagnostic(EntryTraceDiagnostic diagnostic)
{
return new EntryTraceDiagnosticContract
{
Severity = diagnostic.Severity,
Reason = diagnostic.Reason,
Message = diagnostic.Message,
Span = diagnostic.Span is null ? null : EntryTraceSpanContract.FromSpan(diagnostic.Span.Value),
RelatedPath = diagnostic.RelatedPath
};
}
public EntryTraceDiagnostic ToDiagnostic()
{
return new EntryTraceDiagnostic(
Severity,
Reason,
Message,
Span?.ToSpan(),
RelatedPath);
}
}
private sealed class EntryTracePlanContract
{
public List<string> Command { get; set; } = new();
public Dictionary<string, string> Environment { get; set; } = new();
public string WorkingDirectory { get; set; } = string.Empty;
public string User { get; set; } = string.Empty;
public string TerminalPath { get; set; } = string.Empty;
public EntryTraceTerminalType Type { get; set; }
public string? Runtime { get; set; }
public double Confidence { get; set; }
public Dictionary<string, string> Evidence { get; set; } = new();
public static EntryTracePlanContract FromPlan(EntryTracePlan plan)
{
return new EntryTracePlanContract
{
Command = plan.Command.ToList(),
Environment = plan.Environment.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal),
WorkingDirectory = plan.WorkingDirectory,
User = plan.User,
TerminalPath = plan.TerminalPath,
Type = plan.Type,
Runtime = plan.Runtime,
Confidence = plan.Confidence,
Evidence = plan.Evidence.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal)
};
}
public EntryTracePlan ToPlan()
{
return new EntryTracePlan(
Command.ToImmutableArray(),
Environment.ToImmutableDictionary(StringComparer.Ordinal),
WorkingDirectory,
User,
TerminalPath,
Type,
Runtime,
Confidence,
Evidence.ToImmutableDictionary(StringComparer.Ordinal));
}
}
private sealed class EntryTraceTerminalContract
{
public string Path { get; set; } = string.Empty;
public EntryTraceTerminalType Type { get; set; }
public string? Runtime { get; set; }
public double Confidence { get; set; }
public Dictionary<string, string> Evidence { get; set; } = new();
public string User { get; set; } = string.Empty;
public string WorkingDirectory { get; set; } = string.Empty;
public List<string> Arguments { get; set; } = new();
public static EntryTraceTerminalContract FromTerminal(EntryTraceTerminal terminal)
{
return new EntryTraceTerminalContract
{
Path = terminal.Path,
Type = terminal.Type,
Runtime = terminal.Runtime,
Confidence = terminal.Confidence,
Evidence = terminal.Evidence.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal),
User = terminal.User,
WorkingDirectory = terminal.WorkingDirectory,
Arguments = terminal.Arguments.ToList()
};
}
public EntryTraceTerminal ToTerminal()
{
return new EntryTraceTerminal(
Path,
Type,
Runtime,
Confidence,
Evidence.ToImmutableDictionary(StringComparer.Ordinal),
User,
WorkingDirectory,
Arguments.ToImmutableArray());
}
}
private sealed class EntryTraceEvidenceContract
{
public string Path { get; set; } = string.Empty;
public string? Layer { get; set; }
public string? Source { get; set; }
public Dictionary<string, string>? Metadata { get; set; }
public static EntryTraceEvidenceContract FromEvidence(EntryTraceEvidence evidence)
{
return new EntryTraceEvidenceContract
{
Path = evidence.Path,
Layer = evidence.LayerDigest,
Source = evidence.Source,
Metadata = evidence.Metadata?.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal)
};
}
public EntryTraceEvidence ToEvidence()
{
return new EntryTraceEvidence(
Path,
Layer,
Source ?? string.Empty,
Metadata is null ? null : new Dictionary<string, string>(Metadata, StringComparer.Ordinal));
}
}
private sealed class EntryTraceSpanContract
{
public string? Path { get; set; }
public int StartLine { get; set; }
public int StartColumn { get; set; }
public int EndLine { get; set; }
public int EndColumn { get; set; }
public static EntryTraceSpanContract FromSpan(EntryTraceSpan span)
{
return new EntryTraceSpanContract
{
Path = span.Path,
StartLine = span.StartLine,
StartColumn = span.StartColumn,
EndLine = span.EndLine,
EndColumn = span.EndColumn
};
}
public EntryTraceSpan ToSpan()
{
return new EntryTraceSpan(
Path,
StartLine,
StartColumn,
EndLine,
EndColumn);
}
}
}

View File

@@ -0,0 +1,333 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
namespace StellaOps.Scanner.EntryTrace;
public sealed record EntryTraceNdjsonMetadata(
string ScanId,
string ImageDigest,
DateTimeOffset GeneratedAtUtc,
string? Source = null);
public static class EntryTraceNdjsonWriter
{
private static readonly JsonWriterOptions WriterOptions = new()
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
Indented = false,
SkipValidation = false
};
public static ImmutableArray<string> Serialize(EntryTraceGraph graph, EntryTraceNdjsonMetadata metadata)
{
if (graph is null)
{
throw new ArgumentNullException(nameof(graph));
}
var result = ImmutableArray.CreateBuilder<string>();
result.Add(BuildLine(writer => WriteEntry(writer, graph, metadata)));
foreach (var node in graph.Nodes.OrderBy(n => n.Id))
{
result.Add(BuildLine(writer => WriteNode(writer, node)));
}
foreach (var edge in graph.Edges
.OrderBy(e => e.FromNodeId)
.ThenBy(e => e.ToNodeId)
.ThenBy(e => e.Relationship, StringComparer.Ordinal))
{
result.Add(BuildLine(writer => WriteEdge(writer, edge)));
}
foreach (var plan in graph.Plans
.OrderBy(p => p.TerminalPath, StringComparer.Ordinal)
.ThenBy(p => p.Runtime, StringComparer.Ordinal))
{
result.Add(BuildLine(writer => WriteTarget(writer, plan)));
}
foreach (var diagnostic in graph.Diagnostics
.OrderBy(d => d.Severity)
.ThenBy(d => d.Reason)
.ThenBy(d => d.Message, StringComparer.Ordinal))
{
result.Add(BuildLine(writer => WriteWarning(writer, diagnostic)));
}
foreach (var capability in ExtractCapabilities(graph))
{
result.Add(BuildLine(writer => WriteCapability(writer, capability)));
}
return result.ToImmutable();
}
private static void WriteEntry(Utf8JsonWriter writer, EntryTraceGraph graph, EntryTraceNdjsonMetadata metadata)
{
writer.WriteStartObject();
writer.WriteString("type", "entrytrace.entry");
writer.WriteString("scan_id", metadata.ScanId);
writer.WriteString("image_digest", metadata.ImageDigest);
writer.WriteString("outcome", graph.Outcome.ToString().ToLowerInvariant());
writer.WriteNumber("nodes", graph.Nodes.Length);
writer.WriteNumber("edges", graph.Edges.Length);
writer.WriteNumber("targets", graph.Plans.Length);
writer.WriteNumber("warnings", graph.Diagnostics.Length);
writer.WriteString("generated_at", metadata.GeneratedAtUtc.UtcDateTime.ToString("O"));
if (!string.IsNullOrWhiteSpace(metadata.Source))
{
writer.WriteString("source", metadata.Source);
}
writer.WriteEndObject();
}
private static void WriteNode(Utf8JsonWriter writer, EntryTraceNode node)
{
writer.WriteStartObject();
writer.WriteString("type", "entrytrace.node");
writer.WriteNumber("id", node.Id);
writer.WriteString("kind", node.Kind.ToString().ToLowerInvariant());
writer.WriteString("display_name", node.DisplayName);
writer.WritePropertyName("arguments");
WriteArray(writer, node.Arguments);
writer.WriteString("interpreter", node.InterpreterKind.ToString().ToLowerInvariant());
if (node.Evidence is not null)
{
writer.WritePropertyName("evidence");
WriteEvidence(writer, node.Evidence);
}
if (node.Span is not null)
{
writer.WritePropertyName("span");
WriteSpan(writer, node.Span.Value);
}
if (node.Metadata is not null && node.Metadata.Count > 0)
{
writer.WritePropertyName("metadata");
WriteDictionary(writer, node.Metadata);
}
writer.WriteEndObject();
}
private static void WriteEdge(Utf8JsonWriter writer, EntryTraceEdge edge)
{
writer.WriteStartObject();
writer.WriteString("type", "entrytrace.edge");
writer.WriteNumber("from", edge.FromNodeId);
writer.WriteNumber("to", edge.ToNodeId);
writer.WriteString("relationship", edge.Relationship);
if (edge.Metadata is not null && edge.Metadata.Count > 0)
{
writer.WritePropertyName("metadata");
WriteDictionary(writer, edge.Metadata);
}
writer.WriteEndObject();
}
private static void WriteTarget(Utf8JsonWriter writer, EntryTracePlan plan)
{
writer.WriteStartObject();
writer.WriteString("type", "entrytrace.target");
writer.WriteString("path", plan.TerminalPath);
writer.WriteString("runtime", plan.Runtime);
writer.WriteString("terminal_type", plan.Type.ToString().ToLowerInvariant());
writer.WriteNumber("confidence", Math.Round(plan.Confidence, 4));
writer.WriteString("confidence_level", ConfidenceLevelFromScore(plan.Confidence));
writer.WriteString("user", plan.User);
writer.WriteString("working_directory", plan.WorkingDirectory);
writer.WritePropertyName("arguments");
WriteArray(writer, plan.Command);
if (plan.Environment is not null && plan.Environment.Count > 0)
{
writer.WritePropertyName("environment");
WriteDictionary(writer, plan.Environment);
}
if (plan.Evidence is not null && plan.Evidence.Count > 0)
{
writer.WritePropertyName("evidence");
WriteDictionary(writer, plan.Evidence);
}
writer.WriteEndObject();
}
private static void WriteWarning(Utf8JsonWriter writer, EntryTraceDiagnostic diagnostic)
{
writer.WriteStartObject();
writer.WriteString("type", "entrytrace.warning");
writer.WriteString("severity", diagnostic.Severity.ToString().ToLowerInvariant());
writer.WriteString("reason", diagnostic.Reason.ToString().ToLowerInvariant());
writer.WriteString("message", diagnostic.Message);
if (diagnostic.Span is not null)
{
writer.WritePropertyName("span");
WriteSpan(writer, diagnostic.Span.Value);
}
if (!string.IsNullOrEmpty(diagnostic.RelatedPath))
{
writer.WriteString("related_path", diagnostic.RelatedPath);
}
writer.WriteEndObject();
}
private static void WriteCapability(Utf8JsonWriter writer, CapabilitySummary capability)
{
writer.WriteStartObject();
writer.WriteString("type", "entrytrace.capability");
writer.WriteString("category", capability.Category);
writer.WriteString("name", capability.Name);
writer.WriteNumber("occurrences", capability.Count);
writer.WriteEndObject();
}
private static string BuildLine(Action<Utf8JsonWriter> build)
{
var buffer = new ArrayBufferWriter<byte>(256);
using (var writer = new Utf8JsonWriter(buffer, WriterOptions))
{
build(writer);
writer.Flush();
}
var json = Encoding.UTF8.GetString(buffer.WrittenSpan);
return json + "\n";
}
private static void WriteArray(Utf8JsonWriter writer, ImmutableArray<string> values)
{
writer.WriteStartArray();
foreach (var value in values)
{
writer.WriteStringValue(value);
}
writer.WriteEndArray();
}
private static void WriteDictionary(Utf8JsonWriter writer, IReadOnlyDictionary<string, string> values)
{
writer.WriteStartObject();
foreach (var kvp in values.OrderBy(kvp => kvp.Key, StringComparer.Ordinal))
{
writer.WriteString(kvp.Key, kvp.Value);
}
writer.WriteEndObject();
}
private static void WriteEvidence(Utf8JsonWriter writer, EntryTraceEvidence evidence)
{
writer.WriteStartObject();
writer.WriteString("path", evidence.Path);
if (!string.IsNullOrWhiteSpace(evidence.LayerDigest))
{
writer.WriteString("layer", evidence.LayerDigest);
}
if (!string.IsNullOrWhiteSpace(evidence.Source))
{
writer.WriteString("source", evidence.Source);
}
if (evidence.Metadata is not null && evidence.Metadata.Count > 0)
{
writer.WritePropertyName("metadata");
WriteDictionary(writer, evidence.Metadata);
}
writer.WriteEndObject();
}
private static void WriteSpan(Utf8JsonWriter writer, EntryTraceSpan span)
{
writer.WriteStartObject();
if (!string.IsNullOrWhiteSpace(span.Path))
{
writer.WriteString("path", span.Path);
}
writer.WriteNumber("start_line", span.StartLine);
writer.WriteNumber("start_column", span.StartColumn);
writer.WriteNumber("end_line", span.EndLine);
writer.WriteNumber("end_column", span.EndColumn);
writer.WriteEndObject();
}
private static ImmutableArray<CapabilitySummary> ExtractCapabilities(EntryTraceGraph graph)
{
var accumulator = new Dictionary<(string Category, string Name), int>(CapabilityKeyComparer.Instance);
foreach (var metadata in graph.Nodes.Select(n => n.Metadata).Where(m => m is not null))
{
AccumulateCapability(accumulator, metadata!);
}
foreach (var metadata in graph.Edges.Select(e => e.Metadata).Where(m => m is not null))
{
AccumulateCapability(accumulator, metadata!);
}
return accumulator
.OrderBy(kvp => kvp.Key.Category, comparer: StringComparer.Ordinal)
.ThenBy(kvp => kvp.Key.Name, comparer: StringComparer.Ordinal)
.Select(kvp => new CapabilitySummary(kvp.Key.Category, kvp.Key.Name, kvp.Value))
.ToImmutableArray();
}
private static void AccumulateCapability(
IDictionary<(string Category, string Name), int> accumulator,
IReadOnlyDictionary<string, string> metadata)
{
if (!metadata.TryGetValue("wrapper.category", out var category) || string.IsNullOrWhiteSpace(category))
{
return;
}
if (!metadata.TryGetValue("wrapper.name", out var name) || string.IsNullOrWhiteSpace(name))
{
name = category;
}
var key = (category, name);
accumulator.TryGetValue(key, out var count);
accumulator[key] = count + 1;
}
private readonly record struct CapabilitySummary(string Category, string Name, int Count);
private static string ConfidenceLevelFromScore(double score)
{
if (score >= 90d)
{
return "high";
}
if (score >= 75d)
{
return "medium";
}
return "low";
}
private sealed class CapabilityKeyComparer : IEqualityComparer<(string Category, string Name)>
{
public static CapabilityKeyComparer Instance { get; } = new();
public bool Equals((string Category, string Name) x, (string Category, string Name) y)
{
return StringComparer.OrdinalIgnoreCase.Equals(x.Category, y.Category)
&& StringComparer.OrdinalIgnoreCase.Equals(x.Name, y.Name);
}
public int GetHashCode((string Category, string Name) obj)
{
var categoryHash = StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Category ?? string.Empty);
var nameHash = StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Name ?? string.Empty);
return HashCode.Combine(categoryHash, nameHash);
}
}
}

View File

@@ -1,29 +1,32 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.EntryTrace.Diagnostics;
namespace StellaOps.Scanner.EntryTrace;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddEntryTraceAnalyzer(this IServiceCollection services, Action<EntryTraceAnalyzerOptions>? configure = null)
{
if (services is null)
{
throw new ArgumentNullException(nameof(services));
}
services.AddOptions<EntryTraceAnalyzerOptions>()
.BindConfiguration(EntryTraceAnalyzerOptions.SectionName);
if (configure is not null)
{
services.Configure(configure);
}
services.TryAddSingleton<EntryTraceMetrics>();
services.TryAddSingleton<IEntryTraceAnalyzer, EntryTraceAnalyzer>();
return services;
}
}
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.EntryTrace.Diagnostics;
using StellaOps.Scanner.EntryTrace.Runtime;
namespace StellaOps.Scanner.EntryTrace;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddEntryTraceAnalyzer(this IServiceCollection services, Action<EntryTraceAnalyzerOptions>? configure = null)
{
if (services is null)
{
throw new ArgumentNullException(nameof(services));
}
services.AddOptions<EntryTraceAnalyzerOptions>()
.BindConfiguration(EntryTraceAnalyzerOptions.SectionName);
if (configure is not null)
{
services.Configure(configure);
}
services.TryAddSingleton<EntryTraceMetrics>();
services.TryAddSingleton<IEntryTraceAnalyzer, EntryTraceAnalyzer>();
services.TryAddSingleton<EntryTraceRuntimeReconciler>();
services.TryAddSingleton<IEntryTraceResultStore, NullEntryTraceResultStore>();
return services;
}
}

View File

@@ -15,5 +15,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="../StellaOps.Scanner.Surface.FS/StellaOps.Scanner.Surface.FS.csproj" />
<ProjectReference Include="../StellaOps.Scanner.Surface.Validation/StellaOps.Scanner.Surface.Validation.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -2,11 +2,16 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SCANNER-ENTRYTRACE-18-502 | TODO | EntryTrace Guild | SCANNER-ENTRYTRACE-18-501 | Expand chain walker with init shim/user-switch/supervisor recognition plus env/workdir accumulation and guarded edges. | Graph nodes annotate tini/dumb-init/gosu/su-exec/s6/supervisord/runit branches with capability tags, environment deltas, and guard metadata validated against fixture scripts. |
| SCANNER-ENTRYTRACE-18-503 | TODO | EntryTrace Guild | SCANNER-ENTRYTRACE-18-502 | Introduce target classifier + EntryPlan handoff with confidence scoring for ELF/Java/.NET/Node/Python and user/workdir context. | Analyzer returns typed targets with confidence metrics and per-branch EntryPlans exercised via golden fixtures and language analyzer stubs. |
| SCANNER-ENTRYTRACE-18-504 | TODO | EntryTrace Guild | SCANNER-ENTRYTRACE-18-503 | Emit EntryTrace AOC NDJSON (`entrytrace.entry/node/edge/target/warning/capability`) and wire CLI/service streaming outputs. | NDJSON writer passes determinism tests, CLI/service endpoints stream ordered observations, and diagnostics integrate new warning codes for dynamic eval/glob limits/windows shims. |
| ENTRYTRACE-SURFACE-01 | TODO | EntryTrace Guild | SURFACE-VAL-02, SURFACE-FS-02 | Run Surface.Validation prereq checks and resolve cached entry fragments via Surface.FS to avoid duplicate parsing. | EntryTrace performance metrics show reuse; regression tests updated; validation errors surfaced consistently. |
| ENTRYTRACE-SURFACE-02 | TODO | EntryTrace Guild | SURFACE-SECRETS-02 | Replace direct env/secret access with Surface.Secrets provider when tracing runtime configs. | Shared provider used; failure modes covered; documentation refreshed. |
| SCANNER-ENTRYTRACE-18-502 | DONE (2025-11-01) | EntryTrace Guild | SCANNER-ENTRYTRACE-18-501 | Expand chain walker with init shim/user-switch/supervisor recognition plus env/workdir accumulation and guarded edges. | Graph nodes annotate tini/dumb-init/gosu/su-exec/s6/supervisord/runit branches with capability tags, environment deltas, and guard metadata validated against fixture scripts. |
| SCANNER-ENTRYTRACE-18-503 | DONE (2025-11-01) | EntryTrace Guild | SCANNER-ENTRYTRACE-18-502 | Introduce target classifier + EntryPlan handoff with confidence scoring for ELF/Java/.NET/Node/Python/Ruby/PHP-FPM/Go/Rust/Nginx and user/workdir context; capture PT_INTERP / CLR / Go BuildID / Rust notes and jar manifests as evidence. | Analyzer returns typed targets with confidence metrics, binary fingerprint evidence (PT_INTERP, CLR header, Go/Rust markers, jar Main-Class), and per-branch EntryPlans exercised via golden fixtures and language analyzer stubs. |
| SCANNER-ENTRYTRACE-18-504 | DONE (2025-11-01) | EntryTrace Guild | SCANNER-ENTRYTRACE-18-503 | Emit EntryTrace AOC NDJSON (`entrytrace.entry/node/edge/target/warning/capability`) and wire CLI/service streaming outputs. | NDJSON writer passes determinism tests, CLI/service endpoints stream ordered observations, and diagnostics integrate new warning codes for dynamic eval/glob limits/windows shims. |
| SCANNER-ENTRYTRACE-18-505 | DONE (2025-11-02) | EntryTrace Guild | SCANNER-ENTRYTRACE-18-504 | Implement process-tree replay (ProcGraph) to reconcile `/proc` exec chains with static EntryTrace results, collapsing wrappers (tini/gosu/supervisord) and emitting agreement/conflict diagnostics. | Runtime harness walks `/proc` (tests + fixture containers), merges ProcGraph with static graph, records High/Medium/Low confidence outcomes, and adds coverage to integration tests. |
| SCANNER-ENTRYTRACE-18-506 | DONE (2025-11-02) | EntryTrace Guild, Scanner WebService Guild | SCANNER-ENTRYTRACE-18-505 | Surface EntryTrace graph + confidence via Scanner.WebService and CLI (REST + streaming), including target summary in scan reports and policy payloads. | WebService exposes `/scans/{id}/entrytrace` + CLI verb, responses include chain/terminal/confidence/evidence, golden fixtures updated, and Policy/Export contracts documented. |
| SCANNER-ENTRYTRACE-18-507 | DOING (2025-11-02) | EntryTrace Guild | SCANNER-ENTRYTRACE-18-503 | Expand candidate discovery beyond ENTRYPOINT/CMD by scanning Docker history metadata and default service directories (`/etc/services/**`, `/s6/**`, `/etc/supervisor/*.conf`, `/usr/local/bin/*-entrypoint`) when explicit commands are absent. | Analyzer produces deterministic fallback candidates with evidence per discovery source, golden fixtures cover supervisor/service directories, and diagnostics distinguish inferred vs declared entrypoints. |
| SCANNER-ENTRYTRACE-18-508 | DOING (2025-11-02) | EntryTrace Guild | SCANNER-ENTRYTRACE-18-503 | Extend wrapper catalogue to collapse language/package launchers (`bundle`, `bundle exec`, `docker-php-entrypoint`, `npm`, `yarn node`, `pipenv`, `poetry run`) and vendor init scripts before terminal classification. | Wrapper detection table includes the new aliases with metadata, analyzer unwraps them into underlying commands, and fixture scripts assert metadata for runtime/package managers. |
| SCANNER-ENTRYTRACE-18-509 | DONE (2025-11-02) | EntryTrace Guild, QA Guild | SCANNER-ENTRYTRACE-18-506 | Add regression coverage for persisted EntryTrace surfaces (result store, WebService endpoint, CLI renderer) and NDJSON payload hashing. | Unit/integration tests cover result retrieval (store/WebService), CLI rendering (`scan entrytrace`), and NDJSON hash stability with fixture snapshots. |
| ENTRYTRACE-SURFACE-01 | DONE (2025-11-02) | EntryTrace Guild | SURFACE-VAL-02, SURFACE-FS-02 | Run Surface.Validation prereq checks and resolve cached entry fragments via Surface.FS to avoid duplicate parsing. | EntryTrace performance metrics show reuse; regression tests updated; validation errors surfaced consistently. |
| ENTRYTRACE-SURFACE-02 | DONE (2025-11-02) | EntryTrace Guild | SURFACE-SECRETS-02 | Replace direct env/secret access with Surface.Secrets provider when tracing runtime configs. | Shared provider used; failure modes covered; documentation refreshed. |
## Status Review — 2025-10-19