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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user