Refactor and enhance scanner worker functionality
- Cleaned up code formatting and organization across multiple files for improved readability. - Introduced `OsScanAnalyzerDispatcher` to handle OS analyzer execution and plugin loading. - Updated `ScanJobContext` to include an `Analysis` property for storing scan results. - Enhanced `ScanJobProcessor` to utilize the new `OsScanAnalyzerDispatcher`. - Improved logging and error handling in `ScanProgressReporter` for better traceability. - Updated project dependencies and added references to new analyzer plugins. - Revised task documentation to reflect current status and dependencies.
This commit is contained in:
		
							
								
								
									
										32
									
								
								src/StellaOps.Scanner.EntryTrace/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/StellaOps.Scanner.EntryTrace/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | ||||
| # StellaOps.Scanner.EntryTrace — Agent Charter | ||||
|  | ||||
| ## Mission | ||||
| Resolve container `ENTRYPOINT`/`CMD` chains into deterministic call graphs that fuel usage-aware SBOMs, policy explainability, and runtime drift detection. Implement the EntryTrace analyzers and expose them as restart-time plug-ins for the Scanner Worker. | ||||
|  | ||||
| ## Scope | ||||
| - Parse POSIX/Bourne shell constructs (exec, command, case, if, source/run-parts) with deterministic AST output. | ||||
| - Walk layered root filesystems to resolve PATH lookups, interpreter hand-offs (Python/Node/Java), and record evidence. | ||||
| - Surface explainable diagnostics for unresolved branches (env indirection, missing files, unsupported syntax) and emit metrics. | ||||
| - Package analyzers as signed plug-ins under `plugins/scanner/entrytrace/`, guarded by restart-only policy. | ||||
|  | ||||
| ## Out of Scope | ||||
| - SBOM emission/diffing (owned by `Scanner.Emit`/`Scanner.Diff`). | ||||
| - Runtime enforcement or live drift reconciliation (owned by Zastava). | ||||
| - Registry/network fetchers beyond file lookups inside extracted layers. | ||||
|  | ||||
| ## Interfaces & Contracts | ||||
| - Primary entry point: `IEntryTraceAnalyzer.ResolveAsync` returning a deterministic `EntryTraceGraph`. | ||||
| - Graph nodes must include file path, line span, interpreter classification, evidence source, and follow `Scanner.Core` timestamp/ID helpers when emitting events. | ||||
| - Diagnostics must enumerate unknown reasons from fixed enum; metrics tagged `entrytrace.*`. | ||||
| - Plug-ins register via `IEntryTraceAnalyzerFactory` and must validate against `IPluginCatalogGuard`. | ||||
|  | ||||
| ## Observability & Security | ||||
| - No dynamic assembly loading beyond restart-time plug-in catalog. | ||||
| - Structured logs include `scanId`, `imageDigest`, `layerDigest`, `command`, `reason`. | ||||
| - Metrics counters: `entrytrace_resolutions_total{result}`, `entrytrace_unresolved_total{reason}`. | ||||
| - Deny `source` directives outside image root; sandbox file IO via provided `IRootFileSystem`. | ||||
|  | ||||
| ## Testing | ||||
| - Unit tests live in `../StellaOps.Scanner.EntryTrace.Tests` with golden fixtures under `Fixtures/`. | ||||
| - Determinism harness: same inputs produce byte-identical serialized graphs. | ||||
| - Parser fuzz seeds captured for regression; interpreter tracers validated with sample scripts for Python, Node, Java launchers. | ||||
| @@ -0,0 +1,51 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Diagnostics.Metrics; | ||||
|  | ||||
| namespace StellaOps.Scanner.EntryTrace.Diagnostics; | ||||
|  | ||||
| public static class EntryTraceInstrumentation | ||||
| { | ||||
|     public static readonly Meter Meter = new("stellaops.scanner.entrytrace", "1.0.0"); | ||||
| } | ||||
|  | ||||
| public sealed class EntryTraceMetrics | ||||
| { | ||||
|     private readonly Counter<long> _resolutions; | ||||
|     private readonly Counter<long> _unresolved; | ||||
|  | ||||
|     public EntryTraceMetrics() | ||||
|     { | ||||
|         _resolutions = EntryTraceInstrumentation.Meter.CreateCounter<long>( | ||||
|             "entrytrace_resolutions_total", | ||||
|             description: "Number of entry trace attempts by outcome."); | ||||
|         _unresolved = EntryTraceInstrumentation.Meter.CreateCounter<long>( | ||||
|             "entrytrace_unresolved_total", | ||||
|             description: "Number of unresolved entry trace hops by reason."); | ||||
|     } | ||||
|  | ||||
|     public void RecordOutcome(string imageDigest, string scanId, EntryTraceOutcome outcome) | ||||
|     { | ||||
|         _resolutions.Add(1, CreateTags(imageDigest, scanId, ("outcome", outcome.ToString().ToLowerInvariant()))); | ||||
|     } | ||||
|  | ||||
|     public void RecordUnknown(string imageDigest, string scanId, EntryTraceUnknownReason reason) | ||||
|     { | ||||
|         _unresolved.Add(1, CreateTags(imageDigest, scanId, ("reason", reason.ToString().ToLowerInvariant()))); | ||||
|     } | ||||
|  | ||||
|     private static KeyValuePair<string, object?>[] CreateTags(string imageDigest, string scanId, params (string Key, object? Value)[] extras) | ||||
|     { | ||||
|         var tags = new List<KeyValuePair<string, object?>>(2 + extras.Length) | ||||
|         { | ||||
|             new("image", imageDigest), | ||||
|             new("scan.id", scanId) | ||||
|         }; | ||||
|  | ||||
|         foreach (var extra in extras) | ||||
|         { | ||||
|             tags.Add(new KeyValuePair<string, object?>(extra.Key, extra.Value)); | ||||
|         } | ||||
|  | ||||
|         return tags.ToArray(); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										963
									
								
								src/StellaOps.Scanner.EntryTrace/EntryTraceAnalyzer.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										963
									
								
								src/StellaOps.Scanner.EntryTrace/EntryTraceAnalyzer.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,963 @@ | ||||
| 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; | ||||
|  | ||||
| namespace StellaOps.Scanner.EntryTrace; | ||||
|  | ||||
| public sealed class EntryTraceAnalyzer : IEntryTraceAnalyzer | ||||
| { | ||||
|     private readonly EntryTraceAnalyzerOptions _options; | ||||
|     private readonly EntryTraceMetrics _metrics; | ||||
|     private readonly ILogger<EntryTraceAnalyzer> _logger; | ||||
|  | ||||
|     public EntryTraceAnalyzer( | ||||
|         IOptions<EntryTraceAnalyzerOptions> options, | ||||
|         EntryTraceMetrics metrics, | ||||
|         ILogger<EntryTraceAnalyzer> logger) | ||||
|     { | ||||
|         _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); | ||||
|         _metrics = metrics ?? throw new ArgumentNullException(nameof(metrics)); | ||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|  | ||||
|         if (_options.MaxDepth <= 0) | ||||
|         { | ||||
|             _options.MaxDepth = 32; | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(_options.DefaultPath)) | ||||
|         { | ||||
|             _options.DefaultPath = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public ValueTask<EntryTraceGraph> ResolveAsync( | ||||
|         EntrypointSpecification entrypoint, | ||||
|         EntryTraceContext context, | ||||
|         CancellationToken cancellationToken = default) | ||||
|     { | ||||
|         if (entrypoint is null) | ||||
|         { | ||||
|             throw new ArgumentNullException(nameof(entrypoint)); | ||||
|         } | ||||
|  | ||||
|         if (context is null) | ||||
|         { | ||||
|             throw new ArgumentNullException(nameof(context)); | ||||
|         } | ||||
|  | ||||
|         cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|         var builder = new Builder( | ||||
|             entrypoint, | ||||
|             context, | ||||
|             _options, | ||||
|             _metrics, | ||||
|             _logger); | ||||
|  | ||||
|         var graph = builder.BuildGraph(); | ||||
|         _metrics.RecordOutcome(context.ImageDigest, context.ScanId, graph.Outcome); | ||||
|         foreach (var diagnostic in graph.Diagnostics) | ||||
|         { | ||||
|             _metrics.RecordUnknown(context.ImageDigest, context.ScanId, diagnostic.Reason); | ||||
|         } | ||||
|  | ||||
|         return ValueTask.FromResult(graph); | ||||
|     } | ||||
|  | ||||
|     private sealed class Builder | ||||
|     { | ||||
|         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 HashSet<string> _visitedScripts = new(StringComparer.Ordinal); | ||||
|         private readonly HashSet<string> _visitedCommands = new(StringComparer.Ordinal); | ||||
|         private int _nextNodeId = 1; | ||||
|  | ||||
|         public Builder( | ||||
|             EntrypointSpecification entrypoint, | ||||
|             EntryTraceContext context, | ||||
|             EntryTraceAnalyzerOptions options, | ||||
|             EntryTraceMetrics metrics, | ||||
|             ILogger logger) | ||||
|         { | ||||
|             _entrypoint = entrypoint; | ||||
|             _context = context; | ||||
|             _options = options; | ||||
|             _metrics = metrics; | ||||
|             _logger = logger; | ||||
|             _pathEntries = DeterminePath(context); | ||||
|         } | ||||
|  | ||||
|         private static ImmutableArray<string> DeterminePath(EntryTraceContext context) | ||||
|         { | ||||
|             if (context.Path.Length > 0) | ||||
|             { | ||||
|                 return context.Path; | ||||
|             } | ||||
|  | ||||
|             if (context.Environment.TryGetValue("PATH", out var raw) && !string.IsNullOrWhiteSpace(raw)) | ||||
|             { | ||||
|                 return raw.Split(':').Select(p => p.Trim()).Where(p => p.Length > 0).ToImmutableArray(); | ||||
|             } | ||||
|  | ||||
|             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); | ||||
|             } | ||||
|  | ||||
|             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 ImmutableArray<string> ComposeInitialCommand(EntrypointSpecification specification) | ||||
|         { | ||||
|             if (specification.Entrypoint.Length > 0) | ||||
|             { | ||||
|                 if (specification.Command.Length > 0) | ||||
|                 { | ||||
|                     return specification.Entrypoint.Concat(specification.Command).ToImmutableArray(); | ||||
|                 } | ||||
|  | ||||
|                 return specification.Entrypoint; | ||||
|             } | ||||
|  | ||||
|             if (specification.Command.Length > 0) | ||||
|             { | ||||
|                 return specification.Command; | ||||
|             } | ||||
|  | ||||
|             if (!string.IsNullOrWhiteSpace(specification.EntrypointShell)) | ||||
|             { | ||||
|                 return ImmutableArray.Create("/bin/sh", "-c", specification.EntrypointShell!); | ||||
|             } | ||||
|  | ||||
|             if (!string.IsNullOrWhiteSpace(specification.CommandShell)) | ||||
|             { | ||||
|                 return ImmutableArray.Create("/bin/sh", "-c", specification.CommandShell!); | ||||
|             } | ||||
|  | ||||
|             return ImmutableArray<string>.Empty; | ||||
|         } | ||||
|  | ||||
|         private void ResolveCommand( | ||||
|             ImmutableArray<string> arguments, | ||||
|             EntryTraceNode? parent, | ||||
|             EntryTraceSpan? originSpan, | ||||
|             int depth, | ||||
|             string relationship) | ||||
|         { | ||||
|             if (arguments.Length == 0) | ||||
|             { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             if (depth >= _options.MaxDepth) | ||||
|             { | ||||
|                 _diagnostics.Add(new EntryTraceDiagnostic( | ||||
|                     EntryTraceDiagnosticSeverity.Warning, | ||||
|                     EntryTraceUnknownReason.RecursionLimitReached, | ||||
|                     $"Recursion depth limit {_options.MaxDepth} reached while resolving '{arguments[0]}'.", | ||||
|                     originSpan, | ||||
|                     RelatedPath: null)); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             var commandName = arguments[0]; | ||||
|             var evidence = default(EntryTraceEvidence?); | ||||
|             var descriptor = default(RootFileDescriptor); | ||||
|  | ||||
|             if (!TryResolveExecutable(commandName, out descriptor, out evidence)) | ||||
|             { | ||||
|                 _diagnostics.Add(new EntryTraceDiagnostic( | ||||
|                     EntryTraceDiagnosticSeverity.Warning, | ||||
|                     EntryTraceUnknownReason.CommandNotFound, | ||||
|                     $"Command '{commandName}' not found in PATH.", | ||||
|                     originSpan, | ||||
|                     RelatedPath: null)); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             var node = AddNode( | ||||
|                 EntryTraceNodeKind.Command, | ||||
|                 commandName, | ||||
|                 arguments, | ||||
|                 DetermineInterpreterKind(descriptor), | ||||
|                 evidence, | ||||
|                 originSpan); | ||||
|  | ||||
|             if (parent is not null) | ||||
|             { | ||||
|                 _edges.Add(new EntryTraceEdge(parent.Id, node.Id, relationship, Metadata: null)); | ||||
|             } | ||||
|  | ||||
|             if (!_visitedCommands.Add(descriptor.Path)) | ||||
|             { | ||||
|                 // Prevent infinite loops when scripts call themselves recursively. | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             if (TryFollowInterpreter(node, descriptor, arguments, depth)) | ||||
|             { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             if (TryFollowShell(node, descriptor, arguments, depth)) | ||||
|             { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             // Terminal executable. | ||||
|         } | ||||
|  | ||||
|         private bool TryResolveExecutable( | ||||
|             string commandName, | ||||
|             out RootFileDescriptor descriptor, | ||||
|             out EntryTraceEvidence? evidence) | ||||
|         { | ||||
|             evidence = null; | ||||
|  | ||||
|             if (commandName.Contains('/', StringComparison.Ordinal)) | ||||
|             { | ||||
|                 if (_context.FileSystem.TryReadAllText(commandName, out descriptor, out _)) | ||||
|                 { | ||||
|                     evidence = new EntryTraceEvidence(commandName, descriptor.LayerDigest, "path", null); | ||||
|                     return true; | ||||
|                 } | ||||
|  | ||||
|                 if (_context.FileSystem.TryResolveExecutable(commandName, Array.Empty<string>(), out descriptor)) | ||||
|                 { | ||||
|                     evidence = new EntryTraceEvidence(descriptor.Path, descriptor.LayerDigest, "path", null); | ||||
|                     return true; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             if (_context.FileSystem.TryResolveExecutable(commandName, _pathEntries, out descriptor)) | ||||
|             { | ||||
|                 evidence = new EntryTraceEvidence(descriptor.Path, descriptor.LayerDigest, "path-search", new Dictionary<string, string> | ||||
|                 { | ||||
|                     ["command"] = commandName | ||||
|                 }); | ||||
|                 return true; | ||||
|             } | ||||
|  | ||||
|             descriptor = null!; | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         private bool TryFollowInterpreter( | ||||
|             EntryTraceNode node, | ||||
|             RootFileDescriptor descriptor, | ||||
|             ImmutableArray<string> arguments, | ||||
|             int depth) | ||||
|         { | ||||
|             var interpreter = DetermineInterpreterKind(descriptor); | ||||
|             if (interpreter == EntryTraceInterpreterKind.None) | ||||
|             { | ||||
|                 interpreter = DetectInterpreterFromCommand(arguments); | ||||
|             } | ||||
|  | ||||
|             if (interpreter == EntryTraceInterpreterKind.None) | ||||
|             { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             switch (interpreter) | ||||
|             { | ||||
|                 case EntryTraceInterpreterKind.Python: | ||||
|                     return HandlePython(node, arguments, descriptor, depth); | ||||
|                 case EntryTraceInterpreterKind.Node: | ||||
|                     return HandleNode(node, arguments, descriptor, depth); | ||||
|                 case EntryTraceInterpreterKind.Java: | ||||
|                     return HandleJava(node, arguments, descriptor, depth); | ||||
|                 default: | ||||
|                     return false; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private EntryTraceInterpreterKind DetermineInterpreterKind(RootFileDescriptor descriptor) | ||||
|         { | ||||
|             if (descriptor.ShebangInterpreter is null) | ||||
|             { | ||||
|                 return EntryTraceInterpreterKind.None; | ||||
|             } | ||||
|  | ||||
|             var shebang = descriptor.ShebangInterpreter.ToLowerInvariant(); | ||||
|             if (shebang.Contains("python", StringComparison.Ordinal)) | ||||
|             { | ||||
|                 return EntryTraceInterpreterKind.Python; | ||||
|             } | ||||
|  | ||||
|             if (shebang.Contains("node", StringComparison.Ordinal)) | ||||
|             { | ||||
|                 return EntryTraceInterpreterKind.Node; | ||||
|             } | ||||
|  | ||||
|             if (shebang.Contains("java", StringComparison.Ordinal)) | ||||
|             { | ||||
|                 return EntryTraceInterpreterKind.Java; | ||||
|             } | ||||
|  | ||||
|             if (shebang.Contains("sh", StringComparison.Ordinal) || shebang.Contains("bash", StringComparison.Ordinal)) | ||||
|             { | ||||
|                 return EntryTraceInterpreterKind.None; | ||||
|             } | ||||
|  | ||||
|             return EntryTraceInterpreterKind.None; | ||||
|         } | ||||
|  | ||||
|         private EntryTraceInterpreterKind DetectInterpreterFromCommand(ImmutableArray<string> arguments) | ||||
|         { | ||||
|             if (arguments.Length == 0) | ||||
|             { | ||||
|                 return EntryTraceInterpreterKind.None; | ||||
|             } | ||||
|  | ||||
|             var command = arguments[0]; | ||||
|             if (command.Equals("python", StringComparison.OrdinalIgnoreCase) || | ||||
|                 command.StartsWith("python", StringComparison.OrdinalIgnoreCase)) | ||||
|             { | ||||
|                 return EntryTraceInterpreterKind.Python; | ||||
|             } | ||||
|  | ||||
|             if (command.Equals("node", StringComparison.OrdinalIgnoreCase) || | ||||
|                 command.Equals("nodejs", StringComparison.OrdinalIgnoreCase)) | ||||
|             { | ||||
|                 return EntryTraceInterpreterKind.Node; | ||||
|             } | ||||
|  | ||||
|             if (command.Equals("java", StringComparison.OrdinalIgnoreCase)) | ||||
|             { | ||||
|                 return EntryTraceInterpreterKind.Java; | ||||
|             } | ||||
|  | ||||
|             return EntryTraceInterpreterKind.None; | ||||
|         } | ||||
|  | ||||
|         private bool HandlePython( | ||||
|             EntryTraceNode node, | ||||
|             ImmutableArray<string> arguments, | ||||
|             RootFileDescriptor descriptor, | ||||
|             int depth) | ||||
|         { | ||||
|             if (arguments.Length < 2) | ||||
|             { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             var argIndex = 1; | ||||
|             var moduleMode = false; | ||||
|             string? moduleName = null; | ||||
|             string? scriptPath = null; | ||||
|  | ||||
|             while (argIndex < arguments.Length) | ||||
|             { | ||||
|                 var current = arguments[argIndex]; | ||||
|                 if (current == "-m" && argIndex + 1 < arguments.Length) | ||||
|                 { | ||||
|                     moduleMode = true; | ||||
|                     moduleName = arguments[argIndex + 1]; | ||||
|                     break; | ||||
|                 } | ||||
|  | ||||
|                 if (!current.StartsWith("-", StringComparison.Ordinal)) | ||||
|                 { | ||||
|                     scriptPath = current; | ||||
|                     break; | ||||
|                 } | ||||
|  | ||||
|                 argIndex++; | ||||
|             } | ||||
|  | ||||
|             if (moduleMode && moduleName is not null) | ||||
|             { | ||||
|                 _edges.Add(new EntryTraceEdge(node.Id, node.Id, "python-module", new Dictionary<string, string> | ||||
|                 { | ||||
|                     ["module"] = moduleName | ||||
|                 })); | ||||
|                 return true; | ||||
|             } | ||||
|  | ||||
|             if (scriptPath is null) | ||||
|             { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             if (!_context.FileSystem.TryReadAllText(scriptPath, out var scriptDescriptor, out var content)) | ||||
|             { | ||||
|                 _diagnostics.Add(new EntryTraceDiagnostic( | ||||
|                     EntryTraceDiagnosticSeverity.Warning, | ||||
|                     EntryTraceUnknownReason.MissingFile, | ||||
|                     $"Python script '{scriptPath}' was not found.", | ||||
|                     Span: null, | ||||
|                     RelatedPath: scriptPath)); | ||||
|                 return true; | ||||
|             } | ||||
|  | ||||
|             var scriptNode = AddNode( | ||||
|                 EntryTraceNodeKind.Script, | ||||
|                 scriptPath, | ||||
|                 ImmutableArray<string>.Empty, | ||||
|                 EntryTraceInterpreterKind.Python, | ||||
|                 new EntryTraceEvidence(scriptDescriptor.Path, scriptDescriptor.LayerDigest, "script", null), | ||||
|                 null); | ||||
|  | ||||
|             _edges.Add(new EntryTraceEdge(node.Id, scriptNode.Id, "executes", null)); | ||||
|  | ||||
|             if (IsLikelyShell(content)) | ||||
|             { | ||||
|                 ResolveShellScript(content, scriptDescriptor.Path, scriptNode, depth + 1); | ||||
|             } | ||||
|  | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         private bool HandleNode( | ||||
|             EntryTraceNode node, | ||||
|             ImmutableArray<string> arguments, | ||||
|             RootFileDescriptor descriptor, | ||||
|             int depth) | ||||
|         { | ||||
|             if (arguments.Length < 2) | ||||
|             { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             var scriptArg = arguments.Skip(1).FirstOrDefault(a => !a.StartsWith("-", StringComparison.Ordinal)); | ||||
|             if (string.IsNullOrWhiteSpace(scriptArg)) | ||||
|             { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             if (!_context.FileSystem.TryReadAllText(scriptArg, out var scriptDescriptor, out var content)) | ||||
|             { | ||||
|                 _diagnostics.Add(new EntryTraceDiagnostic( | ||||
|                     EntryTraceDiagnosticSeverity.Warning, | ||||
|                     EntryTraceUnknownReason.MissingFile, | ||||
|                     $"Node script '{scriptArg}' was not found.", | ||||
|                     Span: null, | ||||
|                     RelatedPath: scriptArg)); | ||||
|                 return true; | ||||
|             } | ||||
|  | ||||
|             var scriptNode = AddNode( | ||||
|                 EntryTraceNodeKind.Script, | ||||
|                 scriptArg, | ||||
|                 ImmutableArray<string>.Empty, | ||||
|                 EntryTraceInterpreterKind.Node, | ||||
|                 new EntryTraceEvidence(scriptDescriptor.Path, scriptDescriptor.LayerDigest, "script", null), | ||||
|                 null); | ||||
|  | ||||
|             _edges.Add(new EntryTraceEdge(node.Id, scriptNode.Id, "executes", null)); | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         private bool HandleJava( | ||||
|             EntryTraceNode node, | ||||
|             ImmutableArray<string> arguments, | ||||
|             RootFileDescriptor descriptor, | ||||
|             int depth) | ||||
|         { | ||||
|             if (arguments.Length < 2) | ||||
|             { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             string? jar = null; | ||||
|             string? mainClass = null; | ||||
|  | ||||
|             for (var i = 1; i < arguments.Length; i++) | ||||
|             { | ||||
|                 var arg = arguments[i]; | ||||
|                 if (arg == "-jar" && i + 1 < arguments.Length) | ||||
|                 { | ||||
|                     jar = arguments[i + 1]; | ||||
|                     break; | ||||
|                 } | ||||
|  | ||||
|                 if (!arg.StartsWith("-", StringComparison.Ordinal) && mainClass is null) | ||||
|                 { | ||||
|                     mainClass = arg; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             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; | ||||
|             } | ||||
|  | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         private bool TryFollowShell( | ||||
|             EntryTraceNode node, | ||||
|             RootFileDescriptor descriptor, | ||||
|             ImmutableArray<string> arguments, | ||||
|             int depth) | ||||
|         { | ||||
|             if (!IsShellExecutable(descriptor, arguments)) | ||||
|             { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             if (arguments.Length >= 2 && arguments[1] == "-c" && arguments.Length >= 3) | ||||
|             { | ||||
|                 var scriptText = arguments[2]; | ||||
|                 ResolveShellScript(scriptText, descriptor.Path, node, depth + 1); | ||||
|                 return true; | ||||
|             } | ||||
|  | ||||
|             if (arguments.Length >= 2) | ||||
|             { | ||||
|                 var candidate = arguments[1]; | ||||
|                 if (_context.FileSystem.TryReadAllText(candidate, out var scriptDescriptor, out var content)) | ||||
|                 { | ||||
|                     var scriptNode = AddNode( | ||||
|                         EntryTraceNodeKind.Script, | ||||
|                         candidate, | ||||
|                         ImmutableArray<string>.Empty, | ||||
|                         EntryTraceInterpreterKind.None, | ||||
|                         new EntryTraceEvidence(scriptDescriptor.Path, scriptDescriptor.LayerDigest, "script", null), | ||||
|                         null); | ||||
|                     _edges.Add(new EntryTraceEdge(node.Id, scriptNode.Id, "executes", null)); | ||||
|                     ResolveShellScript(content, scriptDescriptor.Path, scriptNode, depth + 1); | ||||
|                     return true; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             if (arguments.Length == 1) | ||||
|             { | ||||
|                 if (_context.FileSystem.TryReadAllText(descriptor.Path, out var scriptDescriptor, out var content)) | ||||
|                 { | ||||
|                     var scriptNode = AddNode( | ||||
|                         EntryTraceNodeKind.Script, | ||||
|                         descriptor.Path, | ||||
|                         ImmutableArray<string>.Empty, | ||||
|                         EntryTraceInterpreterKind.None, | ||||
|                         new EntryTraceEvidence(scriptDescriptor.Path, scriptDescriptor.LayerDigest, "script", null), | ||||
|                         null); | ||||
|                     _edges.Add(new EntryTraceEdge(node.Id, scriptNode.Id, "executes", null)); | ||||
|                     ResolveShellScript(content, scriptDescriptor.Path, scriptNode, depth + 1); | ||||
|                     return true; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         private static bool IsShellExecutable(RootFileDescriptor descriptor, ImmutableArray<string> arguments) | ||||
|         { | ||||
|             if (descriptor.ShebangInterpreter is not null && | ||||
|                 (descriptor.ShebangInterpreter.Contains("sh", StringComparison.OrdinalIgnoreCase) || | ||||
|                  descriptor.ShebangInterpreter.Contains("bash", StringComparison.OrdinalIgnoreCase))) | ||||
|             { | ||||
|                 return true; | ||||
|             } | ||||
|  | ||||
|             var command = arguments[0]; | ||||
|             return command is "/bin/sh" or "sh" or "bash" or "/bin/bash"; | ||||
|         } | ||||
|  | ||||
|         private void ResolveShellScript( | ||||
|             string scriptContent, | ||||
|             string scriptPath, | ||||
|             EntryTraceNode parent, | ||||
|             int depth) | ||||
|         { | ||||
|             if (_visitedScripts.Contains(scriptPath)) | ||||
|             { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             _visitedScripts.Add(scriptPath); | ||||
|  | ||||
|             ShellScript ast; | ||||
|             try | ||||
|             { | ||||
|                 ast = ShellParser.Parse(scriptContent); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _diagnostics.Add(new EntryTraceDiagnostic( | ||||
|                     EntryTraceDiagnosticSeverity.Warning, | ||||
|                     EntryTraceUnknownReason.UnsupportedSyntax, | ||||
|                     $"Failed to parse shell script '{scriptPath}': {ex.Message}", | ||||
|                     Span: null, | ||||
|                     RelatedPath: scriptPath)); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             foreach (var node in ast.Nodes) | ||||
|             { | ||||
|                 HandleShellNode(node, parent, scriptPath, depth); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private void HandleShellNode( | ||||
|             ShellNode node, | ||||
|             EntryTraceNode parent, | ||||
|             string scriptPath, | ||||
|             int depth) | ||||
|         { | ||||
|             switch (node) | ||||
|             { | ||||
|                 case ShellExecNode execNode: | ||||
|                     { | ||||
|                         var args = MaterializeArguments(execNode.Arguments); | ||||
|                         if (args.Length <= 1) | ||||
|                         { | ||||
|                             break; | ||||
|                         } | ||||
|  | ||||
|                         var execArgs = args.RemoveAt(0); | ||||
|                         ResolveCommand(execArgs, parent, ToEntryTraceSpan(execNode.Span, scriptPath), depth + 1, "executes"); | ||||
|                         break; | ||||
|                     } | ||||
|                 case ShellIncludeNode includeNode: | ||||
|                     { | ||||
|                         var includeArg = includeNode.PathExpression; | ||||
|                         var includePath = ResolveScriptPath(scriptPath, includeArg); | ||||
|                         if (!_context.FileSystem.TryReadAllText(includePath, out var descriptor, out var content)) | ||||
|                         { | ||||
|                             _diagnostics.Add(new EntryTraceDiagnostic( | ||||
|                                 EntryTraceDiagnosticSeverity.Warning, | ||||
|                                 EntryTraceUnknownReason.MissingFile, | ||||
|                                 $"Included script '{includePath}' not found.", | ||||
|                                 ToEntryTraceSpan(includeNode.Span, scriptPath), | ||||
|                                 includePath)); | ||||
|                             break; | ||||
|                         } | ||||
|  | ||||
|                         var includeTraceNode = AddNode( | ||||
|                             EntryTraceNodeKind.Include, | ||||
|                             includePath, | ||||
|                             ImmutableArray<string>.Empty, | ||||
|                             EntryTraceInterpreterKind.None, | ||||
|                             new EntryTraceEvidence(descriptor.Path, descriptor.LayerDigest, "include", null), | ||||
|                             ToEntryTraceSpan(includeNode.Span, scriptPath)); | ||||
|  | ||||
|                         _edges.Add(new EntryTraceEdge(parent.Id, includeTraceNode.Id, "includes", null)); | ||||
|                         ResolveShellScript(content, descriptor.Path, includeTraceNode, depth + 1); | ||||
|                         break; | ||||
|                     } | ||||
|                 case ShellRunPartsNode runPartsNode when _options.FollowRunParts: | ||||
|                     { | ||||
|                         var directory = ResolveScriptPath(scriptPath, runPartsNode.DirectoryExpression); | ||||
|                         if (!_context.FileSystem.DirectoryExists(directory)) | ||||
|                         { | ||||
|                             _diagnostics.Add(new EntryTraceDiagnostic( | ||||
|                                 EntryTraceDiagnosticSeverity.Warning, | ||||
|                                 EntryTraceUnknownReason.MissingFile, | ||||
|                                 $"run-parts directory '{directory}' not found.", | ||||
|                                 ToEntryTraceSpan(runPartsNode.Span, scriptPath), | ||||
|                                 directory)); | ||||
|                             break; | ||||
|                         } | ||||
|  | ||||
|                         var entries = _context.FileSystem.EnumerateDirectory(directory) | ||||
|                             .Where(e => !e.IsDirectory && e.IsExecutable) | ||||
|                             .OrderBy(e => e.Path, StringComparer.Ordinal) | ||||
|                             .Take(_options.RunPartsLimit) | ||||
|                             .ToList(); | ||||
|  | ||||
|                         if (entries.Count == 0) | ||||
|                         { | ||||
|                             _diagnostics.Add(new EntryTraceDiagnostic( | ||||
|                                 EntryTraceDiagnosticSeverity.Info, | ||||
|                                 EntryTraceUnknownReason.RunPartsEmpty, | ||||
|                                 $"run-parts directory '{directory}' contained no executable files.", | ||||
|                                 ToEntryTraceSpan(runPartsNode.Span, scriptPath), | ||||
|                                 directory)); | ||||
|                             break; | ||||
|                         } | ||||
|  | ||||
|                         var dirNode = AddNode( | ||||
|                             EntryTraceNodeKind.RunPartsDirectory, | ||||
|                             directory, | ||||
|                             ImmutableArray<string>.Empty, | ||||
|                             EntryTraceInterpreterKind.None, | ||||
|                             new EntryTraceEvidence(directory, null, "run-parts", null), | ||||
|                             ToEntryTraceSpan(runPartsNode.Span, scriptPath)); | ||||
|                         _edges.Add(new EntryTraceEdge(parent.Id, dirNode.Id, "run-parts", null)); | ||||
|  | ||||
|                         foreach (var entry in entries) | ||||
|                         { | ||||
|                         var childNode = AddNode( | ||||
|                             EntryTraceNodeKind.RunPartsScript, | ||||
|                             entry.Path, | ||||
|                             ImmutableArray<string>.Empty, | ||||
|                             EntryTraceInterpreterKind.None, | ||||
|                             new EntryTraceEvidence(entry.Path, entry.LayerDigest, "run-parts", null), | ||||
|                             null); | ||||
|                             _edges.Add(new EntryTraceEdge(dirNode.Id, childNode.Id, "executes", null)); | ||||
|  | ||||
|                             if (_context.FileSystem.TryReadAllText(entry.Path, out var childDescriptor, out var content)) | ||||
|                             { | ||||
|                                 ResolveShellScript(content, childDescriptor.Path, childNode, depth + 1); | ||||
|                             } | ||||
|                         } | ||||
|  | ||||
|                         break; | ||||
|                     } | ||||
|                 case ShellIfNode ifNode: | ||||
|                     { | ||||
|                         foreach (var branch in ifNode.Branches) | ||||
|                         { | ||||
|                             foreach (var inner in branch.Body) | ||||
|                             { | ||||
|                                 HandleShellNode(inner, parent, scriptPath, depth + 1); | ||||
|                             } | ||||
|                         } | ||||
|  | ||||
|                         break; | ||||
|                     } | ||||
|                 case ShellCaseNode caseNode: | ||||
|                     { | ||||
|                         foreach (var arm in caseNode.Arms) | ||||
|                         { | ||||
|                             foreach (var inner in arm.Body) | ||||
|                             { | ||||
|                                 HandleShellNode(inner, parent, scriptPath, depth + 1); | ||||
|                             } | ||||
|                         } | ||||
|  | ||||
|                         break; | ||||
|                     } | ||||
|                 case ShellCommandNode commandNode: | ||||
|                     { | ||||
|                         var args = MaterializeArguments(commandNode.Arguments); | ||||
|                         if (args.Length == 0) | ||||
|                         { | ||||
|                             break; | ||||
|                         } | ||||
|  | ||||
|                         // Skip shell built-in wrappers. | ||||
|                         if (args[0] is "command" or "env") | ||||
|                         { | ||||
|                             var sliced = args.Skip(1).ToImmutableArray(); | ||||
|                             ResolveCommand(sliced, parent, ToEntryTraceSpan(commandNode.Span, scriptPath), depth + 1, "calls"); | ||||
|                         } | ||||
|                         else | ||||
|                         { | ||||
|                             ResolveCommand(args, parent, ToEntryTraceSpan(commandNode.Span, scriptPath), depth + 1, "calls"); | ||||
|                         } | ||||
|  | ||||
|                         break; | ||||
|                     } | ||||
|                 default: | ||||
|                     break; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private static EntryTraceSpan? ToEntryTraceSpan(ShellSpan span, string path) | ||||
|             => new(path, span.StartLine, span.StartColumn, span.EndLine, span.EndColumn); | ||||
|  | ||||
|         private static ImmutableArray<string> MaterializeArguments(ImmutableArray<ShellToken> tokens) | ||||
|         { | ||||
|             var builder = ImmutableArray.CreateBuilder<string>(tokens.Length); | ||||
|             foreach (var token in tokens) | ||||
|             { | ||||
|                 builder.Add(token.Value); | ||||
|             } | ||||
|  | ||||
|             return builder.ToImmutable(); | ||||
|         } | ||||
|  | ||||
|         private string ResolveScriptPath(string currentScript, string candidate) | ||||
|         { | ||||
|             if (string.IsNullOrWhiteSpace(candidate)) | ||||
|             { | ||||
|                 return candidate; | ||||
|             } | ||||
|  | ||||
|             if (candidate.StartsWith("/", StringComparison.Ordinal)) | ||||
|             { | ||||
|                 return NormalizeUnixPath(candidate); | ||||
|             } | ||||
|  | ||||
|             if (candidate.StartsWith("$", StringComparison.Ordinal)) | ||||
|             { | ||||
|                 _diagnostics.Add(new EntryTraceDiagnostic( | ||||
|                     EntryTraceDiagnosticSeverity.Warning, | ||||
|                     EntryTraceUnknownReason.DynamicEnvironmentReference, | ||||
|                     $"Path '{candidate}' depends on environment variable expansion and cannot be resolved statically.", | ||||
|                     Span: null, | ||||
|                     RelatedPath: candidate)); | ||||
|                 return candidate; | ||||
|             } | ||||
|  | ||||
|             var normalizedScript = NormalizeUnixPath(currentScript); | ||||
|             var lastSlash = normalizedScript.LastIndexOf('/'); | ||||
|             var baseDirectory = lastSlash <= 0 ? "/" : normalizedScript[..lastSlash]; | ||||
|             return CombineUnixPath(baseDirectory, candidate); | ||||
|         } | ||||
|  | ||||
|         private static bool IsLikelyShell(string content) | ||||
|         { | ||||
|             if (string.IsNullOrEmpty(content)) | ||||
|             { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             if (content.StartsWith("#!", StringComparison.Ordinal)) | ||||
|             { | ||||
|                 return content.Contains("sh", StringComparison.OrdinalIgnoreCase); | ||||
|             } | ||||
|  | ||||
|             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 static string CombineUnixPath(string baseDirectory, string relative) | ||||
|         { | ||||
|             var normalizedBase = NormalizeUnixPath(baseDirectory); | ||||
|             var trimmedRelative = relative.Replace('\\', '/').Trim(); | ||||
|             if (string.IsNullOrEmpty(trimmedRelative)) | ||||
|             { | ||||
|                 return normalizedBase; | ||||
|             } | ||||
|  | ||||
|             if (trimmedRelative.StartsWith('/')) | ||||
|             { | ||||
|                 return NormalizeUnixPath(trimmedRelative); | ||||
|             } | ||||
|  | ||||
|             if (!normalizedBase.EndsWith('/')) | ||||
|             { | ||||
|                 normalizedBase += "/"; | ||||
|             } | ||||
|  | ||||
|             return NormalizeUnixPath(normalizedBase + trimmedRelative); | ||||
|         } | ||||
|  | ||||
|         private static string NormalizeUnixPath(string path) | ||||
|         { | ||||
|             if (string.IsNullOrWhiteSpace(path)) | ||||
|             { | ||||
|                 return "/"; | ||||
|             } | ||||
|  | ||||
|             var text = path.Replace('\\', '/').Trim(); | ||||
|             if (!text.StartsWith('/')) | ||||
|             { | ||||
|                 text = "/" + text; | ||||
|             } | ||||
|  | ||||
|             var segments = new List<string>(); | ||||
|             foreach (var part in text.Split('/', StringSplitOptions.RemoveEmptyEntries)) | ||||
|             { | ||||
|                 if (part == ".") | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 if (part == "..") | ||||
|                 { | ||||
|                     if (segments.Count > 0) | ||||
|                     { | ||||
|                         segments.RemoveAt(segments.Count - 1); | ||||
|                     } | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 segments.Add(part); | ||||
|             } | ||||
|  | ||||
|             return segments.Count == 0 ? "/" : "/" + string.Join('/', segments); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,26 @@ | ||||
| namespace StellaOps.Scanner.EntryTrace; | ||||
|  | ||||
| public sealed class EntryTraceAnalyzerOptions | ||||
| { | ||||
|     public const string SectionName = "Scanner:Analyzers:EntryTrace"; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Maximum recursion depth while following includes/run-parts/interpreters. | ||||
|     /// </summary> | ||||
|     public int MaxDepth { get; set; } = 64; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Enables traversal of run-parts directories. | ||||
|     /// </summary> | ||||
|     public bool FollowRunParts { get; set; } = true; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Colon-separated default PATH string used when the environment omits PATH. | ||||
|     /// </summary> | ||||
|     public string DefaultPath { get; set; } = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Maximum number of scripts considered per run-parts directory to prevent explosion. | ||||
|     /// </summary> | ||||
|     public int RunPartsLimit { get; set; } = 64; | ||||
| } | ||||
							
								
								
									
										16
									
								
								src/StellaOps.Scanner.EntryTrace/EntryTraceContext.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								src/StellaOps.Scanner.EntryTrace/EntryTraceContext.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| using System.Collections.Immutable; | ||||
| using Microsoft.Extensions.Logging; | ||||
|  | ||||
| namespace StellaOps.Scanner.EntryTrace; | ||||
|  | ||||
| /// <summary> | ||||
| /// Provides runtime context for entry trace analysis. | ||||
| /// </summary> | ||||
| public sealed record EntryTraceContext( | ||||
|     IRootFileSystem FileSystem, | ||||
|     ImmutableDictionary<string, string> Environment, | ||||
|     ImmutableArray<string> Path, | ||||
|     string WorkingDirectory, | ||||
|     string ImageDigest, | ||||
|     string ScanId, | ||||
|     ILogger? Logger); | ||||
							
								
								
									
										125
									
								
								src/StellaOps.Scanner.EntryTrace/EntryTraceTypes.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								src/StellaOps.Scanner.EntryTrace/EntryTraceTypes.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,125 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Immutable; | ||||
|  | ||||
| namespace StellaOps.Scanner.EntryTrace; | ||||
|  | ||||
| /// <summary> | ||||
| /// Outcome classification for entrypoint resolution attempts. | ||||
| /// </summary> | ||||
| public enum EntryTraceOutcome | ||||
| { | ||||
|     Resolved, | ||||
|     PartiallyResolved, | ||||
|     Unresolved | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Logical classification for nodes in the entry trace graph. | ||||
| /// </summary> | ||||
| public enum EntryTraceNodeKind | ||||
| { | ||||
|     Command, | ||||
|     Script, | ||||
|     Include, | ||||
|     Interpreter, | ||||
|     Executable, | ||||
|     RunPartsDirectory, | ||||
|     RunPartsScript | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Interpreter categories supported by the analyzer. | ||||
| /// </summary> | ||||
| public enum EntryTraceInterpreterKind | ||||
| { | ||||
|     None, | ||||
|     Python, | ||||
|     Node, | ||||
|     Java | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Diagnostic severity levels emitted by the analyzer. | ||||
| /// </summary> | ||||
| public enum EntryTraceDiagnosticSeverity | ||||
| { | ||||
|     Info, | ||||
|     Warning, | ||||
|     Error | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Enumerates the canonical reasons for unresolved edges. | ||||
| /// </summary> | ||||
| public enum EntryTraceUnknownReason | ||||
| { | ||||
|     CommandNotFound, | ||||
|     MissingFile, | ||||
|     DynamicEnvironmentReference, | ||||
|     UnsupportedSyntax, | ||||
|     RecursionLimitReached, | ||||
|     InterpreterNotSupported, | ||||
|     ModuleNotFound, | ||||
|     JarNotFound, | ||||
|     RunPartsEmpty, | ||||
|     PermissionDenied | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Represents a span within a script file. | ||||
| /// </summary> | ||||
| public readonly record struct EntryTraceSpan( | ||||
|     string? Path, | ||||
|     int StartLine, | ||||
|     int StartColumn, | ||||
|     int EndLine, | ||||
|     int EndColumn); | ||||
|  | ||||
| /// <summary> | ||||
| /// Evidence describing where a node originated from within the image. | ||||
| /// </summary> | ||||
| public sealed record EntryTraceEvidence( | ||||
|     string Path, | ||||
|     string? LayerDigest, | ||||
|     string Source, | ||||
|     IReadOnlyDictionary<string, string>? Metadata); | ||||
|  | ||||
| /// <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); | ||||
|  | ||||
| /// <summary> | ||||
| /// Represents a directed edge in the entry trace graph. | ||||
| /// </summary> | ||||
| public sealed record EntryTraceEdge( | ||||
|     int FromNodeId, | ||||
|     int ToNodeId, | ||||
|     string Relationship, | ||||
|     IReadOnlyDictionary<string, string>? Metadata); | ||||
|  | ||||
| /// <summary> | ||||
| /// Captures diagnostic information regarding resolution gaps. | ||||
| /// </summary> | ||||
| public sealed record EntryTraceDiagnostic( | ||||
|     EntryTraceDiagnosticSeverity Severity, | ||||
|     EntryTraceUnknownReason Reason, | ||||
|     string Message, | ||||
|     EntryTraceSpan? Span, | ||||
|     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); | ||||
							
								
								
									
										71
									
								
								src/StellaOps.Scanner.EntryTrace/EntrypointSpecification.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								src/StellaOps.Scanner.EntryTrace/EntrypointSpecification.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,71 @@ | ||||
| using System.Collections.Immutable; | ||||
|  | ||||
| namespace StellaOps.Scanner.EntryTrace; | ||||
|  | ||||
| /// <summary> | ||||
| /// Represents the combined Docker ENTRYPOINT/CMD contract provided to the analyzer. | ||||
| /// </summary> | ||||
| public sealed record EntrypointSpecification | ||||
| { | ||||
|     private EntrypointSpecification( | ||||
|         ImmutableArray<string> entrypoint, | ||||
|         ImmutableArray<string> command, | ||||
|         string? entrypointShell, | ||||
|         string? commandShell) | ||||
|     { | ||||
|         Entrypoint = entrypoint; | ||||
|         Command = command; | ||||
|         EntrypointShell = string.IsNullOrWhiteSpace(entrypointShell) ? null : entrypointShell; | ||||
|         CommandShell = string.IsNullOrWhiteSpace(commandShell) ? null : commandShell; | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Exec-form ENTRYPOINT arguments. | ||||
|     /// </summary> | ||||
|     public ImmutableArray<string> Entrypoint { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Exec-form CMD arguments. | ||||
|     /// </summary> | ||||
|     public ImmutableArray<string> Command { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Shell-form ENTRYPOINT (if provided). | ||||
|     /// </summary> | ||||
|     public string? EntrypointShell { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Shell-form CMD (if provided). | ||||
|     /// </summary> | ||||
|     public string? CommandShell { get; } | ||||
|  | ||||
|     public static EntrypointSpecification FromExecForm( | ||||
|         IEnumerable<string>? entrypoint, | ||||
|         IEnumerable<string>? command) | ||||
|         => new( | ||||
|             entrypoint is null ? ImmutableArray<string>.Empty : entrypoint.ToImmutableArray(), | ||||
|             command is null ? ImmutableArray<string>.Empty : command.ToImmutableArray(), | ||||
|             entrypointShell: null, | ||||
|             commandShell: null); | ||||
|  | ||||
|     public static EntrypointSpecification FromShellForm( | ||||
|         string? entrypoint, | ||||
|         string? command) | ||||
|         => new( | ||||
|             ImmutableArray<string>.Empty, | ||||
|             ImmutableArray<string>.Empty, | ||||
|             entrypoint, | ||||
|             command); | ||||
|  | ||||
|     public EntrypointSpecification WithCommand(IEnumerable<string>? command) | ||||
|         => new(Entrypoint, command?.ToImmutableArray() ?? ImmutableArray<string>.Empty, EntrypointShell, CommandShell); | ||||
|  | ||||
|     public EntrypointSpecification WithCommandShell(string? commandShell) | ||||
|         => new(Entrypoint, Command, EntrypointShell, commandShell); | ||||
|  | ||||
|     public EntrypointSpecification WithEntrypoint(IEnumerable<string>? entrypoint) | ||||
|         => new(entrypoint?.ToImmutableArray() ?? ImmutableArray<string>.Empty, Command, EntrypointShell, CommandShell); | ||||
|  | ||||
|     public EntrypointSpecification WithEntrypointShell(string? entrypointShell) | ||||
|         => new(Entrypoint, Command, entrypointShell, CommandShell); | ||||
| } | ||||
| @@ -0,0 +1,39 @@ | ||||
| using System.Collections.Immutable; | ||||
|  | ||||
| namespace StellaOps.Scanner.EntryTrace; | ||||
|  | ||||
| /// <summary> | ||||
| /// Represents a layered read-only filesystem snapshot built from container layers. | ||||
| /// </summary> | ||||
| public interface IRootFileSystem | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Attempts to resolve an executable by name using the provided PATH entries. | ||||
|     /// </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> | ||||
|     /// Checks whether a directory exists. | ||||
|     /// </summary> | ||||
|     bool DirectoryExists(string path); | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Describes a file discovered within the layered filesystem. | ||||
| /// </summary> | ||||
| public sealed record RootFileDescriptor( | ||||
|     string Path, | ||||
|     string? LayerDigest, | ||||
|     bool IsExecutable, | ||||
|     bool IsDirectory, | ||||
|     string? ShebangInterpreter); | ||||
							
								
								
									
										9
									
								
								src/StellaOps.Scanner.EntryTrace/IEntryTraceAnalyzer.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/StellaOps.Scanner.EntryTrace/IEntryTraceAnalyzer.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| namespace StellaOps.Scanner.EntryTrace; | ||||
|  | ||||
| public interface IEntryTraceAnalyzer | ||||
| { | ||||
|     ValueTask<EntryTraceGraph> ResolveAsync( | ||||
|         EntrypointSpecification entrypoint, | ||||
|         EntryTraceContext context, | ||||
|         CancellationToken cancellationToken = default); | ||||
| } | ||||
							
								
								
									
										54
									
								
								src/StellaOps.Scanner.EntryTrace/Parsing/ShellNodes.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								src/StellaOps.Scanner.EntryTrace/Parsing/ShellNodes.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,54 @@ | ||||
| using System.Collections.Immutable; | ||||
|  | ||||
| namespace StellaOps.Scanner.EntryTrace.Parsing; | ||||
|  | ||||
| public abstract record ShellNode(ShellSpan Span); | ||||
|  | ||||
| public sealed record ShellScript(ImmutableArray<ShellNode> Nodes); | ||||
|  | ||||
| public sealed record ShellSpan(int StartLine, int StartColumn, int EndLine, int EndColumn); | ||||
|  | ||||
| public sealed record ShellCommandNode( | ||||
|     string Command, | ||||
|     ImmutableArray<ShellToken> Arguments, | ||||
|     ShellSpan Span) : ShellNode(Span); | ||||
|  | ||||
| public sealed record ShellIncludeNode( | ||||
|     string PathExpression, | ||||
|     ImmutableArray<ShellToken> Arguments, | ||||
|     ShellSpan Span) : ShellNode(Span); | ||||
|  | ||||
| public sealed record ShellExecNode( | ||||
|     ImmutableArray<ShellToken> Arguments, | ||||
|     ShellSpan Span) : ShellNode(Span); | ||||
|  | ||||
| public sealed record ShellIfNode( | ||||
|     ImmutableArray<ShellConditionalBranch> Branches, | ||||
|     ShellSpan Span) : ShellNode(Span); | ||||
|  | ||||
| public sealed record ShellConditionalBranch( | ||||
|     ShellConditionalKind Kind, | ||||
|     ImmutableArray<ShellNode> Body, | ||||
|     ShellSpan Span, | ||||
|     string? PredicateSummary); | ||||
|  | ||||
| public enum ShellConditionalKind | ||||
| { | ||||
|     If, | ||||
|     Elif, | ||||
|     Else | ||||
| } | ||||
|  | ||||
| public sealed record ShellCaseNode( | ||||
|     ImmutableArray<ShellCaseArm> Arms, | ||||
|     ShellSpan Span) : ShellNode(Span); | ||||
|  | ||||
| public sealed record ShellCaseArm( | ||||
|     ImmutableArray<string> Patterns, | ||||
|     ImmutableArray<ShellNode> Body, | ||||
|     ShellSpan Span); | ||||
|  | ||||
| public sealed record ShellRunPartsNode( | ||||
|     string DirectoryExpression, | ||||
|     ImmutableArray<ShellToken> Arguments, | ||||
|     ShellSpan Span) : ShellNode(Span); | ||||
							
								
								
									
										485
									
								
								src/StellaOps.Scanner.EntryTrace/Parsing/ShellParser.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										485
									
								
								src/StellaOps.Scanner.EntryTrace/Parsing/ShellParser.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,485 @@ | ||||
| using System.Collections.Immutable; | ||||
| using System.Globalization; | ||||
| using System.Linq; | ||||
| using System.Text; | ||||
|  | ||||
| namespace StellaOps.Scanner.EntryTrace.Parsing; | ||||
|  | ||||
| /// <summary> | ||||
| /// Deterministic parser producing a lightweight AST for Bourne shell constructs needed by EntryTrace. | ||||
| /// Supports: simple commands, exec, source/dot, run-parts, if/elif/else/fi, case/esac. | ||||
| /// </summary> | ||||
| public sealed class ShellParser | ||||
| { | ||||
|     private readonly IReadOnlyList<ShellToken> _tokens; | ||||
|     private int _index; | ||||
|  | ||||
|     private ShellParser(IReadOnlyList<ShellToken> tokens) | ||||
|     { | ||||
|         _tokens = tokens; | ||||
|     } | ||||
|  | ||||
|     public static ShellScript Parse(string source) | ||||
|     { | ||||
|         var tokenizer = new ShellTokenizer(); | ||||
|         var tokens = tokenizer.Tokenize(source); | ||||
|         var parser = new ShellParser(tokens); | ||||
|         var nodes = parser.ParseNodes(untilKeywords: null); | ||||
|         return new ShellScript(nodes.ToImmutableArray()); | ||||
|     } | ||||
|  | ||||
|     private List<ShellNode> ParseNodes(HashSet<string>? untilKeywords) | ||||
|     { | ||||
|         var nodes = new List<ShellNode>(); | ||||
|  | ||||
|         while (true) | ||||
|         { | ||||
|             SkipNewLines(); | ||||
|             var token = Peek(); | ||||
|  | ||||
|             if (token.Kind == ShellTokenKind.EndOfFile) | ||||
|             { | ||||
|                 break; | ||||
|             } | ||||
|  | ||||
|             if (token.Kind == ShellTokenKind.Word && untilKeywords is not null && untilKeywords.Contains(token.Value)) | ||||
|             { | ||||
|                 break; | ||||
|             } | ||||
|  | ||||
|             ShellNode? node = token.Kind switch | ||||
|             { | ||||
|                 ShellTokenKind.Word when token.Value == "if" => ParseIf(), | ||||
|                 ShellTokenKind.Word when token.Value == "case" => ParseCase(), | ||||
|                 _ => ParseCommandLike() | ||||
|             }; | ||||
|  | ||||
|             if (node is not null) | ||||
|             { | ||||
|                 nodes.Add(node); | ||||
|             } | ||||
|  | ||||
|             SkipCommandSeparators(); | ||||
|         } | ||||
|  | ||||
|         return nodes; | ||||
|     } | ||||
|  | ||||
|     private ShellNode ParseCommandLike() | ||||
|     { | ||||
|         var start = Peek(); | ||||
|         var tokens = ReadUntilTerminator(); | ||||
|  | ||||
|         if (tokens.Count == 0) | ||||
|         { | ||||
|             return new ShellCommandNode(string.Empty, ImmutableArray<ShellToken>.Empty, CreateSpan(start, start)); | ||||
|         } | ||||
|  | ||||
|         var normalizedName = ExtractCommandName(tokens); | ||||
|         var immutableTokens = tokens.ToImmutableArray(); | ||||
|         var span = CreateSpan(tokens[0], tokens[^1]); | ||||
|  | ||||
|         return normalizedName switch | ||||
|         { | ||||
|             "exec" => new ShellExecNode(immutableTokens, span), | ||||
|             "source" or "." => new ShellIncludeNode( | ||||
|                 ExtractPrimaryArgument(immutableTokens), | ||||
|                 immutableTokens, | ||||
|                 span), | ||||
|             "run-parts" => new ShellRunPartsNode( | ||||
|                 ExtractPrimaryArgument(immutableTokens), | ||||
|                 immutableTokens, | ||||
|                 span), | ||||
|             _ => new ShellCommandNode(normalizedName, immutableTokens, span) | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private ShellIfNode ParseIf() | ||||
|     { | ||||
|         var start = Expect(ShellTokenKind.Word, "if"); | ||||
|         var predicateTokens = ReadUntilKeyword("then"); | ||||
|         Expect(ShellTokenKind.Word, "then"); | ||||
|  | ||||
|         var branches = new List<ShellConditionalBranch>(); | ||||
|         var predicateSummary = JoinTokens(predicateTokens); | ||||
|         var thenNodes = ParseNodes(new HashSet<string>(StringComparer.Ordinal) | ||||
|         { | ||||
|             "elif", | ||||
|             "else", | ||||
|             "fi" | ||||
|         }); | ||||
|  | ||||
|         branches.Add(new ShellConditionalBranch( | ||||
|             ShellConditionalKind.If, | ||||
|             thenNodes.ToImmutableArray(), | ||||
|             CreateSpan(start, thenNodes.LastOrDefault()?.Span ?? CreateSpan(start, start)), | ||||
|             predicateSummary)); | ||||
|  | ||||
|         while (true) | ||||
|         { | ||||
|             SkipNewLines(); | ||||
|             var next = Peek(); | ||||
|             if (next.Kind != ShellTokenKind.Word) | ||||
|             { | ||||
|                 break; | ||||
|             } | ||||
|  | ||||
|             if (next.Value == "elif") | ||||
|             { | ||||
|                 var elifStart = Advance(); | ||||
|                 var elifPredicate = ReadUntilKeyword("then"); | ||||
|                 Expect(ShellTokenKind.Word, "then"); | ||||
|                 var elifBody = ParseNodes(new HashSet<string>(StringComparer.Ordinal) | ||||
|                 { | ||||
|                     "elif", | ||||
|                     "else", | ||||
|                     "fi" | ||||
|                 }); | ||||
|                 var span = elifBody.Count > 0 | ||||
|                     ? CreateSpan(elifStart, elifBody[^1].Span) | ||||
|                     : CreateSpan(elifStart, elifStart); | ||||
|  | ||||
|                 branches.Add(new ShellConditionalBranch( | ||||
|                     ShellConditionalKind.Elif, | ||||
|                     elifBody.ToImmutableArray(), | ||||
|                     span, | ||||
|                     JoinTokens(elifPredicate))); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (next.Value == "else") | ||||
|             { | ||||
|                 var elseStart = Advance(); | ||||
|                 var elseBody = ParseNodes(new HashSet<string>(StringComparer.Ordinal) | ||||
|                 { | ||||
|                     "fi" | ||||
|                 }); | ||||
|                 branches.Add(new ShellConditionalBranch( | ||||
|                     ShellConditionalKind.Else, | ||||
|                     elseBody.ToImmutableArray(), | ||||
|                     elseBody.Count > 0 ? CreateSpan(elseStart, elseBody[^1].Span) : CreateSpan(elseStart, elseStart), | ||||
|                     null)); | ||||
|                 break; | ||||
|             } | ||||
|  | ||||
|             break; | ||||
|         } | ||||
|  | ||||
|         Expect(ShellTokenKind.Word, "fi"); | ||||
|         var end = Previous(); | ||||
|         return new ShellIfNode(branches.ToImmutableArray(), CreateSpan(start, end)); | ||||
|     } | ||||
|  | ||||
|     private ShellCaseNode ParseCase() | ||||
|     { | ||||
|         var start = Expect(ShellTokenKind.Word, "case"); | ||||
|         var selectorTokens = ReadUntilKeyword("in"); | ||||
|         Expect(ShellTokenKind.Word, "in"); | ||||
|  | ||||
|         var arms = new List<ShellCaseArm>(); | ||||
|         while (true) | ||||
|         { | ||||
|             SkipNewLines(); | ||||
|             var token = Peek(); | ||||
|             if (token.Kind == ShellTokenKind.Word && token.Value == "esac") | ||||
|             { | ||||
|                 break; | ||||
|             } | ||||
|  | ||||
|             if (token.Kind == ShellTokenKind.EndOfFile) | ||||
|             { | ||||
|                 throw new FormatException("Unexpected end of file while parsing case arms."); | ||||
|             } | ||||
|  | ||||
|             var patterns = ReadPatterns(); | ||||
|             Expect(ShellTokenKind.Operator, ")"); | ||||
|  | ||||
|             var body = ParseNodes(new HashSet<string>(StringComparer.Ordinal) | ||||
|             { | ||||
|                 ";;", | ||||
|                 "esac" | ||||
|             }); | ||||
|  | ||||
|             ShellSpan span; | ||||
|             if (body.Count > 0) | ||||
|             { | ||||
|                 span = CreateSpan(patterns.FirstToken ?? token, body[^1].Span); | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 span = CreateSpan(patterns.FirstToken ?? token, token); | ||||
|             } | ||||
|  | ||||
|             arms.Add(new ShellCaseArm( | ||||
|                 patterns.Values.ToImmutableArray(), | ||||
|                 body.ToImmutableArray(), | ||||
|                 span)); | ||||
|  | ||||
|             SkipNewLines(); | ||||
|             var separator = Peek(); | ||||
|             if (separator.Kind == ShellTokenKind.Operator && separator.Value == ";;") | ||||
|             { | ||||
|                 Advance(); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (separator.Kind == ShellTokenKind.Word && separator.Value == "esac") | ||||
|             { | ||||
|                 break; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         Expect(ShellTokenKind.Word, "esac"); | ||||
|         return new ShellCaseNode(arms.ToImmutableArray(), CreateSpan(start, Previous())); | ||||
|  | ||||
|         (List<string> Values, ShellToken? FirstToken) ReadPatterns() | ||||
|         { | ||||
|             var values = new List<string>(); | ||||
|             ShellToken? first = null; | ||||
|             var sb = new StringBuilder(); | ||||
|  | ||||
|             while (true) | ||||
|             { | ||||
|                 var current = Peek(); | ||||
|                 if (current.Kind is ShellTokenKind.Operator && current.Value is ")" or "|") | ||||
|                 { | ||||
|                     if (sb.Length > 0) | ||||
|                     { | ||||
|                         values.Add(sb.ToString()); | ||||
|                         sb.Clear(); | ||||
|                     } | ||||
|  | ||||
|                     if (current.Value == "|") | ||||
|                     { | ||||
|                         Advance(); | ||||
|                         continue; | ||||
|                     } | ||||
|  | ||||
|                     break; | ||||
|                 } | ||||
|  | ||||
|                 if (current.Kind == ShellTokenKind.EndOfFile) | ||||
|                 { | ||||
|                     throw new FormatException("Unexpected EOF in case arm pattern."); | ||||
|                 } | ||||
|  | ||||
|                 if (first is null) | ||||
|                 { | ||||
|                     first = current; | ||||
|                 } | ||||
|  | ||||
|                 sb.Append(current.Value); | ||||
|                 Advance(); | ||||
|             } | ||||
|  | ||||
|             if (values.Count == 0 && sb.Length > 0) | ||||
|             { | ||||
|                 values.Add(sb.ToString()); | ||||
|             } | ||||
|  | ||||
|             return (values, first); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private List<ShellToken> ReadUntilTerminator() | ||||
|     { | ||||
|         var tokens = new List<ShellToken>(); | ||||
|         while (true) | ||||
|         { | ||||
|             var token = Peek(); | ||||
|             if (token.Kind is ShellTokenKind.EndOfFile or ShellTokenKind.NewLine) | ||||
|             { | ||||
|                 break; | ||||
|             } | ||||
|  | ||||
|             if (token.Kind == ShellTokenKind.Operator && token.Value is ";" or "&&" or "||") | ||||
|             { | ||||
|                 break; | ||||
|             } | ||||
|  | ||||
|             tokens.Add(Advance()); | ||||
|         } | ||||
|  | ||||
|         return tokens; | ||||
|     } | ||||
|  | ||||
|     private ImmutableArray<ShellToken> ReadUntilKeyword(string keyword) | ||||
|     { | ||||
|         var tokens = new List<ShellToken>(); | ||||
|         while (true) | ||||
|         { | ||||
|             var token = Peek(); | ||||
|             if (token.Kind == ShellTokenKind.EndOfFile) | ||||
|             { | ||||
|                 throw new FormatException($"Unexpected EOF while looking for keyword '{keyword}'."); | ||||
|             } | ||||
|  | ||||
|             if (token.Kind == ShellTokenKind.Word && token.Value == keyword) | ||||
|             { | ||||
|                 break; | ||||
|             } | ||||
|  | ||||
|             tokens.Add(Advance()); | ||||
|         } | ||||
|  | ||||
|         return tokens.ToImmutableArray(); | ||||
|     } | ||||
|  | ||||
|     private static string ExtractCommandName(IReadOnlyList<ShellToken> tokens) | ||||
|     { | ||||
|         foreach (var token in tokens) | ||||
|         { | ||||
|             if (token.Kind is not ShellTokenKind.Word and not ShellTokenKind.SingleQuoted and not ShellTokenKind.DoubleQuoted) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (token.Value.Contains('=', StringComparison.Ordinal)) | ||||
|             { | ||||
|                 // Skip environment assignments e.g. FOO=bar exec /app | ||||
|                 var eqIndex = token.Value.IndexOf('=', StringComparison.Ordinal); | ||||
|                 if (eqIndex > 0 && token.Value[..eqIndex].All(IsIdentifierChar)) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             return NormalizeCommandName(token.Value); | ||||
|         } | ||||
|  | ||||
|         return string.Empty; | ||||
|  | ||||
|         static bool IsIdentifierChar(char c) => char.IsLetterOrDigit(c) || c == '_'; | ||||
|     } | ||||
|  | ||||
|     private static string NormalizeCommandName(string value) | ||||
|     { | ||||
|         if (string.IsNullOrEmpty(value)) | ||||
|         { | ||||
|             return string.Empty; | ||||
|         } | ||||
|  | ||||
|         return value switch | ||||
|         { | ||||
|             "." => ".", | ||||
|             _ => value.Trim() | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private void SkipCommandSeparators() | ||||
|     { | ||||
|         while (true) | ||||
|         { | ||||
|             var token = Peek(); | ||||
|             if (token.Kind == ShellTokenKind.NewLine) | ||||
|             { | ||||
|                 Advance(); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (token.Kind == ShellTokenKind.Operator && (token.Value == ";" || token.Value == "&")) | ||||
|             { | ||||
|                 Advance(); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             break; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void SkipNewLines() | ||||
|     { | ||||
|         while (Peek().Kind == ShellTokenKind.NewLine) | ||||
|         { | ||||
|             Advance(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private ShellToken Expect(ShellTokenKind kind, string? value = null) | ||||
|     { | ||||
|         var token = Peek(); | ||||
|         if (token.Kind != kind || (value is not null && token.Value != value)) | ||||
|         { | ||||
|             throw new FormatException($"Unexpected token '{token.Value}' at line {token.Line}, expected {value ?? kind.ToString()}."); | ||||
|         } | ||||
|  | ||||
|         return Advance(); | ||||
|     } | ||||
|  | ||||
|     private ShellToken Advance() | ||||
|     { | ||||
|         if (_index >= _tokens.Count) | ||||
|         { | ||||
|             return _tokens[^1]; | ||||
|         } | ||||
|  | ||||
|         return _tokens[_index++]; | ||||
|     } | ||||
|  | ||||
|     private ShellToken Peek() | ||||
|     { | ||||
|         if (_index >= _tokens.Count) | ||||
|         { | ||||
|             return _tokens[^1]; | ||||
|         } | ||||
|  | ||||
|         return _tokens[_index]; | ||||
|     } | ||||
|  | ||||
|     private ShellToken Previous() | ||||
|     { | ||||
|         if (_index == 0) | ||||
|         { | ||||
|             return _tokens[0]; | ||||
|         } | ||||
|  | ||||
|         return _tokens[_index - 1]; | ||||
|     } | ||||
|  | ||||
|     private static ShellSpan CreateSpan(ShellToken start, ShellToken end) | ||||
|     { | ||||
|         return new ShellSpan(start.Line, start.Column, end.Line, end.Column + end.Value.Length); | ||||
|     } | ||||
|  | ||||
|     private static ShellSpan CreateSpan(ShellToken start, ShellSpan end) | ||||
|     { | ||||
|         return new ShellSpan(start.Line, start.Column, end.EndLine, end.EndColumn); | ||||
|     } | ||||
|  | ||||
|     private static string JoinTokens(IEnumerable<ShellToken> tokens) | ||||
|     { | ||||
|         var builder = new StringBuilder(); | ||||
|         var first = true; | ||||
|         foreach (var token in tokens) | ||||
|         { | ||||
|             if (!first) | ||||
|             { | ||||
|                 builder.Append(' '); | ||||
|             } | ||||
|  | ||||
|             builder.Append(token.Value); | ||||
|             first = false; | ||||
|         } | ||||
|  | ||||
|         return builder.ToString(); | ||||
|     } | ||||
|  | ||||
|     private static string ExtractPrimaryArgument(ImmutableArray<ShellToken> tokens) | ||||
|     { | ||||
|         if (tokens.Length <= 1) | ||||
|         { | ||||
|             return string.Empty; | ||||
|         } | ||||
|  | ||||
|         for (var i = 1; i < tokens.Length; i++) | ||||
|         { | ||||
|             var token = tokens[i]; | ||||
|             if (token.Kind is ShellTokenKind.Word or ShellTokenKind.SingleQuoted or ShellTokenKind.DoubleQuoted) | ||||
|             { | ||||
|                 return token.Value; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return string.Empty; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										16
									
								
								src/StellaOps.Scanner.EntryTrace/Parsing/ShellToken.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								src/StellaOps.Scanner.EntryTrace/Parsing/ShellToken.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| namespace StellaOps.Scanner.EntryTrace.Parsing; | ||||
|  | ||||
| /// <summary> | ||||
| /// Token produced by the shell lexer. | ||||
| /// </summary> | ||||
| public readonly record struct ShellToken(ShellTokenKind Kind, string Value, int Line, int Column); | ||||
|  | ||||
| public enum ShellTokenKind | ||||
| { | ||||
|     Word, | ||||
|     SingleQuoted, | ||||
|     DoubleQuoted, | ||||
|     Operator, | ||||
|     NewLine, | ||||
|     EndOfFile | ||||
| } | ||||
							
								
								
									
										200
									
								
								src/StellaOps.Scanner.EntryTrace/Parsing/ShellTokenizer.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										200
									
								
								src/StellaOps.Scanner.EntryTrace/Parsing/ShellTokenizer.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,200 @@ | ||||
| using System.Globalization; | ||||
| using System.Text; | ||||
|  | ||||
| namespace StellaOps.Scanner.EntryTrace.Parsing; | ||||
|  | ||||
| /// <summary> | ||||
| /// Lightweight Bourne shell tokenizer sufficient for ENTRYPOINT scripts. | ||||
| /// Deterministic: emits tokens in source order without normalization. | ||||
| /// </summary> | ||||
| public sealed class ShellTokenizer | ||||
| { | ||||
|     public IReadOnlyList<ShellToken> Tokenize(string source) | ||||
|     { | ||||
|         if (source is null) | ||||
|         { | ||||
|             throw new ArgumentNullException(nameof(source)); | ||||
|         } | ||||
|  | ||||
|         var tokens = new List<ShellToken>(); | ||||
|         var line = 1; | ||||
|         var column = 1; | ||||
|         var index = 0; | ||||
|  | ||||
|         while (index < source.Length) | ||||
|         { | ||||
|             var ch = source[index]; | ||||
|  | ||||
|             if (ch == '\r') | ||||
|             { | ||||
|                 index++; | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (ch == '\n') | ||||
|             { | ||||
|                 tokens.Add(new ShellToken(ShellTokenKind.NewLine, "\n", line, column)); | ||||
|                 index++; | ||||
|                 line++; | ||||
|                 column = 1; | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (char.IsWhiteSpace(ch)) | ||||
|             { | ||||
|                 index++; | ||||
|                 column++; | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (ch == '#') | ||||
|             { | ||||
|                 // Comment: skip until newline. | ||||
|                 while (index < source.Length && source[index] != '\n') | ||||
|                 { | ||||
|                     index++; | ||||
|                 } | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (IsOperatorStart(ch)) | ||||
|             { | ||||
|                 var opStartColumn = column; | ||||
|                 var op = ConsumeOperator(source, ref index, ref column); | ||||
|                 tokens.Add(new ShellToken(ShellTokenKind.Operator, op, line, opStartColumn)); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (ch == '\'') | ||||
|             { | ||||
|                 var (value, length) = ConsumeSingleQuoted(source, index + 1); | ||||
|                 tokens.Add(new ShellToken(ShellTokenKind.SingleQuoted, value, line, column)); | ||||
|                 index += length + 2; | ||||
|                 column += length + 2; | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (ch == '"') | ||||
|             { | ||||
|                 var (value, length) = ConsumeDoubleQuoted(source, index + 1); | ||||
|                 tokens.Add(new ShellToken(ShellTokenKind.DoubleQuoted, value, line, column)); | ||||
|                 index += length + 2; | ||||
|                 column += length + 2; | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var (word, consumed) = ConsumeWord(source, index); | ||||
|             tokens.Add(new ShellToken(ShellTokenKind.Word, word, line, column)); | ||||
|             index += consumed; | ||||
|             column += consumed; | ||||
|         } | ||||
|  | ||||
|         tokens.Add(new ShellToken(ShellTokenKind.EndOfFile, string.Empty, line, column)); | ||||
|         return tokens; | ||||
|     } | ||||
|  | ||||
|     private static bool IsOperatorStart(char ch) => ch switch | ||||
|     { | ||||
|         ';' or '&' or '|' or '(' or ')' => true, | ||||
|         _ => false | ||||
|     }; | ||||
|  | ||||
|     private static string ConsumeOperator(string source, ref int index, ref int column) | ||||
|     { | ||||
|         var start = index; | ||||
|         var ch = source[index]; | ||||
|         index++; | ||||
|         column++; | ||||
|  | ||||
|         if (index < source.Length) | ||||
|         { | ||||
|             var next = source[index]; | ||||
|             if ((ch == '&' && next == '&') || | ||||
|                 (ch == '|' && next == '|') || | ||||
|                 (ch == ';' && next == ';')) | ||||
|             { | ||||
|                 index++; | ||||
|                 column++; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return source[start..index]; | ||||
|     } | ||||
|  | ||||
|     private static (string Value, int Length) ConsumeSingleQuoted(string source, int startIndex) | ||||
|     { | ||||
|         var end = startIndex; | ||||
|         while (end < source.Length && source[end] != '\'') | ||||
|         { | ||||
|             end++; | ||||
|         } | ||||
|  | ||||
|         if (end >= source.Length) | ||||
|         { | ||||
|             throw new FormatException("Unterminated single-quoted string in entrypoint script."); | ||||
|         } | ||||
|  | ||||
|         return (source[startIndex..end], end - startIndex); | ||||
|     } | ||||
|  | ||||
|     private static (string Value, int Length) ConsumeDoubleQuoted(string source, int startIndex) | ||||
|     { | ||||
|         var builder = new StringBuilder(); | ||||
|         var index = startIndex; | ||||
|  | ||||
|         while (index < source.Length) | ||||
|         { | ||||
|             var ch = source[index]; | ||||
|             if (ch == '"') | ||||
|             { | ||||
|                 return (builder.ToString(), index - startIndex); | ||||
|             } | ||||
|  | ||||
|             if (ch == '\\' && index + 1 < source.Length) | ||||
|             { | ||||
|                 var next = source[index + 1]; | ||||
|                 if (next is '"' or '\\' or '$' or '`' or '\n') | ||||
|                 { | ||||
|                     builder.Append(next); | ||||
|                     index += 2; | ||||
|                     continue; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             builder.Append(ch); | ||||
|             index++; | ||||
|         } | ||||
|  | ||||
|         throw new FormatException("Unterminated double-quoted string in entrypoint script."); | ||||
|     } | ||||
|  | ||||
|     private static (string Value, int Length) ConsumeWord(string source, int startIndex) | ||||
|     { | ||||
|         var index = startIndex; | ||||
|         while (index < source.Length) | ||||
|         { | ||||
|             var ch = source[index]; | ||||
|             if (char.IsWhiteSpace(ch) || ch == '\n' || ch == '\r' || IsOperatorStart(ch) || ch == '#' ) | ||||
|             { | ||||
|                 break; | ||||
|             } | ||||
|  | ||||
|             if (ch == '\\' && index + 1 < source.Length && source[index + 1] == '\n') | ||||
|             { | ||||
|                 // Line continuation. | ||||
|                 index += 2; | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             index++; | ||||
|         } | ||||
|  | ||||
|         if (index == startIndex) | ||||
|         { | ||||
|             throw new InvalidOperationException("Tokenizer failed to advance while consuming word."); | ||||
|         } | ||||
|  | ||||
|         var text = source[startIndex..index]; | ||||
|         return (text, index - startIndex); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,29 @@ | ||||
| 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; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,18 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </PropertyGroup> | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.0" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\StellaOps.Plugin\StellaOps.Plugin.csproj" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
							
								
								
									
										11
									
								
								src/StellaOps.Scanner.EntryTrace/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/StellaOps.Scanner.EntryTrace/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| # EntryTrace Analyzer Task Board (Sprint 10) | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | SCANNER-ENTRYTRACE-10-401 | DONE (2025-10-19) | EntryTrace Guild | Scanner Core contracts | Implement deterministic POSIX shell AST parser covering exec/command/source/run-parts/case/if used by ENTRYPOINT scripts. | Parser emits stable AST and serialization tests prove determinism for representative fixtures; see `ShellParserTests`. | | ||||
| | SCANNER-ENTRYTRACE-10-402 | DONE (2025-10-19) | EntryTrace Guild | SCANNER-ENTRYTRACE-10-401 | Resolve commands across layered rootfs, tracking evidence per hop (PATH hit, layer origin, shebang). | Resolver returns terminal program path with layer attribution for fixtures; deterministic traversal asserted in `EntryTraceAnalyzerTests.ResolveAsync_IsDeterministic`. | | ||||
| | SCANNER-ENTRYTRACE-10-403 | DONE (2025-10-19) | EntryTrace Guild | SCANNER-ENTRYTRACE-10-402 | Follow interpreter wrappers (shell → Python/Node/Java launchers) to terminal target, including module/jar detection. | Interpreter tracer reports correct module/script for language launchers; tests cover Python/Node/Java wrappers. | | ||||
| | SCANNER-ENTRYTRACE-10-404 | DONE (2025-10-19) | EntryTrace Guild | SCANNER-ENTRYTRACE-10-403 | Build Python entry analyzer detecting venv shebangs, module invocations, `-m` usage and record usage flag. | Python fixtures produce expected module metadata (`python-module` edge) and diagnostics for missing scripts. | | ||||
| | SCANNER-ENTRYTRACE-10-405 | DONE (2025-10-19) | EntryTrace Guild | SCANNER-ENTRYTRACE-10-403 | Implement Node/Java launcher analyzer capturing script/jar targets including npm lifecycle wrappers. | Node/Java fixtures resolved with evidence chain; `RunParts` coverage ensures child scripts traced. | | ||||
| | SCANNER-ENTRYTRACE-10-406 | DONE (2025-10-19) | EntryTrace Guild | SCANNER-ENTRYTRACE-10-402 | Surface explainability + diagnostics for unresolved constructs and emit metrics counters. | Diagnostics catalog enumerates unknown reasons; metrics wired via `EntryTraceMetrics`; explainability doc updated. | | ||||
| | SCANNER-ENTRYTRACE-10-407 | DONE (2025-10-19) | EntryTrace Guild | SCANNER-ENTRYTRACE-10-401..406 | Package EntryTrace analyzers as restart-time plug-ins with manifest + host registration. | Plug-in manifest under `plugins/scanner/entrytrace/`; restart-only policy documented; DI extension exposes `AddEntryTraceAnalyzer`. | | ||||
		Reference in New Issue
	
	Block a user