Add Authority Advisory AI and API Lifecycle Configuration
- Introduced AuthorityAdvisoryAiOptions and related classes for managing advisory AI configurations, including remote inference options and tenant-specific settings. - Added AuthorityApiLifecycleOptions to control API lifecycle settings, including legacy OAuth endpoint configurations. - Implemented validation and normalization methods for both advisory AI and API lifecycle options to ensure proper configuration. - Created AuthorityNotificationsOptions and its related classes for managing notification settings, including ack tokens, webhooks, and escalation options. - Developed IssuerDirectoryClient and related models for interacting with the issuer directory service, including caching mechanisms and HTTP client configurations. - Added support for dependency injection through ServiceCollectionExtensions for the Issuer Directory Client. - Updated project file to include necessary package references for the new Issuer Directory Client library.
This commit is contained in:
@@ -71,7 +71,7 @@ internal static class RustCargoLockParser
|
||||
{
|
||||
if (trimmed[0] == ']')
|
||||
{
|
||||
builder.SetArray(currentArrayKey, arrayValues);
|
||||
packageBuilder.SetArray(currentArrayKey, arrayValues);
|
||||
currentArrayKey = null;
|
||||
arrayValues.Clear();
|
||||
continue;
|
||||
@@ -89,8 +89,8 @@ internal static class RustCargoLockParser
|
||||
if (trimmed[0] == '[')
|
||||
{
|
||||
// Entering a new table; finish any pending package and skip section.
|
||||
FlushCurrent(builder, packages);
|
||||
builder = null;
|
||||
FlushCurrent(packageBuilder, resultBuilder);
|
||||
packageBuilder = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
|
||||
| Seq | ID | Status | Depends on | Description | Exit Criteria |
|
||||
|-----|----|--------|------------|-------------|---------------|
|
||||
| 5 | SCANNER-ANALYZERS-LANG-10-308R | TODO | SCANNER-ANALYZERS-LANG-10-307R | Determinism fixtures + performance benchmarks; compare against competitor heuristic coverage. | Fixtures `Fixtures/lang/rust/` committed; determinism guard; benchmark shows ≥15 % better coverage vs competitor. |
|
||||
| 6 | SCANNER-ANALYZERS-LANG-10-309R | TODO | SCANNER-ANALYZERS-LANG-10-308R | Package plug-in manifest + Offline Kit documentation; ensure Worker integration. | Manifest copied; Worker loads analyzer; Offline Kit doc updated. |
|
||||
| 5 | SCANNER-ANALYZERS-LANG-10-308R | DONE | SCANNER-ANALYZERS-LANG-10-307R | Determinism fixtures + performance benchmarks; compare against competitor heuristic coverage. | Fixtures `Fixtures/lang/rust/` committed; determinism guard; benchmark shows ≥15 % better coverage vs competitor. |
|
||||
| 6 | SCANNER-ANALYZERS-LANG-10-309R | DONE | SCANNER-ANALYZERS-LANG-10-308R | Package plug-in manifest + Offline Kit documentation; ensure Worker integration. | Manifest copied; Worker loads analyzer; Offline Kit doc updated. |
|
||||
|
||||
@@ -13,4 +13,6 @@ public static class ScanAnalysisKeys
|
||||
public const string LanguageComponentFragments = "analysis.lang.fragments";
|
||||
|
||||
public const string EntryTraceGraph = "analysis.entrytrace.graph";
|
||||
|
||||
public const string EntryTraceNdjson = "analysis.entrytrace.ndjson";
|
||||
}
|
||||
|
||||
@@ -7,4 +7,5 @@ public static class ScanMetadataKeys
|
||||
public const string ImageConfigPath = "scanner.image.config.path";
|
||||
public const string LayerDirectories = "scanner.rootfs.layers";
|
||||
public const string LayerArchives = "scanner.layer.archives";
|
||||
public const string RuntimeProcRoot = "scanner.runtime.proc_root";
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.EntryTrace.Diagnostics;
|
||||
using StellaOps.Scanner.EntryTrace.Parsing;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.EntryTrace.Diagnostics;
|
||||
using StellaOps.Scanner.EntryTrace.FileSystem;
|
||||
using StellaOps.Scanner.EntryTrace.Parsing;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace;
|
||||
|
||||
@@ -73,13 +76,17 @@ public sealed class EntryTraceAnalyzer : IEntryTraceAnalyzer
|
||||
{
|
||||
private readonly EntrypointSpecification _entrypoint;
|
||||
private readonly EntryTraceContext _context;
|
||||
private readonly EntryTraceAnalyzerOptions _options;
|
||||
private readonly EntryTraceMetrics _metrics;
|
||||
private readonly ILogger _logger;
|
||||
private readonly ImmutableArray<string> _pathEntries;
|
||||
private readonly List<EntryTraceNode> _nodes = new();
|
||||
private readonly List<EntryTraceEdge> _edges = new();
|
||||
private readonly List<EntryTraceDiagnostic> _diagnostics = new();
|
||||
private readonly EntryTraceAnalyzerOptions _options;
|
||||
private readonly EntryTraceMetrics _metrics;
|
||||
private readonly ILogger _logger;
|
||||
private readonly ImmutableArray<string> _pathEntries;
|
||||
private readonly ImmutableArray<EntryTraceCandidate> _candidates;
|
||||
private readonly List<EntryTraceNode> _nodes = new();
|
||||
private readonly List<EntryTraceEdge> _edges = new();
|
||||
private readonly List<EntryTraceDiagnostic> _diagnostics = new();
|
||||
private readonly List<EntryTracePlan> _plans = new();
|
||||
private readonly List<EntryTraceTerminal> _terminals = new();
|
||||
private readonly HashSet<string> _terminalKeys = new(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> _visitedScripts = new(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> _visitedCommands = new(StringComparer.Ordinal);
|
||||
private int _nextNodeId = 1;
|
||||
@@ -89,15 +96,16 @@ public sealed class EntryTraceAnalyzer : IEntryTraceAnalyzer
|
||||
EntryTraceContext context,
|
||||
EntryTraceAnalyzerOptions options,
|
||||
EntryTraceMetrics metrics,
|
||||
ILogger logger)
|
||||
{
|
||||
_entrypoint = entrypoint;
|
||||
_context = context;
|
||||
_options = options;
|
||||
_metrics = metrics;
|
||||
_logger = logger;
|
||||
_pathEntries = DeterminePath(context);
|
||||
}
|
||||
ILogger logger)
|
||||
{
|
||||
_entrypoint = entrypoint;
|
||||
_context = context;
|
||||
_options = options;
|
||||
_metrics = metrics;
|
||||
_logger = logger;
|
||||
_pathEntries = DeterminePath(context);
|
||||
_candidates = context.Candidates;
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> DeterminePath(EntryTraceContext context)
|
||||
{
|
||||
@@ -114,46 +122,65 @@ public sealed class EntryTraceAnalyzer : IEntryTraceAnalyzer
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
public EntryTraceGraph BuildGraph()
|
||||
{
|
||||
var initialArgs = ComposeInitialCommand(_entrypoint);
|
||||
if (initialArgs.Length == 0)
|
||||
{
|
||||
_diagnostics.Add(new EntryTraceDiagnostic(
|
||||
EntryTraceDiagnosticSeverity.Error,
|
||||
EntryTraceUnknownReason.CommandNotFound,
|
||||
"ENTRYPOINT/CMD yielded no executable command.",
|
||||
Span: null,
|
||||
RelatedPath: null));
|
||||
return ToGraph(EntryTraceOutcome.Unresolved);
|
||||
}
|
||||
public EntryTraceGraph BuildGraph()
|
||||
{
|
||||
var initialArgs = ComposeInitialCommand(_entrypoint);
|
||||
if (initialArgs.Length == 0)
|
||||
{
|
||||
if (_candidates.Length == 0)
|
||||
{
|
||||
_diagnostics.Add(new EntryTraceDiagnostic(
|
||||
EntryTraceDiagnosticSeverity.Warning,
|
||||
EntryTraceUnknownReason.CommandNotFound,
|
||||
"No ENTRYPOINT/CMD declared and no fallback candidates were discovered.",
|
||||
Span: null,
|
||||
RelatedPath: null));
|
||||
return ToGraph(DetermineOutcome());
|
||||
}
|
||||
|
||||
foreach (var candidate in _candidates)
|
||||
{
|
||||
_diagnostics.Add(new EntryTraceDiagnostic(
|
||||
EntryTraceDiagnosticSeverity.Info,
|
||||
MapCandidateReason(candidate.Source),
|
||||
CreateCandidateMessage(candidate),
|
||||
Span: null,
|
||||
RelatedPath: candidate.Evidence?.Path));
|
||||
|
||||
ResolveCommand(candidate.Command, parent: null, originSpan: null, depth: 0, relationship: candidate.Source);
|
||||
}
|
||||
|
||||
return ToGraph(DetermineOutcome());
|
||||
}
|
||||
|
||||
ResolveCommand(initialArgs, parent: null, originSpan: null, depth: 0, relationship: "entrypoint");
|
||||
|
||||
var outcome = DetermineOutcome();
|
||||
return ToGraph(outcome);
|
||||
}
|
||||
|
||||
private EntryTraceOutcome DetermineOutcome()
|
||||
{
|
||||
var hasErrors = _diagnostics.Any(d => d.Severity == EntryTraceDiagnosticSeverity.Error);
|
||||
if (hasErrors)
|
||||
{
|
||||
return EntryTraceOutcome.Unresolved;
|
||||
}
|
||||
|
||||
var hasWarnings = _diagnostics.Any(d => d.Severity == EntryTraceDiagnosticSeverity.Warning);
|
||||
return hasWarnings ? EntryTraceOutcome.PartiallyResolved : EntryTraceOutcome.Resolved;
|
||||
}
|
||||
|
||||
ResolveCommand(initialArgs, parent: null, originSpan: null, depth: 0, relationship: "entrypoint");
|
||||
|
||||
var outcome = DetermineOutcome();
|
||||
return ToGraph(outcome);
|
||||
}
|
||||
|
||||
private EntryTraceOutcome DetermineOutcome()
|
||||
{
|
||||
if (_diagnostics.Count == 0)
|
||||
{
|
||||
return EntryTraceOutcome.Resolved;
|
||||
}
|
||||
|
||||
return _diagnostics.Any(d => d.Severity == EntryTraceDiagnosticSeverity.Error)
|
||||
? EntryTraceOutcome.Unresolved
|
||||
: EntryTraceOutcome.PartiallyResolved;
|
||||
}
|
||||
|
||||
private EntryTraceGraph ToGraph(EntryTraceOutcome outcome)
|
||||
{
|
||||
return new EntryTraceGraph(
|
||||
outcome,
|
||||
_nodes.ToImmutableArray(),
|
||||
_edges.ToImmutableArray(),
|
||||
_diagnostics.ToImmutableArray());
|
||||
}
|
||||
private EntryTraceGraph ToGraph(EntryTraceOutcome outcome)
|
||||
{
|
||||
return new EntryTraceGraph(
|
||||
outcome,
|
||||
_nodes.ToImmutableArray(),
|
||||
_edges.ToImmutableArray(),
|
||||
_diagnostics.ToImmutableArray(),
|
||||
_plans.ToImmutableArray(),
|
||||
_terminals.ToImmutableArray());
|
||||
}
|
||||
|
||||
private ImmutableArray<string> ComposeInitialCommand(EntrypointSpecification specification)
|
||||
{
|
||||
@@ -185,17 +212,17 @@ public sealed class EntryTraceAnalyzer : IEntryTraceAnalyzer
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
private void ResolveCommand(
|
||||
ImmutableArray<string> arguments,
|
||||
EntryTraceNode? parent,
|
||||
EntryTraceSpan? originSpan,
|
||||
int depth,
|
||||
string relationship)
|
||||
{
|
||||
if (arguments.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
private void ResolveCommand(
|
||||
ImmutableArray<string> arguments,
|
||||
EntryTraceNode? parent,
|
||||
EntryTraceSpan? originSpan,
|
||||
int depth,
|
||||
string relationship)
|
||||
{
|
||||
if (arguments.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (depth >= _options.MaxDepth)
|
||||
{
|
||||
@@ -242,18 +269,19 @@ public sealed class EntryTraceAnalyzer : IEntryTraceAnalyzer
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryFollowInterpreter(node, descriptor, arguments, depth))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryFollowShell(node, descriptor, arguments, depth))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Terminal executable.
|
||||
}
|
||||
if (TryFollowInterpreter(node, descriptor, arguments, depth))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryFollowShell(node, descriptor, arguments, depth))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ClassifyTerminal(node, descriptor, arguments);
|
||||
// Terminal executable.
|
||||
}
|
||||
|
||||
private bool TryResolveExecutable(
|
||||
string commandName,
|
||||
@@ -497,16 +525,16 @@ public sealed class EntryTraceAnalyzer : IEntryTraceAnalyzer
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool HandleJava(
|
||||
EntryTraceNode node,
|
||||
ImmutableArray<string> arguments,
|
||||
RootFileDescriptor descriptor,
|
||||
int depth)
|
||||
{
|
||||
if (arguments.Length < 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
private bool HandleJava(
|
||||
EntryTraceNode node,
|
||||
ImmutableArray<string> arguments,
|
||||
RootFileDescriptor descriptor,
|
||||
int depth)
|
||||
{
|
||||
if (arguments.Length < 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string? jar = null;
|
||||
string? mainClass = null;
|
||||
@@ -526,40 +554,42 @@ public sealed class EntryTraceAnalyzer : IEntryTraceAnalyzer
|
||||
}
|
||||
}
|
||||
|
||||
if (jar is not null)
|
||||
{
|
||||
if (!_context.FileSystem.TryResolveExecutable(jar, Array.Empty<string>(), out var jarDescriptor))
|
||||
{
|
||||
_diagnostics.Add(new EntryTraceDiagnostic(
|
||||
EntryTraceDiagnosticSeverity.Warning,
|
||||
EntryTraceUnknownReason.JarNotFound,
|
||||
$"Java JAR '{jar}' not found.",
|
||||
Span: null,
|
||||
RelatedPath: jar));
|
||||
}
|
||||
else
|
||||
{
|
||||
var jarNode = AddNode(
|
||||
EntryTraceNodeKind.Executable,
|
||||
jarDescriptor.Path,
|
||||
ImmutableArray<string>.Empty,
|
||||
EntryTraceInterpreterKind.Java,
|
||||
new EntryTraceEvidence(jarDescriptor.Path, jarDescriptor.LayerDigest, "jar", null),
|
||||
null);
|
||||
_edges.Add(new EntryTraceEdge(node.Id, jarNode.Id, "executes", null));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (mainClass is not null)
|
||||
{
|
||||
_edges.Add(new EntryTraceEdge(node.Id, node.Id, "java-main", new Dictionary<string, string>
|
||||
{
|
||||
["class"] = mainClass
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
if (jar is not null)
|
||||
{
|
||||
if (!_context.FileSystem.TryResolveExecutable(jar, _pathEntries, out var jarDescriptor) &&
|
||||
!_context.FileSystem.TryResolveExecutable(jar, Array.Empty<string>(), out jarDescriptor))
|
||||
{
|
||||
_diagnostics.Add(new EntryTraceDiagnostic(
|
||||
EntryTraceDiagnosticSeverity.Warning,
|
||||
EntryTraceUnknownReason.JarNotFound,
|
||||
$"Java JAR '{jar}' not found.",
|
||||
Span: null,
|
||||
RelatedPath: jar));
|
||||
return true;
|
||||
}
|
||||
|
||||
var jarNode = AddNode(
|
||||
EntryTraceNodeKind.Executable,
|
||||
jarDescriptor.Path,
|
||||
ImmutableArray<string>.Empty,
|
||||
EntryTraceInterpreterKind.Java,
|
||||
new EntryTraceEvidence(jarDescriptor.Path, jarDescriptor.LayerDigest, "jar", null),
|
||||
null);
|
||||
|
||||
_edges.Add(new EntryTraceEdge(node.Id, jarNode.Id, "executes", null));
|
||||
ClassifyTerminal(jarNode, jarDescriptor, arguments);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (mainClass is not null)
|
||||
{
|
||||
_edges.Add(new EntryTraceEdge(node.Id, node.Id, "java-main", new Dictionary<string, string>
|
||||
{
|
||||
["class"] = mainClass
|
||||
}));
|
||||
ClassifyTerminal(node, descriptor, arguments);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1018,26 +1048,325 @@ public sealed class EntryTraceAnalyzer : IEntryTraceAnalyzer
|
||||
return content.Contains("#!/bin/sh", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private EntryTraceNode AddNode(
|
||||
EntryTraceNodeKind kind,
|
||||
string displayName,
|
||||
ImmutableArray<string> arguments,
|
||||
EntryTraceInterpreterKind interpreterKind,
|
||||
EntryTraceEvidence? evidence,
|
||||
EntryTraceSpan? span)
|
||||
{
|
||||
var node = new EntryTraceNode(
|
||||
_nextNodeId++,
|
||||
kind,
|
||||
displayName,
|
||||
arguments,
|
||||
interpreterKind,
|
||||
evidence,
|
||||
span);
|
||||
_nodes.Add(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
private EntryTraceNode AddNode(
|
||||
EntryTraceNodeKind kind,
|
||||
string displayName,
|
||||
ImmutableArray<string> arguments,
|
||||
EntryTraceInterpreterKind interpreterKind,
|
||||
EntryTraceEvidence? evidence,
|
||||
EntryTraceSpan? span,
|
||||
ImmutableDictionary<string, string>? metadata = null)
|
||||
{
|
||||
var node = new EntryTraceNode(
|
||||
_nextNodeId++,
|
||||
kind,
|
||||
displayName,
|
||||
arguments,
|
||||
interpreterKind,
|
||||
evidence,
|
||||
span,
|
||||
metadata);
|
||||
_nodes.Add(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
private void ClassifyTerminal(
|
||||
EntryTraceNode node,
|
||||
RootFileDescriptor descriptor,
|
||||
ImmutableArray<string> arguments)
|
||||
{
|
||||
var signature = CreateCommandSignature(arguments, node.DisplayName);
|
||||
var key = $"{descriptor.Path}|{_context.User}|{_context.WorkingDirectory}|{signature}";
|
||||
if (!_terminalKeys.Add(key))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var evidence = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
double score = descriptor.IsExecutable ? 50d : 40d;
|
||||
string? runtime = null;
|
||||
var type = EntryTraceTerminalType.Unknown;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(descriptor.ShebangInterpreter))
|
||||
{
|
||||
var shebang = descriptor.ShebangInterpreter!;
|
||||
evidence["shebang"] = shebang;
|
||||
runtime = InferRuntimeFromShebang(shebang);
|
||||
type = EntryTraceTerminalType.Script;
|
||||
score += 15d;
|
||||
}
|
||||
|
||||
if (_context.FileSystem.TryReadBytes(descriptor.Path, 2_097_152, out _, out var binaryContent))
|
||||
{
|
||||
var span = binaryContent.Span;
|
||||
if (TryClassifyElf(span, evidence, ref runtime))
|
||||
{
|
||||
type = EntryTraceTerminalType.Native;
|
||||
score += 15d;
|
||||
}
|
||||
else if (TryClassifyPe(span, evidence, ref runtime))
|
||||
{
|
||||
type = EntryTraceTerminalType.Managed;
|
||||
score += 15d;
|
||||
}
|
||||
else if (IsZipArchive(span))
|
||||
{
|
||||
runtime ??= "java";
|
||||
type = EntryTraceTerminalType.Managed;
|
||||
score += 10d;
|
||||
if (TryReadJarManifest(span, descriptor.Path, evidence))
|
||||
{
|
||||
score += 5d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runtime ??= InferRuntimeFromCommand(node.DisplayName, arguments);
|
||||
|
||||
if (runtime is "go" or "rust")
|
||||
{
|
||||
type = EntryTraceTerminalType.Native;
|
||||
score += 10d;
|
||||
}
|
||||
else if (runtime is ".net" or "java" or "python" or "node" or "ruby" or "php" or "php-fpm")
|
||||
{
|
||||
if (type == EntryTraceTerminalType.Unknown)
|
||||
{
|
||||
type = EntryTraceTerminalType.Managed;
|
||||
}
|
||||
|
||||
score += 5d;
|
||||
}
|
||||
|
||||
if (runtime is "shell")
|
||||
{
|
||||
type = EntryTraceTerminalType.Script;
|
||||
}
|
||||
|
||||
if (type == EntryTraceTerminalType.Unknown)
|
||||
{
|
||||
type = EntryTraceTerminalType.Native;
|
||||
}
|
||||
|
||||
var boundedScore = Math.Min(95d, score);
|
||||
var terminal = new EntryTraceTerminal(
|
||||
descriptor.Path,
|
||||
type,
|
||||
runtime,
|
||||
boundedScore,
|
||||
evidence.ToImmutable(),
|
||||
_context.User,
|
||||
_context.WorkingDirectory,
|
||||
arguments.IsDefault ? ImmutableArray<string>.Empty : arguments);
|
||||
|
||||
var plan = new EntryTracePlan(
|
||||
terminal.Arguments,
|
||||
_context.Environment,
|
||||
_context.WorkingDirectory,
|
||||
_context.User,
|
||||
terminal.Path,
|
||||
terminal.Type,
|
||||
terminal.Runtime,
|
||||
terminal.Confidence,
|
||||
terminal.Evidence);
|
||||
|
||||
_terminals.Add(terminal);
|
||||
_plans.Add(plan);
|
||||
}
|
||||
|
||||
private static string CreateCommandSignature(ImmutableArray<string> command, string displayName)
|
||||
{
|
||||
if (command.IsDefaultOrEmpty || command.Length == 0)
|
||||
{
|
||||
return displayName;
|
||||
}
|
||||
|
||||
return string.Join('\u001F', command);
|
||||
}
|
||||
|
||||
private static EntryTraceUnknownReason MapCandidateReason(string source)
|
||||
=> source switch
|
||||
{
|
||||
"history" => EntryTraceUnknownReason.InferredEntrypointFromHistory,
|
||||
"service-directory" => EntryTraceUnknownReason.InferredEntrypointFromServices,
|
||||
"supervisor" => EntryTraceUnknownReason.InferredEntrypointFromSupervisor,
|
||||
"entrypoint-script" => EntryTraceUnknownReason.InferredEntrypointFromEntrypointScript,
|
||||
_ => EntryTraceUnknownReason.CommandNotFound
|
||||
};
|
||||
|
||||
private static string CreateCandidateMessage(EntryTraceCandidate candidate)
|
||||
{
|
||||
var primary = candidate.Command.Length > 0 ? candidate.Command[0] : candidate.Source;
|
||||
return candidate.Source switch
|
||||
{
|
||||
"history" => "Inferred entrypoint from image history.",
|
||||
"service-directory" => $"Inferred service run script '{primary}'.",
|
||||
"supervisor" => candidate.Description is null
|
||||
? "Inferred supervisor command."
|
||||
: $"Inferred supervisor program '{candidate.Description}'.",
|
||||
"entrypoint-script" => $"Inferred entrypoint script '{primary}'.",
|
||||
_ => "Inferred entrypoint candidate."
|
||||
};
|
||||
}
|
||||
|
||||
private static string? InferRuntimeFromShebang(string shebang)
|
||||
{
|
||||
var normalized = shebang.ToLowerInvariant();
|
||||
if (normalized.Contains("python"))
|
||||
{
|
||||
return "python";
|
||||
}
|
||||
|
||||
if (normalized.Contains("node"))
|
||||
{
|
||||
return "node";
|
||||
}
|
||||
|
||||
if (normalized.Contains("ruby"))
|
||||
{
|
||||
return "ruby";
|
||||
}
|
||||
|
||||
if (normalized.Contains("php-fpm"))
|
||||
{
|
||||
return "php-fpm";
|
||||
}
|
||||
|
||||
if (normalized.Contains("php"))
|
||||
{
|
||||
return "php";
|
||||
}
|
||||
|
||||
if (normalized.Contains("sh") || normalized.Contains("bash"))
|
||||
{
|
||||
return "shell";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? InferRuntimeFromCommand(string commandName, ImmutableArray<string> arguments)
|
||||
{
|
||||
var normalized = commandName.ToLowerInvariant();
|
||||
if (normalized == "java" || arguments.Any(arg => arg.Equals("-jar", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return "java";
|
||||
}
|
||||
|
||||
if (normalized.Contains("dotnet") || normalized.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ".net";
|
||||
}
|
||||
|
||||
if (normalized.Contains("python") || normalized.EndsWith(".py", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "python";
|
||||
}
|
||||
|
||||
if (normalized.Contains("node") || normalized.EndsWith(".js", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "node";
|
||||
}
|
||||
|
||||
if (normalized.Contains("go"))
|
||||
{
|
||||
return "go";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool TryClassifyElf(ReadOnlySpan<byte> span, ImmutableDictionary<string, string>.Builder evidence, ref string? runtime)
|
||||
{
|
||||
if (span.Length < 4 || span[0] != 0x7F || span[1] != (byte)'E' || span[2] != (byte)'L' || span[3] != (byte)'F')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
evidence["binary.format"] = "ELF";
|
||||
|
||||
if (ContainsAscii(span, "Go build ID") || ContainsAscii(span, ".gopclntab"))
|
||||
{
|
||||
runtime = "go";
|
||||
evidence["runtime"] = "go";
|
||||
}
|
||||
else if (ContainsAscii(span, "rust_eh_personality") || ContainsAscii(span, ".rustc"))
|
||||
{
|
||||
runtime = "rust";
|
||||
evidence["runtime"] = "rust";
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ContainsAscii(ReadOnlySpan<byte> span, string value)
|
||||
{
|
||||
var bytes = Encoding.ASCII.GetBytes(value);
|
||||
return span.IndexOf(bytes) >= 0;
|
||||
}
|
||||
|
||||
private bool TryClassifyPe(ReadOnlySpan<byte> span, ImmutableDictionary<string, string>.Builder evidence, ref string? runtime)
|
||||
{
|
||||
if (span.Length < 2 || span[0] != 'M' || span[1] != 'Z')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
evidence["binary.format"] = "PE";
|
||||
if (ContainsAscii(span, "BSJB") || ContainsAscii(span, "CLR"))
|
||||
{
|
||||
runtime = ".net";
|
||||
evidence["pe.cli"] = "true";
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryReadJarManifest(ReadOnlySpan<byte> span, string path, ImmutableDictionary<string, string>.Builder evidence)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = new MemoryStream(span.ToArray(), writable: false);
|
||||
using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: false);
|
||||
var manifestEntry = archive.GetEntry("META-INF/MANIFEST.MF");
|
||||
if (manifestEntry is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using var reader = new StreamReader(manifestEntry.Open(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
||||
var content = reader.ReadToEnd();
|
||||
evidence["jar.manifest"] = "true";
|
||||
|
||||
foreach (var line in content.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var separator = line.IndexOf(':');
|
||||
if (separator <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = line[..separator].Trim();
|
||||
var value = line[(separator + 1)..].Trim();
|
||||
|
||||
if (key.Equals("Main-Class", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
evidence["jar.main-class"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to read jar manifest for {JarPath}.", path);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsZipArchive(ReadOnlySpan<byte> span)
|
||||
=> span.Length >= 4 && span[0] == 0x50 && span[1] == 0x4B && span[2] == 0x03 && span[3] == 0x04;
|
||||
|
||||
private static string CombineUnixPath(string baseDirectory, string relative)
|
||||
{
|
||||
var normalizedBase = NormalizeUnixPath(baseDirectory);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace;
|
||||
|
||||
public sealed record EntryTraceCacheEnvelope(
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("options")] string OptionsFingerprint,
|
||||
[property: JsonPropertyName("graph")] EntryTraceGraph Graph);
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace;
|
||||
|
||||
internal static class EntryTraceCacheSerializer
|
||||
{
|
||||
public const string CurrentVersion = "entrytrace.v1";
|
||||
|
||||
public static byte[] Serialize(EntryTraceCacheEnvelope envelope)
|
||||
{
|
||||
if (envelope is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(envelope));
|
||||
}
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(envelope);
|
||||
}
|
||||
|
||||
public static EntryTraceCacheEnvelope Deserialize(byte[] payload)
|
||||
{
|
||||
if (payload is null || payload.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("Payload cannot be empty.", nameof(payload));
|
||||
}
|
||||
|
||||
var envelope = JsonSerializer.Deserialize<EntryTraceCacheEnvelope>(payload);
|
||||
if (envelope is null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to deserialize entry trace cache envelope.");
|
||||
}
|
||||
|
||||
return envelope;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.EntryTrace.FileSystem;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace;
|
||||
|
||||
/// <summary>
|
||||
/// Provides runtime context for entry trace analysis.
|
||||
@@ -14,4 +15,7 @@ public sealed record EntryTraceContext(
|
||||
string User,
|
||||
string ImageDigest,
|
||||
string ScanId,
|
||||
ILogger? Logger);
|
||||
ILogger? Logger)
|
||||
{
|
||||
public ImmutableArray<EntryTraceCandidate> Candidates { get; init; } = ImmutableArray<EntryTraceCandidate>.Empty;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.EntryTrace.FileSystem;
|
||||
using StellaOps.Scanner.EntryTrace.Parsing;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace;
|
||||
|
||||
/// <summary>
|
||||
/// Combines OCI configuration and root filesystem data into the context required by the EntryTrace analyzer.
|
||||
@@ -31,22 +36,447 @@ public static class EntryTraceImageContextFactory
|
||||
var workingDir = NormalizeWorkingDirectory(config.WorkingDirectory);
|
||||
var user = NormalizeUser(config.User);
|
||||
|
||||
var context = new EntryTraceContext(
|
||||
fileSystem,
|
||||
environment,
|
||||
path,
|
||||
workingDir,
|
||||
user,
|
||||
imageDigest,
|
||||
scanId,
|
||||
logger);
|
||||
|
||||
var entrypoint = EntrypointSpecification.FromExecForm(
|
||||
config.Entrypoint.IsDefaultOrEmpty ? null : config.Entrypoint,
|
||||
config.Command.IsDefaultOrEmpty ? null : config.Command);
|
||||
|
||||
return new EntryTraceImageContext(entrypoint, context);
|
||||
}
|
||||
var context = new EntryTraceContext(
|
||||
fileSystem,
|
||||
environment,
|
||||
path,
|
||||
workingDir,
|
||||
user,
|
||||
imageDigest,
|
||||
scanId,
|
||||
logger);
|
||||
|
||||
var candidates = BuildFallbackCandidates(config, fileSystem, logger);
|
||||
context = context with { Candidates = candidates };
|
||||
|
||||
var entrypoint = EntrypointSpecification.FromExecForm(
|
||||
config.Entrypoint.IsDefaultOrEmpty ? null : config.Entrypoint,
|
||||
config.Command.IsDefaultOrEmpty ? null : config.Command);
|
||||
|
||||
return new EntryTraceImageContext(entrypoint, context);
|
||||
}
|
||||
|
||||
private static ImmutableArray<EntryTraceCandidate> BuildFallbackCandidates(
|
||||
OciImageConfig config,
|
||||
IRootFileSystem fileSystem,
|
||||
ILogger? logger)
|
||||
{
|
||||
if (config.Entrypoint.Length > 0 || config.Command.Length > 0)
|
||||
{
|
||||
return ImmutableArray<EntryTraceCandidate>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<EntryTraceCandidate>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
void AddCandidate(ImmutableArray<string> command, string source, EntryTraceEvidence? evidence, string? description)
|
||||
{
|
||||
if (command.IsDefaultOrEmpty || command.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var signature = CreateSignature(command);
|
||||
if (!seen.Add(signature))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
builder.Add(new EntryTraceCandidate(command, source, evidence, description));
|
||||
}
|
||||
|
||||
if (TryAddHistoryCandidate(config, AddCandidate, logger))
|
||||
{
|
||||
// Preserve first viable history candidate only.
|
||||
}
|
||||
|
||||
CollectEntrypointScripts(fileSystem, AddCandidate);
|
||||
CollectSupervisorCommands(fileSystem, AddCandidate);
|
||||
CollectServiceRunScripts(fileSystem, AddCandidate);
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static bool TryAddHistoryCandidate(
|
||||
OciImageConfig config,
|
||||
Action<ImmutableArray<string>, string, EntryTraceEvidence?, string?> addCandidate,
|
||||
ILogger? logger)
|
||||
{
|
||||
if (config.History.IsDefaultOrEmpty || config.History.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var i = config.History.Length - 1; i >= 0; i--)
|
||||
{
|
||||
var entry = config.History[i];
|
||||
if (TryExtractHistoryCommand(entry?.CreatedBy, out var command))
|
||||
{
|
||||
var metadata = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
if (!string.IsNullOrWhiteSpace(entry?.CreatedBy))
|
||||
{
|
||||
metadata["created_by"] = entry!.CreatedBy!;
|
||||
}
|
||||
|
||||
var evidence = new EntryTraceEvidence(
|
||||
Path: "/image/history",
|
||||
LayerDigest: null,
|
||||
Source: "history",
|
||||
metadata.Count > 0 ? metadata.ToImmutable() : null);
|
||||
|
||||
addCandidate(command, "history", evidence, null);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void CollectEntrypointScripts(
|
||||
IRootFileSystem fileSystem,
|
||||
Action<ImmutableArray<string>, string, EntryTraceEvidence?, string?> addCandidate)
|
||||
{
|
||||
const string directory = "/usr/local/bin";
|
||||
if (!fileSystem.DirectoryExists(directory))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var entry in fileSystem.EnumerateDirectory(directory))
|
||||
{
|
||||
if (entry.IsDirectory || !entry.IsExecutable)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = Path.GetFileName(entry.Path);
|
||||
if (!name.Contains("entrypoint", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var evidence = new EntryTraceEvidence(entry.Path, entry.LayerDigest, "entrypoint-script", null);
|
||||
addCandidate(ImmutableArray.Create(entry.Path), "entrypoint-script", evidence, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CollectSupervisorCommands(
|
||||
IRootFileSystem fileSystem,
|
||||
Action<ImmutableArray<string>, string, EntryTraceEvidence?, string?> addCandidate)
|
||||
{
|
||||
const string root = "/etc/supervisor";
|
||||
if (!fileSystem.DirectoryExists(root))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pending = new Stack<string>();
|
||||
pending.Push(root);
|
||||
|
||||
while (pending.Count > 0)
|
||||
{
|
||||
var current = pending.Pop();
|
||||
foreach (var entry in fileSystem.EnumerateDirectory(current))
|
||||
{
|
||||
if (entry.IsDirectory)
|
||||
{
|
||||
pending.Push(entry.Path);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.Path.EndsWith(".conf", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fileSystem.TryReadAllText(entry.Path, out var descriptor, out var content))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var (command, program) in ExtractSupervisorCommands(content))
|
||||
{
|
||||
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
if (!string.IsNullOrWhiteSpace(program))
|
||||
{
|
||||
metadataBuilder["program"] = program!;
|
||||
}
|
||||
|
||||
var evidence = new EntryTraceEvidence(
|
||||
entry.Path,
|
||||
descriptor.LayerDigest,
|
||||
"supervisor",
|
||||
metadataBuilder.Count > 0 ? metadataBuilder.ToImmutable() : null);
|
||||
|
||||
addCandidate(command, "supervisor", evidence, program);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void CollectServiceRunScripts(
|
||||
IRootFileSystem fileSystem,
|
||||
Action<ImmutableArray<string>, string, EntryTraceEvidence?, string?> addCandidate)
|
||||
{
|
||||
string[] roots =
|
||||
{
|
||||
"/etc/services.d",
|
||||
"/etc/services",
|
||||
"/service",
|
||||
"/s6",
|
||||
"/etc/s6",
|
||||
"/etc/s6-overlay/s6-rc.d"
|
||||
};
|
||||
|
||||
foreach (var root in roots)
|
||||
{
|
||||
if (!fileSystem.DirectoryExists(root))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var pending = new Stack<string>();
|
||||
pending.Push(root);
|
||||
|
||||
while (pending.Count > 0)
|
||||
{
|
||||
var current = pending.Pop();
|
||||
foreach (var entry in fileSystem.EnumerateDirectory(current))
|
||||
{
|
||||
if (entry.IsDirectory)
|
||||
{
|
||||
pending.Push(entry.Path);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.IsExecutable)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.Path.EndsWith("/run", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var metadata = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
var directory = Path.GetDirectoryName(entry.Path);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
metadata["service_dir"] = directory!.Replace('\\', '/');
|
||||
}
|
||||
|
||||
var evidence = new EntryTraceEvidence(
|
||||
entry.Path,
|
||||
entry.LayerDigest,
|
||||
"service-directory",
|
||||
metadata.Count > 0 ? metadata.ToImmutable() : null);
|
||||
addCandidate(ImmutableArray.Create(entry.Path), "service-directory", evidence, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<(ImmutableArray<string> Command, string? Program)> ExtractSupervisorCommands(string content)
|
||||
{
|
||||
var results = new List<(ImmutableArray<string>, string?)>();
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return results;
|
||||
}
|
||||
|
||||
string? currentProgram = null;
|
||||
foreach (var rawLine in content.Split('\n'))
|
||||
{
|
||||
var line = rawLine.Trim();
|
||||
if (line.Length == 0 || line.StartsWith("#", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith("[", StringComparison.Ordinal) && line.EndsWith("]", StringComparison.Ordinal))
|
||||
{
|
||||
var section = line[1..^1].Trim();
|
||||
if (section.StartsWith("program:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
currentProgram = section["program:".Length..].Trim();
|
||||
}
|
||||
else
|
||||
{
|
||||
currentProgram = null;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.StartsWith("command", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var equalsIndex = line.IndexOf('=', StringComparison.Ordinal);
|
||||
if (equalsIndex < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var commandText = line[(equalsIndex + 1)..].Trim();
|
||||
if (commandText.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryTokenizeShellCommand(commandText, out var command))
|
||||
{
|
||||
results.Add((command, currentProgram));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static bool TryExtractHistoryCommand(string? createdBy, out ImmutableArray<string> command)
|
||||
{
|
||||
command = ImmutableArray<string>.Empty;
|
||||
if (string.IsNullOrWhiteSpace(createdBy))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var text = createdBy.Trim();
|
||||
|
||||
if (TryParseHistoryJsonCommand(text, "CMD", out command) ||
|
||||
TryParseHistoryJsonCommand(text, "ENTRYPOINT", out command))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var shIndex = text.IndexOf("/bin/sh", StringComparison.OrdinalIgnoreCase);
|
||||
if (shIndex >= 0)
|
||||
{
|
||||
var dashC = text.IndexOf("-c", shIndex, StringComparison.OrdinalIgnoreCase);
|
||||
if (dashC >= 0)
|
||||
{
|
||||
var after = text[(dashC + 2)..].Trim();
|
||||
if (after.StartsWith("#(nop)", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
after = after[6..].Trim();
|
||||
}
|
||||
|
||||
if (after.StartsWith("CMD", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
after = after[3..].Trim();
|
||||
}
|
||||
else if (after.StartsWith("ENTRYPOINT", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
after = after[10..].Trim();
|
||||
}
|
||||
|
||||
if (after.StartsWith("[", StringComparison.Ordinal))
|
||||
{
|
||||
if (TryParseJsonArray(after, out command))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
else if (TryTokenizeShellCommand(after, out command))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryParseHistoryJsonCommand(string text, string keyword, out ImmutableArray<string> command)
|
||||
{
|
||||
command = ImmutableArray<string>.Empty;
|
||||
var index = text.IndexOf(keyword, StringComparison.OrdinalIgnoreCase);
|
||||
if (index < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var bracket = text.IndexOf('[', index);
|
||||
if (bracket < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var end = text.IndexOf(']', bracket);
|
||||
if (end < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var json = text.Substring(bracket, end - bracket + 1);
|
||||
return TryParseJsonArray(json, out command);
|
||||
}
|
||||
|
||||
private static bool TryParseJsonArray(string json, out ImmutableArray<string> command)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
command = ImmutableArray<string>.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<string>();
|
||||
foreach (var element in document.RootElement.EnumerateArray())
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
builder.Add(element.GetString() ?? string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
command = builder.ToImmutable();
|
||||
return command.Length > 0;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
command = ImmutableArray<string>.Empty;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryTokenizeShellCommand(string commandText, out ImmutableArray<string> command)
|
||||
{
|
||||
var tokenizer = new ShellTokenizer();
|
||||
var tokens = tokenizer.Tokenize(commandText);
|
||||
var builder = ImmutableArray.CreateBuilder<string>();
|
||||
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
switch (token.Kind)
|
||||
{
|
||||
case ShellTokenKind.Word:
|
||||
case ShellTokenKind.SingleQuoted:
|
||||
case ShellTokenKind.DoubleQuoted:
|
||||
builder.Add(token.Value);
|
||||
break;
|
||||
case ShellTokenKind.Operator:
|
||||
case ShellTokenKind.NewLine:
|
||||
case ShellTokenKind.EndOfFile:
|
||||
if (builder.Count > 0)
|
||||
{
|
||||
command = builder.ToImmutable();
|
||||
return command.Length > 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
command = builder.ToImmutable();
|
||||
return command.Length > 0;
|
||||
}
|
||||
|
||||
private static string CreateSignature(ImmutableArray<string> command)
|
||||
=> string.Join('\u001F', command);
|
||||
|
||||
private static ImmutableDictionary<string, string> BuildEnvironment(ImmutableArray<string> raw)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace;
|
||||
|
||||
public sealed record EntryTraceResult(
|
||||
string ScanId,
|
||||
string ImageDigest,
|
||||
DateTimeOffset GeneratedAtUtc,
|
||||
EntryTraceGraph Graph,
|
||||
ImmutableArray<string> Ndjson);
|
||||
|
||||
public interface IEntryTraceResultStore
|
||||
{
|
||||
Task StoreAsync(EntryTraceResult result, CancellationToken cancellationToken);
|
||||
|
||||
Task<EntryTraceResult?> GetAsync(string scanId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class NullEntryTraceResultStore : IEntryTraceResultStore
|
||||
{
|
||||
public Task StoreAsync(EntryTraceResult result, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<EntryTraceResult?> GetAsync(string scanId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
|
||||
return Task.FromResult<EntryTraceResult?>(null);
|
||||
}
|
||||
}
|
||||
@@ -16,19 +16,19 @@ public enum EntryTraceOutcome
|
||||
/// <summary>
|
||||
/// Logical classification for nodes in the entry trace graph.
|
||||
/// </summary>
|
||||
public enum EntryTraceNodeKind
|
||||
{
|
||||
Command,
|
||||
Script,
|
||||
Include,
|
||||
public enum EntryTraceNodeKind
|
||||
{
|
||||
Command,
|
||||
Script,
|
||||
Include,
|
||||
Interpreter,
|
||||
Executable,
|
||||
RunPartsDirectory,
|
||||
RunPartsScript
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interpreter categories supported by the analyzer.
|
||||
RunPartsScript
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interpreter categories supported by the analyzer.
|
||||
/// </summary>
|
||||
public enum EntryTraceInterpreterKind
|
||||
{
|
||||
@@ -55,18 +55,45 @@ public enum EntryTraceUnknownReason
|
||||
{
|
||||
CommandNotFound,
|
||||
MissingFile,
|
||||
DynamicEnvironmentReference,
|
||||
UnsupportedSyntax,
|
||||
RecursionLimitReached,
|
||||
InterpreterNotSupported,
|
||||
ModuleNotFound,
|
||||
JarNotFound,
|
||||
RunPartsEmpty,
|
||||
PermissionDenied
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a span within a script file.
|
||||
DynamicEnvironmentReference,
|
||||
UnsupportedSyntax,
|
||||
RecursionLimitReached,
|
||||
InterpreterNotSupported,
|
||||
ModuleNotFound,
|
||||
JarNotFound,
|
||||
RunPartsEmpty,
|
||||
PermissionDenied,
|
||||
WrapperMissingCommand,
|
||||
SupervisorConfigMissing,
|
||||
SupervisorUnsupported,
|
||||
SupervisorProgramNotFound,
|
||||
DynamicEvaluation,
|
||||
RunPartsLimitExceeded,
|
||||
WindowsShimUnsupported,
|
||||
RuntimeSnapshotUnavailable,
|
||||
RuntimeProcessNotFound,
|
||||
RuntimeMatch,
|
||||
RuntimeMismatch,
|
||||
InferredEntrypointFromHistory,
|
||||
InferredEntrypointFromServices,
|
||||
InferredEntrypointFromSupervisor,
|
||||
InferredEntrypointFromEntrypointScript
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Categorises terminal executable kinds.
|
||||
/// </summary>
|
||||
public enum EntryTraceTerminalType
|
||||
{
|
||||
Unknown,
|
||||
Script,
|
||||
Native,
|
||||
Managed,
|
||||
Service
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a span within a script file.
|
||||
/// </summary>
|
||||
public readonly record struct EntryTraceSpan(
|
||||
string? Path,
|
||||
@@ -87,14 +114,15 @@ public sealed record EntryTraceEvidence(
|
||||
/// <summary>
|
||||
/// Represents a node in the entry trace graph.
|
||||
/// </summary>
|
||||
public sealed record EntryTraceNode(
|
||||
int Id,
|
||||
EntryTraceNodeKind Kind,
|
||||
string DisplayName,
|
||||
ImmutableArray<string> Arguments,
|
||||
EntryTraceInterpreterKind InterpreterKind,
|
||||
EntryTraceEvidence? Evidence,
|
||||
EntryTraceSpan? Span);
|
||||
public sealed record EntryTraceNode(
|
||||
int Id,
|
||||
EntryTraceNodeKind Kind,
|
||||
string DisplayName,
|
||||
ImmutableArray<string> Arguments,
|
||||
EntryTraceInterpreterKind InterpreterKind,
|
||||
EntryTraceEvidence? Evidence,
|
||||
EntryTraceSpan? Span,
|
||||
ImmutableDictionary<string, string>? Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a directed edge in the entry trace graph.
|
||||
@@ -116,10 +144,48 @@ public sealed record EntryTraceDiagnostic(
|
||||
string? RelatedPath);
|
||||
|
||||
/// <summary>
|
||||
/// Final graph output produced by the analyzer.
|
||||
/// </summary>
|
||||
public sealed record EntryTraceGraph(
|
||||
EntryTraceOutcome Outcome,
|
||||
ImmutableArray<EntryTraceNode> Nodes,
|
||||
ImmutableArray<EntryTraceEdge> Edges,
|
||||
ImmutableArray<EntryTraceDiagnostic> Diagnostics);
|
||||
/// Final graph output produced by the analyzer.
|
||||
/// </summary>
|
||||
public sealed record EntryTraceGraph(
|
||||
EntryTraceOutcome Outcome,
|
||||
ImmutableArray<EntryTraceNode> Nodes,
|
||||
ImmutableArray<EntryTraceEdge> Edges,
|
||||
ImmutableArray<EntryTraceDiagnostic> Diagnostics,
|
||||
ImmutableArray<EntryTracePlan> Plans,
|
||||
ImmutableArray<EntryTraceTerminal> Terminals);
|
||||
|
||||
/// <summary>
|
||||
/// Describes a classified terminal executable.
|
||||
/// </summary>
|
||||
public sealed record EntryTracePlan(
|
||||
ImmutableArray<string> Command,
|
||||
ImmutableDictionary<string, string> Environment,
|
||||
string WorkingDirectory,
|
||||
string User,
|
||||
string TerminalPath,
|
||||
EntryTraceTerminalType Type,
|
||||
string? Runtime,
|
||||
double Confidence,
|
||||
ImmutableDictionary<string, string> Evidence);
|
||||
|
||||
/// <summary>
|
||||
/// Describes a classified terminal executable.
|
||||
/// </summary>
|
||||
public sealed record EntryTraceTerminal(
|
||||
string Path,
|
||||
EntryTraceTerminalType Type,
|
||||
string? Runtime,
|
||||
double Confidence,
|
||||
ImmutableDictionary<string, string> Evidence,
|
||||
string User,
|
||||
string WorkingDirectory,
|
||||
ImmutableArray<string> Arguments);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a fallback entrypoint candidate inferred from image metadata or filesystem.
|
||||
/// </summary>
|
||||
public sealed record EntryTraceCandidate(
|
||||
ImmutableArray<string> Command,
|
||||
string Source,
|
||||
EntryTraceEvidence? Evidence,
|
||||
string? Description);
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.FileSystem;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IRootFileSystem"/> implementation backed by a single on-disk directory.
|
||||
/// </summary>
|
||||
public sealed class DirectoryRootFileSystem : IRootFileSystem
|
||||
{
|
||||
private readonly DirectoryInfo _root;
|
||||
|
||||
public DirectoryRootFileSystem(string rootPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
|
||||
|
||||
var fullPath = Path.GetFullPath(rootPath);
|
||||
_root = new DirectoryInfo(fullPath);
|
||||
if (!_root.Exists)
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Root directory '{fullPath}' does not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryResolveExecutable(string name, IReadOnlyList<string> searchPaths, out RootFileDescriptor descriptor)
|
||||
{
|
||||
descriptor = null!;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (name.Contains('/', StringComparison.Ordinal))
|
||||
{
|
||||
return TryResolveExecutableByPath(name, out descriptor);
|
||||
}
|
||||
|
||||
if (searchPaths is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var searchPath in searchPaths)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(searchPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var candidate = Combine(searchPath, name);
|
||||
if (TryResolveExecutableByPath(candidate, out descriptor))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryReadAllText(string path, out RootFileDescriptor descriptor, out string content)
|
||||
{
|
||||
if (!TryResolveFile(path, out descriptor, out var fullPath))
|
||||
{
|
||||
content = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
content = File.ReadAllText(fullPath);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
content = string.Empty;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryReadBytes(string path, int maxBytes, out RootFileDescriptor descriptor, out ReadOnlyMemory<byte> content)
|
||||
{
|
||||
if (!TryResolveFile(path, out descriptor, out var fullPath))
|
||||
{
|
||||
content = ReadOnlyMemory<byte>.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(fullPath);
|
||||
if (maxBytes > 0 && fileInfo.Length > maxBytes)
|
||||
{
|
||||
content = ReadOnlyMemory<byte>.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
var buffer = File.ReadAllBytes(fullPath);
|
||||
content = new ReadOnlyMemory<byte>(buffer);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
content = ReadOnlyMemory<byte>.Empty;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public ImmutableArray<RootFileDescriptor> EnumerateDirectory(string path)
|
||||
{
|
||||
var normalized = Normalize(path);
|
||||
var fullPath = GetFullPath(normalized, allowDirectory: true);
|
||||
if (!Directory.Exists(fullPath))
|
||||
{
|
||||
return ImmutableArray<RootFileDescriptor>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<RootFileDescriptor>();
|
||||
foreach (var entry in Directory.EnumerateFileSystemEntries(fullPath))
|
||||
{
|
||||
var isDirectory = Directory.Exists(entry);
|
||||
builder.Add(CreateDescriptor(entry, isDirectory));
|
||||
}
|
||||
|
||||
return builder.ToImmutable().Sort(static (left, right) => string.CompareOrdinal(left.Path, right.Path));
|
||||
}
|
||||
|
||||
public bool DirectoryExists(string path)
|
||||
{
|
||||
var normalized = Normalize(path);
|
||||
var fullPath = GetFullPath(normalized, allowDirectory: true);
|
||||
return Directory.Exists(fullPath);
|
||||
}
|
||||
|
||||
private bool TryResolveExecutableByPath(string path, out RootFileDescriptor descriptor)
|
||||
{
|
||||
if (!TryResolveFile(path, out descriptor, out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return descriptor.IsExecutable;
|
||||
}
|
||||
|
||||
private bool TryResolveFile(string path, out RootFileDescriptor descriptor, out string fullPath)
|
||||
{
|
||||
descriptor = null!;
|
||||
fullPath = string.Empty;
|
||||
|
||||
var normalized = Normalize(path);
|
||||
fullPath = GetFullPath(normalized);
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
descriptor = CreateDescriptor(fullPath, isDirectory: false);
|
||||
return true;
|
||||
}
|
||||
|
||||
private RootFileDescriptor CreateDescriptor(string fullPath, bool isDirectory)
|
||||
{
|
||||
var relative = ToRelativePath(fullPath);
|
||||
|
||||
if (isDirectory)
|
||||
{
|
||||
return new RootFileDescriptor(relative, null, false, true, null);
|
||||
}
|
||||
|
||||
var info = new FileInfo(fullPath);
|
||||
var executable = InferExecutable(info);
|
||||
var shebang = ExtractShebang(fullPath);
|
||||
return new RootFileDescriptor(relative, null, executable, false, shebang);
|
||||
}
|
||||
|
||||
private string GetFullPath(string normalizedPath, bool allowDirectory = false)
|
||||
{
|
||||
var relative = normalizedPath.TrimStart('/');
|
||||
var combined = Path.GetFullPath(Path.Combine(_root.FullName, relative.Replace('/', Path.DirectorySeparatorChar)));
|
||||
if (!combined.StartsWith(_root.FullName, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException($"Path '{normalizedPath}' escapes the root directory.");
|
||||
}
|
||||
|
||||
if (!allowDirectory && Directory.Exists(combined))
|
||||
{
|
||||
throw new InvalidOperationException($"Path '{normalizedPath}' refers to a directory; a file was expected.");
|
||||
}
|
||||
|
||||
return combined;
|
||||
}
|
||||
|
||||
private static string Normalize(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return "/";
|
||||
}
|
||||
|
||||
var text = path.Replace('\\', '/').Trim();
|
||||
if (!text.StartsWith("/", StringComparison.Ordinal))
|
||||
{
|
||||
text = "/" + text;
|
||||
}
|
||||
|
||||
var segments = new Stack<string>();
|
||||
foreach (var segment in text.Split('/', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (segment == ".")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (segment == "..")
|
||||
{
|
||||
if (segments.Count > 0)
|
||||
{
|
||||
segments.Pop();
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
segments.Push(segment);
|
||||
}
|
||||
|
||||
if (segments.Count == 0)
|
||||
{
|
||||
return "/";
|
||||
}
|
||||
|
||||
var builder = new StringBuilder();
|
||||
foreach (var segment in segments.Reverse())
|
||||
{
|
||||
builder.Append('/').Append(segment);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string Combine(string basePath, string name)
|
||||
{
|
||||
var normalizedBase = Normalize(basePath);
|
||||
if (normalizedBase == "/")
|
||||
{
|
||||
return "/" + name;
|
||||
}
|
||||
|
||||
return normalizedBase.EndsWith("/", StringComparison.Ordinal)
|
||||
? normalizedBase + name
|
||||
: normalizedBase + "/" + name;
|
||||
}
|
||||
|
||||
private string ToRelativePath(string fullPath)
|
||||
{
|
||||
var relative = Path.GetRelativePath(_root.FullName, fullPath)
|
||||
.Replace(Path.DirectorySeparatorChar, '/')
|
||||
.Replace(Path.AltDirectorySeparatorChar, '/');
|
||||
|
||||
if (!relative.StartsWith("/", StringComparison.Ordinal))
|
||||
{
|
||||
relative = "/" + relative;
|
||||
}
|
||||
|
||||
return relative;
|
||||
}
|
||||
|
||||
private static bool InferExecutable(FileInfo info)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
var extension = info.Extension.ToLowerInvariant();
|
||||
return extension is ".exe" or ".bat" or ".cmd" or ".com" or ".ps1" or ".sh" or ".py" or ".rb" or ".js";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
#if NET8_0_OR_GREATER
|
||||
var mode = File.GetUnixFileMode(info.FullName);
|
||||
return mode.HasFlag(UnixFileMode.UserExecute) ||
|
||||
mode.HasFlag(UnixFileMode.GroupExecute) ||
|
||||
mode.HasFlag(UnixFileMode.OtherExecute);
|
||||
#else
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
catch
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractShebang(string fullPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
Span<byte> buffer = stackalloc byte[256];
|
||||
var read = stream.Read(buffer);
|
||||
if (read < 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (buffer[0] != (byte)'#' || buffer[1] != (byte)'!')
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var text = Encoding.UTF8.GetString(buffer[..read]);
|
||||
var newlineIndex = text.IndexOfAny(new[] { '\r', '\n' });
|
||||
var shebang = newlineIndex >= 0 ? text[2..newlineIndex] : text[2..];
|
||||
return shebang.Trim();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace;
|
||||
namespace StellaOps.Scanner.EntryTrace.FileSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a layered read-only filesystem snapshot built from container layers.
|
||||
@@ -12,15 +12,20 @@ public interface IRootFileSystem
|
||||
/// </summary>
|
||||
bool TryResolveExecutable(string name, IReadOnlyList<string> searchPaths, out RootFileDescriptor descriptor);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to read the contents of a file as UTF-8 text.
|
||||
/// </summary>
|
||||
bool TryReadAllText(string path, out RootFileDescriptor descriptor, out string content);
|
||||
|
||||
/// <summary>
|
||||
/// Returns descriptors for entries contained within a directory.
|
||||
/// </summary>
|
||||
ImmutableArray<RootFileDescriptor> EnumerateDirectory(string path);
|
||||
/// <summary>
|
||||
/// Attempts to read the contents of a file as UTF-8 text.
|
||||
/// </summary>
|
||||
bool TryReadAllText(string path, out RootFileDescriptor descriptor, out string content);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to read up to <paramref name="maxBytes"/> bytes from the file.
|
||||
/// </summary>
|
||||
bool TryReadBytes(string path, int maxBytes, out RootFileDescriptor descriptor, out ReadOnlyMemory<byte> content);
|
||||
|
||||
/// <summary>
|
||||
/// Returns descriptors for entries contained within a directory.
|
||||
/// </summary>
|
||||
ImmutableArray<RootFileDescriptor> EnumerateDirectory(string path);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a directory exists.
|
||||
|
||||
@@ -7,7 +7,7 @@ using System.Text;
|
||||
using System.Threading;
|
||||
using IOPath = System.IO.Path;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace;
|
||||
namespace StellaOps.Scanner.EntryTrace.FileSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an <see cref="IRootFileSystem"/> backed by OCI image layers.
|
||||
@@ -15,7 +15,7 @@ namespace StellaOps.Scanner.EntryTrace;
|
||||
public sealed class LayeredRootFileSystem : IRootFileSystem
|
||||
{
|
||||
private const int MaxSymlinkDepth = 32;
|
||||
private const int MaxCachedTextBytes = 1_048_576; // 1 MiB
|
||||
private const int MaxCachedBytes = 1_048_576; // 1 MiB
|
||||
|
||||
private readonly ImmutableDictionary<string, FileEntry> _entries;
|
||||
|
||||
@@ -118,14 +118,33 @@ public sealed class LayeredRootFileSystem : IRootFileSystem
|
||||
return false;
|
||||
}
|
||||
|
||||
descriptor = entry.ToDescriptor(resolvedPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
public ImmutableArray<RootFileDescriptor> EnumerateDirectory(string path)
|
||||
{
|
||||
var normalizedDirectory = NormalizeDirectory(path);
|
||||
var results = ImmutableArray.CreateBuilder<RootFileDescriptor>();
|
||||
descriptor = entry.ToDescriptor(resolvedPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryReadBytes(string path, int maxBytes, out RootFileDescriptor descriptor, out ReadOnlyMemory<byte> content)
|
||||
{
|
||||
descriptor = null!;
|
||||
content = default;
|
||||
|
||||
if (!TryResolveFile(path, out var entry, out var resolvedPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!entry.TryReadBytes(maxBytes, out content))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
descriptor = entry.ToDescriptor(resolvedPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
public ImmutableArray<RootFileDescriptor> EnumerateDirectory(string path)
|
||||
{
|
||||
var normalizedDirectory = NormalizeDirectory(path);
|
||||
var results = ImmutableArray.CreateBuilder<RootFileDescriptor>();
|
||||
|
||||
foreach (var entry in _entries.Values)
|
||||
{
|
||||
@@ -362,7 +381,7 @@ public sealed class LayeredRootFileSystem : IRootFileSystem
|
||||
|
||||
var isExecutable = InferExecutable(entryPath, attributes);
|
||||
var contentProvider = FileContentProvider.FromFile(entryPath);
|
||||
var shebang = ExtractShebang(contentProvider.Peek(MaxCachedTextBytes));
|
||||
var shebang = ExtractShebang(contentProvider.Peek(MaxCachedBytes));
|
||||
|
||||
EnsureAncestry(normalized, layer.Digest);
|
||||
_entries[normalized] = FileEntry.File(
|
||||
@@ -409,7 +428,7 @@ public sealed class LayeredRootFileSystem : IRootFileSystem
|
||||
case TarEntryType.ContiguousFile:
|
||||
{
|
||||
var contentProvider = FileContentProvider.FromTarEntry(entry);
|
||||
var preview = contentProvider.Peek(MaxCachedTextBytes);
|
||||
var preview = contentProvider.Peek(MaxCachedBytes);
|
||||
var shebang = ExtractShebang(preview);
|
||||
var isExecutable = InferExecutable(entry);
|
||||
|
||||
@@ -661,16 +680,27 @@ public sealed class LayeredRootFileSystem : IRootFileSystem
|
||||
Kind == FileEntryKind.Directory,
|
||||
Shebang);
|
||||
|
||||
public bool TryReadText(out string content)
|
||||
{
|
||||
if (Kind != FileEntryKind.File || _content is null)
|
||||
{
|
||||
content = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
return _content.TryRead(out content);
|
||||
}
|
||||
public bool TryReadText(out string content)
|
||||
{
|
||||
if (Kind != FileEntryKind.File || _content is null)
|
||||
{
|
||||
content = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
return _content.TryReadText(out content);
|
||||
}
|
||||
|
||||
public bool TryReadBytes(int maxBytes, out ReadOnlyMemory<byte> bytes)
|
||||
{
|
||||
if (Kind != FileEntryKind.File || _content is null)
|
||||
{
|
||||
bytes = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
return _content.TryReadBytes(maxBytes, out bytes);
|
||||
}
|
||||
|
||||
public static FileEntry File(
|
||||
string path,
|
||||
@@ -698,74 +728,191 @@ public sealed class LayeredRootFileSystem : IRootFileSystem
|
||||
Symlink
|
||||
}
|
||||
|
||||
private sealed class FileContentProvider
|
||||
{
|
||||
private readonly Func<string?> _factory;
|
||||
private readonly Lazy<string?> _cached;
|
||||
|
||||
private FileContentProvider(Func<string?> factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_cached = new Lazy<string?>(() => _factory(), LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
}
|
||||
|
||||
public static FileContentProvider FromFile(string path)
|
||||
=> new(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
public static FileContentProvider FromTarEntry(TarEntry entry)
|
||||
{
|
||||
return new FileContentProvider(() =>
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
entry.DataStream?.CopyTo(stream);
|
||||
if (stream.Length > MaxCachedTextBytes)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
stream.Position = 0;
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: true);
|
||||
return reader.ReadToEnd();
|
||||
});
|
||||
}
|
||||
|
||||
public string? Peek(int maxBytes)
|
||||
{
|
||||
var content = _cached.Value;
|
||||
if (content is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (content.Length * sizeof(char) <= maxBytes)
|
||||
{
|
||||
return content;
|
||||
}
|
||||
|
||||
return content[..Math.Min(content.Length, maxBytes / sizeof(char))];
|
||||
}
|
||||
|
||||
public bool TryRead(out string content)
|
||||
{
|
||||
var value = _cached.Value;
|
||||
if (value is null)
|
||||
{
|
||||
content = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
content = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
private sealed class FileContentProvider
|
||||
{
|
||||
private readonly Func<byte[]?>? _binaryFactory;
|
||||
private readonly Func<string?>? _textFactory;
|
||||
private readonly Lazy<byte[]?> _cachedBytes;
|
||||
private readonly Lazy<string?> _cachedText;
|
||||
|
||||
private FileContentProvider(
|
||||
Func<byte[]?>? binaryFactory,
|
||||
Func<string?>? textFactory)
|
||||
{
|
||||
_binaryFactory = binaryFactory;
|
||||
_textFactory = textFactory;
|
||||
_cachedBytes = new Lazy<byte[]?>(() => _binaryFactory?.Invoke(), LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
_cachedText = new Lazy<string?>(() =>
|
||||
{
|
||||
if (_textFactory is not null)
|
||||
{
|
||||
return _textFactory();
|
||||
}
|
||||
|
||||
var bytes = _cachedBytes.Value;
|
||||
if (bytes is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}, LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
}
|
||||
|
||||
public static FileContentProvider FromFile(string path)
|
||||
{
|
||||
return new FileContentProvider(
|
||||
() => ReadFileBytes(path, MaxCachedBytes),
|
||||
() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static FileContentProvider FromTarEntry(TarEntry entry)
|
||||
{
|
||||
byte[]? cached = null;
|
||||
|
||||
return new FileContentProvider(
|
||||
() =>
|
||||
{
|
||||
if (cached is not null)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
cached = ReadTarEntryBytes(entry, MaxCachedBytes);
|
||||
return cached;
|
||||
},
|
||||
() =>
|
||||
{
|
||||
if (cached is null)
|
||||
{
|
||||
cached = ReadTarEntryBytes(entry, MaxCachedBytes);
|
||||
}
|
||||
|
||||
if (cached is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Encoding.UTF8.GetString(cached);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public string? Peek(int maxBytes)
|
||||
{
|
||||
var text = _cachedText.Value;
|
||||
if (text is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (text.Length * sizeof(char) <= maxBytes)
|
||||
{
|
||||
return text;
|
||||
}
|
||||
|
||||
return text[..Math.Min(text.Length, maxBytes / sizeof(char))];
|
||||
}
|
||||
|
||||
public bool TryReadText(out string content)
|
||||
{
|
||||
var text = _cachedText.Value;
|
||||
if (text is null)
|
||||
{
|
||||
content = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
content = text;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryReadBytes(int maxBytes, out ReadOnlyMemory<byte> bytes)
|
||||
{
|
||||
var data = _cachedBytes.Value;
|
||||
if (data is null || data.Length == 0)
|
||||
{
|
||||
bytes = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
var length = Math.Min(maxBytes, data.Length);
|
||||
bytes = new ReadOnlyMemory<byte>(data, 0, length);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static byte[]? ReadFileBytes(string path, int maxBytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
return ReadStreamWithLimit(stream, maxBytes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[]? ReadTarEntryBytes(TarEntry entry, int maxBytes)
|
||||
{
|
||||
if (entry.DataStream is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
entry.DataStream.Position = 0;
|
||||
return ReadStreamWithLimit(entry.DataStream, maxBytes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[]? ReadStreamWithLimit(Stream stream, int maxBytes)
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
var remaining = maxBytes;
|
||||
var temp = new byte[8192];
|
||||
int read;
|
||||
while (remaining > 0 && (read = stream.Read(temp, 0, Math.Min(temp.Length, remaining))) > 0)
|
||||
{
|
||||
buffer.Write(temp, 0, read);
|
||||
remaining -= read;
|
||||
}
|
||||
|
||||
if (buffer.Length == 0 && stream.ReadByte() == -1)
|
||||
{
|
||||
return Array.Empty<byte>();
|
||||
}
|
||||
|
||||
return buffer.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,14 +8,17 @@ namespace StellaOps.Scanner.EntryTrace;
|
||||
/// <summary>
|
||||
/// Represents the deserialized OCI image config document.
|
||||
/// </summary>
|
||||
internal sealed class OciImageConfiguration
|
||||
{
|
||||
[JsonPropertyName("config")]
|
||||
public OciImageConfig? Config { get; init; }
|
||||
|
||||
[JsonPropertyName("container_config")]
|
||||
public OciImageConfig? ContainerConfig { get; init; }
|
||||
}
|
||||
internal sealed class OciImageConfiguration
|
||||
{
|
||||
[JsonPropertyName("config")]
|
||||
public OciImageConfig? Config { get; init; }
|
||||
|
||||
[JsonPropertyName("container_config")]
|
||||
public OciImageConfig? ContainerConfig { get; init; }
|
||||
|
||||
[JsonPropertyName("history")]
|
||||
public ImmutableArray<OciHistoryEntry> History { get; init; } = ImmutableArray<OciHistoryEntry>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logical representation of the OCI image config fields used by EntryTrace.
|
||||
@@ -34,12 +37,15 @@ public sealed class OciImageConfig
|
||||
[JsonConverter(typeof(FlexibleStringListConverter))]
|
||||
public ImmutableArray<string> Command { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
[JsonPropertyName("WorkingDir")]
|
||||
public string? WorkingDirectory { get; init; }
|
||||
|
||||
[JsonPropertyName("User")]
|
||||
public string? User { get; init; }
|
||||
}
|
||||
[JsonPropertyName("WorkingDir")]
|
||||
public string? WorkingDirectory { get; init; }
|
||||
|
||||
[JsonPropertyName("User")]
|
||||
public string? User { get; init; }
|
||||
|
||||
[JsonIgnore]
|
||||
public ImmutableArray<OciHistoryEntry> History { get; init; } = ImmutableArray<OciHistoryEntry>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads <see cref="OciImageConfig"/> instances from OCI config JSON.
|
||||
@@ -65,19 +71,20 @@ public static class OciImageConfigLoader
|
||||
var configuration = JsonSerializer.Deserialize<OciImageConfiguration>(stream, SerializerOptions)
|
||||
?? throw new InvalidDataException("OCI image config is empty or invalid.");
|
||||
|
||||
if (configuration.Config is not null)
|
||||
{
|
||||
return configuration.Config;
|
||||
}
|
||||
|
||||
if (configuration.ContainerConfig is not null)
|
||||
{
|
||||
return configuration.ContainerConfig;
|
||||
}
|
||||
|
||||
throw new InvalidDataException("OCI image config does not include a config section.");
|
||||
}
|
||||
}
|
||||
var baseConfig = configuration.Config ?? configuration.ContainerConfig
|
||||
?? throw new InvalidDataException("OCI image config does not include a config section.");
|
||||
|
||||
return new OciImageConfig
|
||||
{
|
||||
Environment = baseConfig.Environment,
|
||||
Entrypoint = baseConfig.Entrypoint,
|
||||
Command = baseConfig.Command,
|
||||
WorkingDirectory = baseConfig.WorkingDirectory,
|
||||
User = baseConfig.User,
|
||||
History = configuration.History
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FlexibleStringListConverter : JsonConverter<ImmutableArray<string>>
|
||||
{
|
||||
@@ -116,14 +123,18 @@ internal sealed class FlexibleStringListConverter : JsonConverter<ImmutableArray
|
||||
throw new JsonException($"Unsupported JSON token {reader.TokenType} for string array.");
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, ImmutableArray<string> value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStartArray();
|
||||
foreach (var entry in value)
|
||||
{
|
||||
writer.WriteStringValue(entry);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
}
|
||||
public override void Write(Utf8JsonWriter writer, ImmutableArray<string> value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStartArray();
|
||||
foreach (var entry in value)
|
||||
{
|
||||
writer.WriteStringValue(entry);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record OciHistoryEntry(
|
||||
[property: JsonPropertyName("created_by")] string? CreatedBy,
|
||||
[property: JsonPropertyName("empty_layer")] bool EmptyLayer);
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Runtime;
|
||||
|
||||
public sealed class EntryTraceRuntimeReconciler
|
||||
{
|
||||
private static readonly HashSet<string> WrapperNames = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"tini",
|
||||
"tini-static",
|
||||
"tini64",
|
||||
"dumb-init",
|
||||
"dumbinit",
|
||||
"gosu",
|
||||
"su-exec",
|
||||
"chpst",
|
||||
"s6-supervise",
|
||||
"s6-svscan",
|
||||
"s6-svscanctl",
|
||||
"s6-rc-init",
|
||||
"runsv",
|
||||
"runsvdir",
|
||||
"supervisord",
|
||||
"sh",
|
||||
"bash",
|
||||
"dash",
|
||||
"ash",
|
||||
"env"
|
||||
};
|
||||
|
||||
public EntryTraceGraph Reconcile(EntryTraceGraph graph, ProcGraph? procGraph)
|
||||
{
|
||||
if (graph is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(graph));
|
||||
}
|
||||
|
||||
var diagnostics = graph.Diagnostics.ToBuilder();
|
||||
diagnostics.RemoveAll(d =>
|
||||
d.Reason is EntryTraceUnknownReason.RuntimeSnapshotUnavailable
|
||||
or EntryTraceUnknownReason.RuntimeProcessNotFound
|
||||
or EntryTraceUnknownReason.RuntimeMatch
|
||||
or EntryTraceUnknownReason.RuntimeMismatch);
|
||||
|
||||
if (procGraph is null || procGraph.Processes.Count == 0)
|
||||
{
|
||||
diagnostics.Add(new EntryTraceDiagnostic(
|
||||
EntryTraceDiagnosticSeverity.Info,
|
||||
EntryTraceUnknownReason.RuntimeSnapshotUnavailable,
|
||||
"Runtime process snapshot unavailable; static confidence retained.",
|
||||
Span: null,
|
||||
RelatedPath: null));
|
||||
|
||||
return graph with { Diagnostics = diagnostics.ToImmutable() };
|
||||
}
|
||||
|
||||
var runtimeTerminals = BuildRuntimeTerminals(procGraph);
|
||||
if (runtimeTerminals.Length == 0)
|
||||
{
|
||||
diagnostics.Add(new EntryTraceDiagnostic(
|
||||
EntryTraceDiagnosticSeverity.Warning,
|
||||
EntryTraceUnknownReason.RuntimeProcessNotFound,
|
||||
"Runtime process snapshot did not reveal a terminal executable.",
|
||||
Span: null,
|
||||
RelatedPath: null));
|
||||
|
||||
return graph with { Diagnostics = diagnostics.ToImmutable() };
|
||||
}
|
||||
|
||||
var planBuilder = ImmutableArray.CreateBuilder<EntryTracePlan>(graph.Plans.Length);
|
||||
var terminalBuilder = ImmutableArray.CreateBuilder<EntryTraceTerminal>(graph.Terminals.Length);
|
||||
var terminalIndexMap = BuildTerminalIndexMap(graph.Terminals, terminalBuilder);
|
||||
|
||||
foreach (var plan in graph.Plans)
|
||||
{
|
||||
var match = SelectBestRuntimeMatch(plan.TerminalPath, runtimeTerminals);
|
||||
var confidence = EvaluateConfidence(plan.TerminalPath, match?.Path);
|
||||
|
||||
planBuilder.Add(plan with { Confidence = confidence.Score });
|
||||
|
||||
if (terminalIndexMap.TryGetValue(plan.TerminalPath, out var indices) && indices.Count > 0)
|
||||
{
|
||||
var index = indices.Dequeue();
|
||||
terminalBuilder[index] = terminalBuilder[index] with { Confidence = confidence.Score };
|
||||
}
|
||||
|
||||
diagnostics.Add(BuildDiagnostic(confidence, plan.TerminalPath));
|
||||
}
|
||||
|
||||
// Update any terminals that were not tied to plans.
|
||||
for (var i = 0; i < terminalBuilder.Count; i++)
|
||||
{
|
||||
if (terminalBuilder[i].Confidence > 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var terminal = terminalBuilder[i];
|
||||
var match = SelectBestRuntimeMatch(terminal.Path, runtimeTerminals);
|
||||
var confidence = EvaluateConfidence(terminal.Path, match?.Path);
|
||||
terminalBuilder[i] = terminal with { Confidence = confidence.Score };
|
||||
}
|
||||
|
||||
return graph with
|
||||
{
|
||||
Plans = planBuilder.ToImmutable(),
|
||||
Terminals = terminalBuilder.ToImmutable(),
|
||||
Diagnostics = diagnostics.ToImmutable()
|
||||
};
|
||||
}
|
||||
|
||||
private static ImmutableArray<RuntimeTerminal> BuildRuntimeTerminals(ProcGraph graph)
|
||||
{
|
||||
var allCandidates = new List<RuntimeTerminal>();
|
||||
|
||||
foreach (var process in graph.Processes.Values
|
||||
.OrderBy(p => p.StartTimeTicks == 0 ? ulong.MaxValue : p.StartTimeTicks)
|
||||
.ThenBy(p => p.Pid))
|
||||
{
|
||||
var normalizedPath = NormalizeRuntimePath(process);
|
||||
if (string.IsNullOrWhiteSpace(normalizedPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
allCandidates.Add(new RuntimeTerminal(normalizedPath, process));
|
||||
}
|
||||
|
||||
if (allCandidates.Count == 0)
|
||||
{
|
||||
return ImmutableArray<RuntimeTerminal>.Empty;
|
||||
}
|
||||
|
||||
var preferred = allCandidates.Where(candidate => !IsWrapper(candidate.Process)).ToList();
|
||||
var source = preferred.Count > 0 ? preferred : allCandidates;
|
||||
|
||||
var unique = new Dictionary<string, RuntimeTerminal>(StringComparer.Ordinal);
|
||||
foreach (var candidate in source)
|
||||
{
|
||||
if (!unique.ContainsKey(candidate.Path))
|
||||
{
|
||||
unique[candidate.Path] = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return unique.Values.OrderBy(candidate => candidate.Process.StartTimeTicks == 0 ? ulong.MaxValue : candidate.Process.StartTimeTicks)
|
||||
.ThenBy(candidate => candidate.Process.Pid)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static RuntimeTerminal? SelectBestRuntimeMatch(string predictedPath, ImmutableArray<RuntimeTerminal> runtimeTerminals)
|
||||
{
|
||||
if (runtimeTerminals.IsDefaultOrEmpty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
RuntimeTerminal? best = null;
|
||||
double bestScore = double.MinValue;
|
||||
|
||||
foreach (var runtime in runtimeTerminals)
|
||||
{
|
||||
var (score, _, _) = EvaluateConfidence(predictedPath, runtime.Path);
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
best = runtime;
|
||||
if (score >= 95d)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private static Dictionary<string, Queue<int>> BuildTerminalIndexMap(
|
||||
ImmutableArray<EntryTraceTerminal> terminals,
|
||||
ImmutableArray<EntryTraceTerminal>.Builder terminalBuilder)
|
||||
{
|
||||
var map = new Dictionary<string, Queue<int>>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < terminals.Length; i++)
|
||||
{
|
||||
var terminal = terminals[i];
|
||||
terminalBuilder.Add(terminal);
|
||||
|
||||
if (!map.TryGetValue(terminal.Path, out var indices))
|
||||
{
|
||||
indices = new Queue<int>();
|
||||
map[terminal.Path] = indices;
|
||||
}
|
||||
|
||||
indices.Enqueue(i);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private static ConfidenceResult EvaluateConfidence(string predictedPath, string? runtimePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(runtimePath))
|
||||
{
|
||||
return new ConfidenceResult(60d, ConfidenceLevel.Low, string.Empty);
|
||||
}
|
||||
|
||||
var normalizedPredicted = NormalizeComparisonPath(predictedPath);
|
||||
var normalizedRuntime = NormalizeComparisonPath(runtimePath);
|
||||
|
||||
if (string.Equals(normalizedPredicted, normalizedRuntime, StringComparison.Ordinal))
|
||||
{
|
||||
return new ConfidenceResult(95d, ConfidenceLevel.High, runtimePath);
|
||||
}
|
||||
|
||||
var predictedName = Path.GetFileName(normalizedPredicted);
|
||||
var runtimeName = Path.GetFileName(normalizedRuntime);
|
||||
|
||||
if (!string.IsNullOrEmpty(predictedName) &&
|
||||
string.Equals(predictedName, runtimeName, StringComparison.Ordinal))
|
||||
{
|
||||
return new ConfidenceResult(90d, ConfidenceLevel.High, runtimePath);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(predictedName) &&
|
||||
string.Equals(predictedName, runtimeName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new ConfidenceResult(80d, ConfidenceLevel.Medium, runtimePath);
|
||||
}
|
||||
|
||||
return new ConfidenceResult(60d, ConfidenceLevel.Low, runtimePath);
|
||||
}
|
||||
|
||||
private static EntryTraceDiagnostic BuildDiagnostic(ConfidenceResult result, string predictedPath)
|
||||
{
|
||||
var runtimePath = string.IsNullOrWhiteSpace(result.RuntimePath) ? "<unknown>" : result.RuntimePath;
|
||||
var severity = result.Level == ConfidenceLevel.High
|
||||
? EntryTraceDiagnosticSeverity.Info
|
||||
: EntryTraceDiagnosticSeverity.Warning;
|
||||
var reason = result.Level == ConfidenceLevel.High
|
||||
? EntryTraceUnknownReason.RuntimeMatch
|
||||
: EntryTraceUnknownReason.RuntimeMismatch;
|
||||
var message = result.Level == ConfidenceLevel.High
|
||||
? $"Runtime process '{runtimePath}' matches EntryTrace prediction '{predictedPath}'."
|
||||
: $"Runtime process '{runtimePath}' diverges from EntryTrace prediction '{predictedPath}'.";
|
||||
|
||||
return new EntryTraceDiagnostic(
|
||||
severity,
|
||||
reason,
|
||||
message,
|
||||
Span: null,
|
||||
RelatedPath: string.IsNullOrWhiteSpace(result.RuntimePath) ? null : result.RuntimePath);
|
||||
}
|
||||
|
||||
private static bool IsWrapper(ProcProcess process)
|
||||
{
|
||||
var command = GetCommandName(process);
|
||||
return command.Length > 0 && WrapperNames.Contains(command);
|
||||
}
|
||||
|
||||
private static string GetCommandName(ProcProcess process)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(process.CommandName))
|
||||
{
|
||||
return process.CommandName;
|
||||
}
|
||||
|
||||
if (!process.CommandLine.IsDefaultOrEmpty && process.CommandLine.Length > 0)
|
||||
{
|
||||
return Path.GetFileName(process.CommandLine[0]);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(process.ExecutablePath))
|
||||
{
|
||||
return Path.GetFileName(process.ExecutablePath);
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static string NormalizeRuntimePath(ProcProcess process)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(process.ExecutablePath))
|
||||
{
|
||||
return process.ExecutablePath;
|
||||
}
|
||||
|
||||
if (!process.CommandLine.IsDefaultOrEmpty && process.CommandLine.Length > 0)
|
||||
{
|
||||
return process.CommandLine[0];
|
||||
}
|
||||
|
||||
return process.CommandName;
|
||||
}
|
||||
|
||||
private static string NormalizeComparisonPath(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmed = path.Trim();
|
||||
var normalized = trimmed.Replace('\\', '/');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private readonly record struct RuntimeTerminal(string Path, ProcProcess Process);
|
||||
|
||||
private readonly record struct ConfidenceResult(double Score, ConfidenceLevel Level, string RuntimePath);
|
||||
|
||||
private enum ConfidenceLevel
|
||||
{
|
||||
High,
|
||||
Medium,
|
||||
Low
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Runtime;
|
||||
|
||||
public sealed class ProcFileSystemSnapshot : IProcSnapshotProvider
|
||||
{
|
||||
private readonly string _rootPath;
|
||||
|
||||
public ProcFileSystemSnapshot(string? rootPath = null)
|
||||
{
|
||||
_rootPath = string.IsNullOrWhiteSpace(rootPath) ? "/proc" : rootPath;
|
||||
}
|
||||
|
||||
public IEnumerable<int> EnumerateProcessIds()
|
||||
{
|
||||
if (!Directory.Exists(_rootPath))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var directory in Directory.EnumerateDirectories(_rootPath).OrderBy(d => d, StringComparer.Ordinal))
|
||||
{
|
||||
var name = Path.GetFileName(directory);
|
||||
if (int.TryParse(name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var pid))
|
||||
{
|
||||
yield return pid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryReadProcess(int pid, out ProcProcess process)
|
||||
{
|
||||
process = null!;
|
||||
try
|
||||
{
|
||||
var statPath = Path.Combine(_rootPath, pid.ToString(CultureInfo.InvariantCulture), "stat");
|
||||
var cmdPath = Path.Combine(_rootPath, pid.ToString(CultureInfo.InvariantCulture), "cmdline");
|
||||
var exePath = Path.Combine(_rootPath, pid.ToString(CultureInfo.InvariantCulture), "exe");
|
||||
|
||||
if (!File.Exists(statPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var statContent = File.ReadAllText(statPath);
|
||||
var commandName = ParseCommandName(statContent);
|
||||
var parentPid = ParseParentPid(statContent);
|
||||
var startTime = ParseStartTime(statContent);
|
||||
var commandLine = ReadCommandLine(cmdPath, statContent);
|
||||
var executable = ResolveExecutablePath(exePath, commandLine);
|
||||
|
||||
process = new ProcProcess(pid, parentPid, executable, commandLine, commandName, startTime);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ParseCommandName(string statContent)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(statContent))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var openIndex = statContent.IndexOf('(');
|
||||
var closeIndex = statContent.LastIndexOf(')');
|
||||
if (openIndex >= 0 && closeIndex > openIndex)
|
||||
{
|
||||
return statContent.Substring(openIndex + 1, closeIndex - openIndex - 1);
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static int ParseParentPid(string statContent)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(statContent))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var closeIndex = statContent.LastIndexOf(')');
|
||||
if (closeIndex < 0 || closeIndex + 2 >= statContent.Length)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var after = statContent.Substring(closeIndex + 2);
|
||||
var parts = after.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parentPid)
|
||||
? parentPid
|
||||
: 0;
|
||||
}
|
||||
|
||||
private static ulong ParseStartTime(string statContent)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(statContent))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var closeIndex = statContent.LastIndexOf(')');
|
||||
if (closeIndex < 0 || closeIndex + 2 >= statContent.Length)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var after = statContent.Substring(closeIndex + 2);
|
||||
var parts = after.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length < 19)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return ulong.TryParse(parts[18], NumberStyles.Integer, CultureInfo.InvariantCulture, out var startTime)
|
||||
? startTime
|
||||
: 0;
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ReadCommandLine(string path, string statContent)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
var bytes = File.ReadAllBytes(path);
|
||||
if (bytes.Length > 0)
|
||||
{
|
||||
var segments = SplitNullTerminated(bytes)
|
||||
.Where(segment => segment.Length > 0)
|
||||
.ToImmutableArray();
|
||||
if (!segments.IsDefaultOrEmpty)
|
||||
{
|
||||
return segments;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
var openIndex = statContent.IndexOf('(');
|
||||
var closeIndex = statContent.LastIndexOf(')');
|
||||
if (openIndex >= 0 && closeIndex > openIndex)
|
||||
{
|
||||
var command = statContent.Substring(openIndex + 1, closeIndex - openIndex - 1);
|
||||
return ImmutableArray.Create(command);
|
||||
}
|
||||
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
private static string ResolveExecutablePath(string path, ImmutableArray<string> commandLine)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
try
|
||||
{
|
||||
var info = File.ResolveLinkTarget(path, returnFinalTarget: true);
|
||||
if (info is not null)
|
||||
{
|
||||
return info.FullName;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// fall through to command line fallback
|
||||
}
|
||||
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
if (!string.IsNullOrWhiteSpace(fullPath))
|
||||
{
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore failures to resolve symlinks
|
||||
}
|
||||
|
||||
if (!commandLine.IsDefaultOrEmpty && commandLine[0].Contains('/'))
|
||||
{
|
||||
return commandLine[0];
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SplitNullTerminated(byte[] buffer)
|
||||
{
|
||||
var start = 0;
|
||||
for (var i = 0; i < buffer.Length; i++)
|
||||
{
|
||||
if (buffer[i] == 0)
|
||||
{
|
||||
var length = i - start;
|
||||
if (length > 0)
|
||||
{
|
||||
yield return Encoding.UTF8.GetString(buffer, start, length);
|
||||
}
|
||||
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (start < buffer.Length)
|
||||
{
|
||||
yield return Encoding.UTF8.GetString(buffer, start, buffer.Length - start);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Runtime;
|
||||
|
||||
public sealed record ProcGraph(
|
||||
int RootPid,
|
||||
ImmutableDictionary<int, ProcProcess> Processes,
|
||||
ImmutableDictionary<int, ImmutableArray<int>> Children);
|
||||
@@ -0,0 +1,104 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Runtime;
|
||||
|
||||
public interface IProcSnapshotProvider
|
||||
{
|
||||
IEnumerable<int> EnumerateProcessIds();
|
||||
|
||||
bool TryReadProcess(int pid, out ProcProcess process);
|
||||
}
|
||||
|
||||
public static class ProcGraphBuilder
|
||||
{
|
||||
public static ProcGraph? Build(IProcSnapshotProvider provider)
|
||||
{
|
||||
if (provider is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(provider));
|
||||
}
|
||||
|
||||
var processes = new Dictionary<int, ProcProcess>();
|
||||
foreach (var pid in provider.EnumerateProcessIds().OrderBy(pid => pid))
|
||||
{
|
||||
if (pid <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (provider.TryReadProcess(pid, out var process))
|
||||
{
|
||||
processes[pid] = process;
|
||||
}
|
||||
}
|
||||
|
||||
if (processes.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var rootPid = DetermineRootPid(processes);
|
||||
var children = BuildChildren(processes);
|
||||
return new ProcGraph(
|
||||
rootPid,
|
||||
processes.ToImmutableDictionary(pair => pair.Key, pair => pair.Value),
|
||||
children);
|
||||
}
|
||||
|
||||
private static int DetermineRootPid(Dictionary<int, ProcProcess> processes)
|
||||
{
|
||||
if (processes.ContainsKey(1))
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
var childPids = new HashSet<int>();
|
||||
foreach (var process in processes.Values)
|
||||
{
|
||||
if (processes.ContainsKey(process.ParentPid))
|
||||
{
|
||||
childPids.Add(process.Pid);
|
||||
}
|
||||
}
|
||||
|
||||
var rootCandidates = processes.Keys.Where(pid => !childPids.Contains(pid));
|
||||
return rootCandidates.OrderBy(pid => pid).FirstOrDefault(processes.Keys.Min());
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<int, ImmutableArray<int>> BuildChildren(Dictionary<int, ProcProcess> processes)
|
||||
{
|
||||
var map = new Dictionary<int, List<int>>(processes.Count);
|
||||
|
||||
foreach (var process in processes.Values)
|
||||
{
|
||||
if (!processes.ContainsKey(process.ParentPid))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!map.TryGetValue(process.ParentPid, out var list))
|
||||
{
|
||||
list = new List<int>();
|
||||
map[process.ParentPid] = list;
|
||||
}
|
||||
|
||||
list.Add(process.Pid);
|
||||
}
|
||||
|
||||
return map.ToImmutableDictionary(
|
||||
pair => pair.Key,
|
||||
pair => pair.Value
|
||||
.OrderBy(pid => NormalizeStartTime(processes[pid]))
|
||||
.ThenBy(pid => pid)
|
||||
.Select(pid => pid)
|
||||
.ToImmutableArray());
|
||||
}
|
||||
|
||||
private static ulong NormalizeStartTime(ProcProcess process)
|
||||
{
|
||||
return process.StartTimeTicks == 0 ? ulong.MaxValue : process.StartTimeTicks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Runtime;
|
||||
|
||||
public sealed record ProcProcess(
|
||||
int Pid,
|
||||
int ParentPid,
|
||||
string ExecutablePath,
|
||||
ImmutableArray<string> CommandLine,
|
||||
string CommandName,
|
||||
ulong StartTimeTicks);
|
||||
@@ -0,0 +1,309 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Serialization;
|
||||
|
||||
public static class EntryTraceGraphSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
static EntryTraceGraphSerializer()
|
||||
{
|
||||
SerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
}
|
||||
|
||||
public static string Serialize(EntryTraceGraph graph)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
var contract = EntryTraceGraphContract.FromGraph(graph);
|
||||
return JsonSerializer.Serialize(contract, SerializerOptions);
|
||||
}
|
||||
|
||||
public static EntryTraceGraph Deserialize(string json)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(json);
|
||||
var contract = JsonSerializer.Deserialize<EntryTraceGraphContract>(json, SerializerOptions)
|
||||
?? throw new InvalidOperationException("Failed to deserialize EntryTrace graph.");
|
||||
return contract.ToGraph();
|
||||
}
|
||||
|
||||
private sealed class EntryTraceGraphContract
|
||||
{
|
||||
public EntryTraceOutcome Outcome { get; set; }
|
||||
public List<EntryTraceNodeContract> Nodes { get; set; } = new();
|
||||
public List<EntryTraceEdgeContract> Edges { get; set; } = new();
|
||||
public List<EntryTraceDiagnosticContract> Diagnostics { get; set; } = new();
|
||||
public List<EntryTracePlanContract> Plans { get; set; } = new();
|
||||
public List<EntryTraceTerminalContract> Terminals { get; set; } = new();
|
||||
|
||||
public static EntryTraceGraphContract FromGraph(EntryTraceGraph graph)
|
||||
{
|
||||
return new EntryTraceGraphContract
|
||||
{
|
||||
Outcome = graph.Outcome,
|
||||
Nodes = graph.Nodes.Select(EntryTraceNodeContract.FromNode).ToList(),
|
||||
Edges = graph.Edges.Select(EntryTraceEdgeContract.FromEdge).ToList(),
|
||||
Diagnostics = graph.Diagnostics.Select(EntryTraceDiagnosticContract.FromDiagnostic).ToList(),
|
||||
Plans = graph.Plans.Select(EntryTracePlanContract.FromPlan).ToList(),
|
||||
Terminals = graph.Terminals.Select(EntryTraceTerminalContract.FromTerminal).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public EntryTraceGraph ToGraph()
|
||||
{
|
||||
return new EntryTraceGraph(
|
||||
Outcome,
|
||||
Nodes.Select(n => n.ToNode()).ToImmutableArray(),
|
||||
Edges.Select(e => e.ToEdge()).ToImmutableArray(),
|
||||
Diagnostics.Select(d => d.ToDiagnostic()).ToImmutableArray(),
|
||||
Plans.Select(p => p.ToPlan()).ToImmutableArray(),
|
||||
Terminals.Select(t => t.ToTerminal()).ToImmutableArray());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class EntryTraceNodeContract
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public EntryTraceNodeKind Kind { get; set; }
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public List<string> Arguments { get; set; } = new();
|
||||
public EntryTraceInterpreterKind InterpreterKind { get; set; }
|
||||
public EntryTraceEvidenceContract? Evidence { get; set; }
|
||||
public EntryTraceSpanContract? Span { get; set; }
|
||||
public Dictionary<string, string>? Metadata { get; set; }
|
||||
|
||||
public static EntryTraceNodeContract FromNode(EntryTraceNode node)
|
||||
{
|
||||
return new EntryTraceNodeContract
|
||||
{
|
||||
Id = node.Id,
|
||||
Kind = node.Kind,
|
||||
DisplayName = node.DisplayName,
|
||||
Arguments = node.Arguments.ToList(),
|
||||
InterpreterKind = node.InterpreterKind,
|
||||
Evidence = node.Evidence is null ? null : EntryTraceEvidenceContract.FromEvidence(node.Evidence),
|
||||
Span = node.Span is null ? null : EntryTraceSpanContract.FromSpan(node.Span.Value),
|
||||
Metadata = node.Metadata?.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal)
|
||||
};
|
||||
}
|
||||
|
||||
public EntryTraceNode ToNode()
|
||||
{
|
||||
return new EntryTraceNode(
|
||||
Id,
|
||||
Kind,
|
||||
DisplayName,
|
||||
Arguments.ToImmutableArray(),
|
||||
InterpreterKind,
|
||||
Evidence?.ToEvidence(),
|
||||
Span?.ToSpan(),
|
||||
Metadata is null ? null : Metadata.ToImmutableDictionary(StringComparer.Ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class EntryTraceEdgeContract
|
||||
{
|
||||
public int From { get; set; }
|
||||
public int To { get; set; }
|
||||
public string Relationship { get; set; } = string.Empty;
|
||||
public Dictionary<string, string>? Metadata { get; set; }
|
||||
|
||||
public static EntryTraceEdgeContract FromEdge(EntryTraceEdge edge)
|
||||
{
|
||||
return new EntryTraceEdgeContract
|
||||
{
|
||||
From = edge.FromNodeId,
|
||||
To = edge.ToNodeId,
|
||||
Relationship = edge.Relationship,
|
||||
Metadata = edge.Metadata?.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal)
|
||||
};
|
||||
}
|
||||
|
||||
public EntryTraceEdge ToEdge()
|
||||
{
|
||||
return new EntryTraceEdge(
|
||||
From,
|
||||
To,
|
||||
Relationship,
|
||||
Metadata is null ? null : Metadata.ToImmutableDictionary(StringComparer.Ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class EntryTraceDiagnosticContract
|
||||
{
|
||||
public EntryTraceDiagnosticSeverity Severity { get; set; }
|
||||
public EntryTraceUnknownReason Reason { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public EntryTraceSpanContract? Span { get; set; }
|
||||
public string? RelatedPath { get; set; }
|
||||
|
||||
public static EntryTraceDiagnosticContract FromDiagnostic(EntryTraceDiagnostic diagnostic)
|
||||
{
|
||||
return new EntryTraceDiagnosticContract
|
||||
{
|
||||
Severity = diagnostic.Severity,
|
||||
Reason = diagnostic.Reason,
|
||||
Message = diagnostic.Message,
|
||||
Span = diagnostic.Span is null ? null : EntryTraceSpanContract.FromSpan(diagnostic.Span.Value),
|
||||
RelatedPath = diagnostic.RelatedPath
|
||||
};
|
||||
}
|
||||
|
||||
public EntryTraceDiagnostic ToDiagnostic()
|
||||
{
|
||||
return new EntryTraceDiagnostic(
|
||||
Severity,
|
||||
Reason,
|
||||
Message,
|
||||
Span?.ToSpan(),
|
||||
RelatedPath);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class EntryTracePlanContract
|
||||
{
|
||||
public List<string> Command { get; set; } = new();
|
||||
public Dictionary<string, string> Environment { get; set; } = new();
|
||||
public string WorkingDirectory { get; set; } = string.Empty;
|
||||
public string User { get; set; } = string.Empty;
|
||||
public string TerminalPath { get; set; } = string.Empty;
|
||||
public EntryTraceTerminalType Type { get; set; }
|
||||
public string? Runtime { get; set; }
|
||||
public double Confidence { get; set; }
|
||||
public Dictionary<string, string> Evidence { get; set; } = new();
|
||||
|
||||
public static EntryTracePlanContract FromPlan(EntryTracePlan plan)
|
||||
{
|
||||
return new EntryTracePlanContract
|
||||
{
|
||||
Command = plan.Command.ToList(),
|
||||
Environment = plan.Environment.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal),
|
||||
WorkingDirectory = plan.WorkingDirectory,
|
||||
User = plan.User,
|
||||
TerminalPath = plan.TerminalPath,
|
||||
Type = plan.Type,
|
||||
Runtime = plan.Runtime,
|
||||
Confidence = plan.Confidence,
|
||||
Evidence = plan.Evidence.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal)
|
||||
};
|
||||
}
|
||||
|
||||
public EntryTracePlan ToPlan()
|
||||
{
|
||||
return new EntryTracePlan(
|
||||
Command.ToImmutableArray(),
|
||||
Environment.ToImmutableDictionary(StringComparer.Ordinal),
|
||||
WorkingDirectory,
|
||||
User,
|
||||
TerminalPath,
|
||||
Type,
|
||||
Runtime,
|
||||
Confidence,
|
||||
Evidence.ToImmutableDictionary(StringComparer.Ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class EntryTraceTerminalContract
|
||||
{
|
||||
public string Path { get; set; } = string.Empty;
|
||||
public EntryTraceTerminalType Type { get; set; }
|
||||
public string? Runtime { get; set; }
|
||||
public double Confidence { get; set; }
|
||||
public Dictionary<string, string> Evidence { get; set; } = new();
|
||||
public string User { get; set; } = string.Empty;
|
||||
public string WorkingDirectory { get; set; } = string.Empty;
|
||||
public List<string> Arguments { get; set; } = new();
|
||||
|
||||
public static EntryTraceTerminalContract FromTerminal(EntryTraceTerminal terminal)
|
||||
{
|
||||
return new EntryTraceTerminalContract
|
||||
{
|
||||
Path = terminal.Path,
|
||||
Type = terminal.Type,
|
||||
Runtime = terminal.Runtime,
|
||||
Confidence = terminal.Confidence,
|
||||
Evidence = terminal.Evidence.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal),
|
||||
User = terminal.User,
|
||||
WorkingDirectory = terminal.WorkingDirectory,
|
||||
Arguments = terminal.Arguments.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public EntryTraceTerminal ToTerminal()
|
||||
{
|
||||
return new EntryTraceTerminal(
|
||||
Path,
|
||||
Type,
|
||||
Runtime,
|
||||
Confidence,
|
||||
Evidence.ToImmutableDictionary(StringComparer.Ordinal),
|
||||
User,
|
||||
WorkingDirectory,
|
||||
Arguments.ToImmutableArray());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class EntryTraceEvidenceContract
|
||||
{
|
||||
public string Path { get; set; } = string.Empty;
|
||||
public string? Layer { get; set; }
|
||||
public string? Source { get; set; }
|
||||
public Dictionary<string, string>? Metadata { get; set; }
|
||||
|
||||
public static EntryTraceEvidenceContract FromEvidence(EntryTraceEvidence evidence)
|
||||
{
|
||||
return new EntryTraceEvidenceContract
|
||||
{
|
||||
Path = evidence.Path,
|
||||
Layer = evidence.LayerDigest,
|
||||
Source = evidence.Source,
|
||||
Metadata = evidence.Metadata?.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal)
|
||||
};
|
||||
}
|
||||
|
||||
public EntryTraceEvidence ToEvidence()
|
||||
{
|
||||
return new EntryTraceEvidence(
|
||||
Path,
|
||||
Layer,
|
||||
Source ?? string.Empty,
|
||||
Metadata is null ? null : new Dictionary<string, string>(Metadata, StringComparer.Ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class EntryTraceSpanContract
|
||||
{
|
||||
public string? Path { get; set; }
|
||||
public int StartLine { get; set; }
|
||||
public int StartColumn { get; set; }
|
||||
public int EndLine { get; set; }
|
||||
public int EndColumn { get; set; }
|
||||
|
||||
public static EntryTraceSpanContract FromSpan(EntryTraceSpan span)
|
||||
{
|
||||
return new EntryTraceSpanContract
|
||||
{
|
||||
Path = span.Path,
|
||||
StartLine = span.StartLine,
|
||||
StartColumn = span.StartColumn,
|
||||
EndLine = span.EndLine,
|
||||
EndColumn = span.EndColumn
|
||||
};
|
||||
}
|
||||
|
||||
public EntryTraceSpan ToSpan()
|
||||
{
|
||||
return new EntryTraceSpan(
|
||||
Path,
|
||||
StartLine,
|
||||
StartColumn,
|
||||
EndLine,
|
||||
EndColumn);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace;
|
||||
|
||||
public sealed record EntryTraceNdjsonMetadata(
|
||||
string ScanId,
|
||||
string ImageDigest,
|
||||
DateTimeOffset GeneratedAtUtc,
|
||||
string? Source = null);
|
||||
|
||||
public static class EntryTraceNdjsonWriter
|
||||
{
|
||||
private static readonly JsonWriterOptions WriterOptions = new()
|
||||
{
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Indented = false,
|
||||
SkipValidation = false
|
||||
};
|
||||
|
||||
public static ImmutableArray<string> Serialize(EntryTraceGraph graph, EntryTraceNdjsonMetadata metadata)
|
||||
{
|
||||
if (graph is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(graph));
|
||||
}
|
||||
|
||||
var result = ImmutableArray.CreateBuilder<string>();
|
||||
|
||||
result.Add(BuildLine(writer => WriteEntry(writer, graph, metadata)));
|
||||
|
||||
foreach (var node in graph.Nodes.OrderBy(n => n.Id))
|
||||
{
|
||||
result.Add(BuildLine(writer => WriteNode(writer, node)));
|
||||
}
|
||||
|
||||
foreach (var edge in graph.Edges
|
||||
.OrderBy(e => e.FromNodeId)
|
||||
.ThenBy(e => e.ToNodeId)
|
||||
.ThenBy(e => e.Relationship, StringComparer.Ordinal))
|
||||
{
|
||||
result.Add(BuildLine(writer => WriteEdge(writer, edge)));
|
||||
}
|
||||
|
||||
foreach (var plan in graph.Plans
|
||||
.OrderBy(p => p.TerminalPath, StringComparer.Ordinal)
|
||||
.ThenBy(p => p.Runtime, StringComparer.Ordinal))
|
||||
{
|
||||
result.Add(BuildLine(writer => WriteTarget(writer, plan)));
|
||||
}
|
||||
|
||||
foreach (var diagnostic in graph.Diagnostics
|
||||
.OrderBy(d => d.Severity)
|
||||
.ThenBy(d => d.Reason)
|
||||
.ThenBy(d => d.Message, StringComparer.Ordinal))
|
||||
{
|
||||
result.Add(BuildLine(writer => WriteWarning(writer, diagnostic)));
|
||||
}
|
||||
|
||||
foreach (var capability in ExtractCapabilities(graph))
|
||||
{
|
||||
result.Add(BuildLine(writer => WriteCapability(writer, capability)));
|
||||
}
|
||||
|
||||
return result.ToImmutable();
|
||||
}
|
||||
|
||||
private static void WriteEntry(Utf8JsonWriter writer, EntryTraceGraph graph, EntryTraceNdjsonMetadata metadata)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "entrytrace.entry");
|
||||
writer.WriteString("scan_id", metadata.ScanId);
|
||||
writer.WriteString("image_digest", metadata.ImageDigest);
|
||||
writer.WriteString("outcome", graph.Outcome.ToString().ToLowerInvariant());
|
||||
writer.WriteNumber("nodes", graph.Nodes.Length);
|
||||
writer.WriteNumber("edges", graph.Edges.Length);
|
||||
writer.WriteNumber("targets", graph.Plans.Length);
|
||||
writer.WriteNumber("warnings", graph.Diagnostics.Length);
|
||||
writer.WriteString("generated_at", metadata.GeneratedAtUtc.UtcDateTime.ToString("O"));
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Source))
|
||||
{
|
||||
writer.WriteString("source", metadata.Source);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteNode(Utf8JsonWriter writer, EntryTraceNode node)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "entrytrace.node");
|
||||
writer.WriteNumber("id", node.Id);
|
||||
writer.WriteString("kind", node.Kind.ToString().ToLowerInvariant());
|
||||
writer.WriteString("display_name", node.DisplayName);
|
||||
writer.WritePropertyName("arguments");
|
||||
WriteArray(writer, node.Arguments);
|
||||
writer.WriteString("interpreter", node.InterpreterKind.ToString().ToLowerInvariant());
|
||||
if (node.Evidence is not null)
|
||||
{
|
||||
writer.WritePropertyName("evidence");
|
||||
WriteEvidence(writer, node.Evidence);
|
||||
}
|
||||
if (node.Span is not null)
|
||||
{
|
||||
writer.WritePropertyName("span");
|
||||
WriteSpan(writer, node.Span.Value);
|
||||
}
|
||||
if (node.Metadata is not null && node.Metadata.Count > 0)
|
||||
{
|
||||
writer.WritePropertyName("metadata");
|
||||
WriteDictionary(writer, node.Metadata);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteEdge(Utf8JsonWriter writer, EntryTraceEdge edge)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "entrytrace.edge");
|
||||
writer.WriteNumber("from", edge.FromNodeId);
|
||||
writer.WriteNumber("to", edge.ToNodeId);
|
||||
writer.WriteString("relationship", edge.Relationship);
|
||||
if (edge.Metadata is not null && edge.Metadata.Count > 0)
|
||||
{
|
||||
writer.WritePropertyName("metadata");
|
||||
WriteDictionary(writer, edge.Metadata);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteTarget(Utf8JsonWriter writer, EntryTracePlan plan)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "entrytrace.target");
|
||||
writer.WriteString("path", plan.TerminalPath);
|
||||
writer.WriteString("runtime", plan.Runtime);
|
||||
writer.WriteString("terminal_type", plan.Type.ToString().ToLowerInvariant());
|
||||
writer.WriteNumber("confidence", Math.Round(plan.Confidence, 4));
|
||||
writer.WriteString("confidence_level", ConfidenceLevelFromScore(plan.Confidence));
|
||||
writer.WriteString("user", plan.User);
|
||||
writer.WriteString("working_directory", plan.WorkingDirectory);
|
||||
writer.WritePropertyName("arguments");
|
||||
WriteArray(writer, plan.Command);
|
||||
if (plan.Environment is not null && plan.Environment.Count > 0)
|
||||
{
|
||||
writer.WritePropertyName("environment");
|
||||
WriteDictionary(writer, plan.Environment);
|
||||
}
|
||||
if (plan.Evidence is not null && plan.Evidence.Count > 0)
|
||||
{
|
||||
writer.WritePropertyName("evidence");
|
||||
WriteDictionary(writer, plan.Evidence);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteWarning(Utf8JsonWriter writer, EntryTraceDiagnostic diagnostic)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "entrytrace.warning");
|
||||
writer.WriteString("severity", diagnostic.Severity.ToString().ToLowerInvariant());
|
||||
writer.WriteString("reason", diagnostic.Reason.ToString().ToLowerInvariant());
|
||||
writer.WriteString("message", diagnostic.Message);
|
||||
if (diagnostic.Span is not null)
|
||||
{
|
||||
writer.WritePropertyName("span");
|
||||
WriteSpan(writer, diagnostic.Span.Value);
|
||||
}
|
||||
if (!string.IsNullOrEmpty(diagnostic.RelatedPath))
|
||||
{
|
||||
writer.WriteString("related_path", diagnostic.RelatedPath);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteCapability(Utf8JsonWriter writer, CapabilitySummary capability)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "entrytrace.capability");
|
||||
writer.WriteString("category", capability.Category);
|
||||
writer.WriteString("name", capability.Name);
|
||||
writer.WriteNumber("occurrences", capability.Count);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static string BuildLine(Action<Utf8JsonWriter> build)
|
||||
{
|
||||
var buffer = new ArrayBufferWriter<byte>(256);
|
||||
using (var writer = new Utf8JsonWriter(buffer, WriterOptions))
|
||||
{
|
||||
build(writer);
|
||||
writer.Flush();
|
||||
}
|
||||
|
||||
var json = Encoding.UTF8.GetString(buffer.WrittenSpan);
|
||||
return json + "\n";
|
||||
}
|
||||
|
||||
private static void WriteArray(Utf8JsonWriter writer, ImmutableArray<string> values)
|
||||
{
|
||||
writer.WriteStartArray();
|
||||
foreach (var value in values)
|
||||
{
|
||||
writer.WriteStringValue(value);
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
private static void WriteDictionary(Utf8JsonWriter writer, IReadOnlyDictionary<string, string> values)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
foreach (var kvp in values.OrderBy(kvp => kvp.Key, StringComparer.Ordinal))
|
||||
{
|
||||
writer.WriteString(kvp.Key, kvp.Value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteEvidence(Utf8JsonWriter writer, EntryTraceEvidence evidence)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("path", evidence.Path);
|
||||
if (!string.IsNullOrWhiteSpace(evidence.LayerDigest))
|
||||
{
|
||||
writer.WriteString("layer", evidence.LayerDigest);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(evidence.Source))
|
||||
{
|
||||
writer.WriteString("source", evidence.Source);
|
||||
}
|
||||
if (evidence.Metadata is not null && evidence.Metadata.Count > 0)
|
||||
{
|
||||
writer.WritePropertyName("metadata");
|
||||
WriteDictionary(writer, evidence.Metadata);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteSpan(Utf8JsonWriter writer, EntryTraceSpan span)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
if (!string.IsNullOrWhiteSpace(span.Path))
|
||||
{
|
||||
writer.WriteString("path", span.Path);
|
||||
}
|
||||
writer.WriteNumber("start_line", span.StartLine);
|
||||
writer.WriteNumber("start_column", span.StartColumn);
|
||||
writer.WriteNumber("end_line", span.EndLine);
|
||||
writer.WriteNumber("end_column", span.EndColumn);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static ImmutableArray<CapabilitySummary> ExtractCapabilities(EntryTraceGraph graph)
|
||||
{
|
||||
var accumulator = new Dictionary<(string Category, string Name), int>(CapabilityKeyComparer.Instance);
|
||||
|
||||
foreach (var metadata in graph.Nodes.Select(n => n.Metadata).Where(m => m is not null))
|
||||
{
|
||||
AccumulateCapability(accumulator, metadata!);
|
||||
}
|
||||
|
||||
foreach (var metadata in graph.Edges.Select(e => e.Metadata).Where(m => m is not null))
|
||||
{
|
||||
AccumulateCapability(accumulator, metadata!);
|
||||
}
|
||||
|
||||
return accumulator
|
||||
.OrderBy(kvp => kvp.Key.Category, comparer: StringComparer.Ordinal)
|
||||
.ThenBy(kvp => kvp.Key.Name, comparer: StringComparer.Ordinal)
|
||||
.Select(kvp => new CapabilitySummary(kvp.Key.Category, kvp.Key.Name, kvp.Value))
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static void AccumulateCapability(
|
||||
IDictionary<(string Category, string Name), int> accumulator,
|
||||
IReadOnlyDictionary<string, string> metadata)
|
||||
{
|
||||
if (!metadata.TryGetValue("wrapper.category", out var category) || string.IsNullOrWhiteSpace(category))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!metadata.TryGetValue("wrapper.name", out var name) || string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
name = category;
|
||||
}
|
||||
|
||||
var key = (category, name);
|
||||
accumulator.TryGetValue(key, out var count);
|
||||
accumulator[key] = count + 1;
|
||||
}
|
||||
|
||||
private readonly record struct CapabilitySummary(string Category, string Name, int Count);
|
||||
|
||||
private static string ConfidenceLevelFromScore(double score)
|
||||
{
|
||||
if (score >= 90d)
|
||||
{
|
||||
return "high";
|
||||
}
|
||||
|
||||
if (score >= 75d)
|
||||
{
|
||||
return "medium";
|
||||
}
|
||||
|
||||
return "low";
|
||||
}
|
||||
|
||||
private sealed class CapabilityKeyComparer : IEqualityComparer<(string Category, string Name)>
|
||||
{
|
||||
public static CapabilityKeyComparer Instance { get; } = new();
|
||||
|
||||
public bool Equals((string Category, string Name) x, (string Category, string Name) y)
|
||||
{
|
||||
return StringComparer.OrdinalIgnoreCase.Equals(x.Category, y.Category)
|
||||
&& StringComparer.OrdinalIgnoreCase.Equals(x.Name, y.Name);
|
||||
}
|
||||
|
||||
public int GetHashCode((string Category, string Name) obj)
|
||||
{
|
||||
var categoryHash = StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Category ?? string.Empty);
|
||||
var nameHash = StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Name ?? string.Empty);
|
||||
return HashCode.Combine(categoryHash, nameHash);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,32 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.EntryTrace.Diagnostics;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddEntryTraceAnalyzer(this IServiceCollection services, Action<EntryTraceAnalyzerOptions>? configure = null)
|
||||
{
|
||||
if (services is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(services));
|
||||
}
|
||||
|
||||
services.AddOptions<EntryTraceAnalyzerOptions>()
|
||||
.BindConfiguration(EntryTraceAnalyzerOptions.SectionName);
|
||||
|
||||
if (configure is not null)
|
||||
{
|
||||
services.Configure(configure);
|
||||
}
|
||||
|
||||
services.TryAddSingleton<EntryTraceMetrics>();
|
||||
services.TryAddSingleton<IEntryTraceAnalyzer, EntryTraceAnalyzer>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.EntryTrace.Diagnostics;
|
||||
using StellaOps.Scanner.EntryTrace.Runtime;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddEntryTraceAnalyzer(this IServiceCollection services, Action<EntryTraceAnalyzerOptions>? configure = null)
|
||||
{
|
||||
if (services is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(services));
|
||||
}
|
||||
|
||||
services.AddOptions<EntryTraceAnalyzerOptions>()
|
||||
.BindConfiguration(EntryTraceAnalyzerOptions.SectionName);
|
||||
|
||||
if (configure is not null)
|
||||
{
|
||||
services.Configure(configure);
|
||||
}
|
||||
|
||||
services.TryAddSingleton<EntryTraceMetrics>();
|
||||
services.TryAddSingleton<IEntryTraceAnalyzer, EntryTraceAnalyzer>();
|
||||
services.TryAddSingleton<EntryTraceRuntimeReconciler>();
|
||||
services.TryAddSingleton<IEntryTraceResultStore, NullEntryTraceResultStore>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,5 +15,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Scanner.Surface.FS/StellaOps.Scanner.Surface.FS.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Scanner.Surface.Validation/StellaOps.Scanner.Surface.Validation.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| SCANNER-ENTRYTRACE-18-502 | TODO | EntryTrace Guild | SCANNER-ENTRYTRACE-18-501 | Expand chain walker with init shim/user-switch/supervisor recognition plus env/workdir accumulation and guarded edges. | Graph nodes annotate tini/dumb-init/gosu/su-exec/s6/supervisord/runit branches with capability tags, environment deltas, and guard metadata validated against fixture scripts. |
|
||||
| SCANNER-ENTRYTRACE-18-503 | TODO | EntryTrace Guild | SCANNER-ENTRYTRACE-18-502 | Introduce target classifier + EntryPlan handoff with confidence scoring for ELF/Java/.NET/Node/Python and user/workdir context. | Analyzer returns typed targets with confidence metrics and per-branch EntryPlans exercised via golden fixtures and language analyzer stubs. |
|
||||
| SCANNER-ENTRYTRACE-18-504 | TODO | EntryTrace Guild | SCANNER-ENTRYTRACE-18-503 | Emit EntryTrace AOC NDJSON (`entrytrace.entry/node/edge/target/warning/capability`) and wire CLI/service streaming outputs. | NDJSON writer passes determinism tests, CLI/service endpoints stream ordered observations, and diagnostics integrate new warning codes for dynamic eval/glob limits/windows shims. |
|
||||
| ENTRYTRACE-SURFACE-01 | TODO | EntryTrace Guild | SURFACE-VAL-02, SURFACE-FS-02 | Run Surface.Validation prereq checks and resolve cached entry fragments via Surface.FS to avoid duplicate parsing. | EntryTrace performance metrics show reuse; regression tests updated; validation errors surfaced consistently. |
|
||||
| ENTRYTRACE-SURFACE-02 | TODO | EntryTrace Guild | SURFACE-SECRETS-02 | Replace direct env/secret access with Surface.Secrets provider when tracing runtime configs. | Shared provider used; failure modes covered; documentation refreshed. |
|
||||
| SCANNER-ENTRYTRACE-18-502 | DONE (2025-11-01) | EntryTrace Guild | SCANNER-ENTRYTRACE-18-501 | Expand chain walker with init shim/user-switch/supervisor recognition plus env/workdir accumulation and guarded edges. | Graph nodes annotate tini/dumb-init/gosu/su-exec/s6/supervisord/runit branches with capability tags, environment deltas, and guard metadata validated against fixture scripts. |
|
||||
| SCANNER-ENTRYTRACE-18-503 | DONE (2025-11-01) | EntryTrace Guild | SCANNER-ENTRYTRACE-18-502 | Introduce target classifier + EntryPlan handoff with confidence scoring for ELF/Java/.NET/Node/Python/Ruby/PHP-FPM/Go/Rust/Nginx and user/workdir context; capture PT_INTERP / CLR / Go BuildID / Rust notes and jar manifests as evidence. | Analyzer returns typed targets with confidence metrics, binary fingerprint evidence (PT_INTERP, CLR header, Go/Rust markers, jar Main-Class), and per-branch EntryPlans exercised via golden fixtures and language analyzer stubs. |
|
||||
| SCANNER-ENTRYTRACE-18-504 | DONE (2025-11-01) | EntryTrace Guild | SCANNER-ENTRYTRACE-18-503 | Emit EntryTrace AOC NDJSON (`entrytrace.entry/node/edge/target/warning/capability`) and wire CLI/service streaming outputs. | NDJSON writer passes determinism tests, CLI/service endpoints stream ordered observations, and diagnostics integrate new warning codes for dynamic eval/glob limits/windows shims. |
|
||||
| SCANNER-ENTRYTRACE-18-505 | DONE (2025-11-02) | EntryTrace Guild | SCANNER-ENTRYTRACE-18-504 | Implement process-tree replay (ProcGraph) to reconcile `/proc` exec chains with static EntryTrace results, collapsing wrappers (tini/gosu/supervisord) and emitting agreement/conflict diagnostics. | Runtime harness walks `/proc` (tests + fixture containers), merges ProcGraph with static graph, records High/Medium/Low confidence outcomes, and adds coverage to integration tests. |
|
||||
| SCANNER-ENTRYTRACE-18-506 | DONE (2025-11-02) | EntryTrace Guild, Scanner WebService Guild | SCANNER-ENTRYTRACE-18-505 | Surface EntryTrace graph + confidence via Scanner.WebService and CLI (REST + streaming), including target summary in scan reports and policy payloads. | WebService exposes `/scans/{id}/entrytrace` + CLI verb, responses include chain/terminal/confidence/evidence, golden fixtures updated, and Policy/Export contracts documented. |
|
||||
| SCANNER-ENTRYTRACE-18-507 | DOING (2025-11-02) | EntryTrace Guild | SCANNER-ENTRYTRACE-18-503 | Expand candidate discovery beyond ENTRYPOINT/CMD by scanning Docker history metadata and default service directories (`/etc/services/**`, `/s6/**`, `/etc/supervisor/*.conf`, `/usr/local/bin/*-entrypoint`) when explicit commands are absent. | Analyzer produces deterministic fallback candidates with evidence per discovery source, golden fixtures cover supervisor/service directories, and diagnostics distinguish inferred vs declared entrypoints. |
|
||||
| SCANNER-ENTRYTRACE-18-508 | DOING (2025-11-02) | EntryTrace Guild | SCANNER-ENTRYTRACE-18-503 | Extend wrapper catalogue to collapse language/package launchers (`bundle`, `bundle exec`, `docker-php-entrypoint`, `npm`, `yarn node`, `pipenv`, `poetry run`) and vendor init scripts before terminal classification. | Wrapper detection table includes the new aliases with metadata, analyzer unwraps them into underlying commands, and fixture scripts assert metadata for runtime/package managers. |
|
||||
| SCANNER-ENTRYTRACE-18-509 | DONE (2025-11-02) | EntryTrace Guild, QA Guild | SCANNER-ENTRYTRACE-18-506 | Add regression coverage for persisted EntryTrace surfaces (result store, WebService endpoint, CLI renderer) and NDJSON payload hashing. | Unit/integration tests cover result retrieval (store/WebService), CLI rendering (`scan entrytrace`), and NDJSON hash stability with fixture snapshots. |
|
||||
| ENTRYTRACE-SURFACE-01 | DONE (2025-11-02) | EntryTrace Guild | SURFACE-VAL-02, SURFACE-FS-02 | Run Surface.Validation prereq checks and resolve cached entry fragments via Surface.FS to avoid duplicate parsing. | EntryTrace performance metrics show reuse; regression tests updated; validation errors surfaced consistently. |
|
||||
| ENTRYTRACE-SURFACE-02 | DONE (2025-11-02) | EntryTrace Guild | SURFACE-SECRETS-02 | Replace direct env/secret access with Surface.Secrets provider when tracing runtime configs. | Shared provider used; failure modes covered; documentation refreshed. |
|
||||
|
||||
## Status Review — 2025-10-19
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Catalog;
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class EntryTraceDocument
|
||||
{
|
||||
[BsonId]
|
||||
public string ScanId { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("image_digest")]
|
||||
public string ImageDigest { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("generated_at")]
|
||||
public DateTime GeneratedAtUtc { get; set; }
|
||||
= DateTime.UtcNow;
|
||||
|
||||
[BsonElement("graph_json")]
|
||||
public string GraphJson { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("ndjson")]
|
||||
public List<string> Ndjson { get; set; } = new();
|
||||
}
|
||||
@@ -5,9 +5,10 @@ using Amazon.S3;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Scanner.EntryTrace;
|
||||
using StellaOps.Scanner.Storage.Migrations;
|
||||
using StellaOps.Scanner.Storage.Mongo;
|
||||
using StellaOps.Scanner.Storage.ObjectStore;
|
||||
@@ -64,6 +65,8 @@ public static class ServiceCollectionExtensions
|
||||
services.TryAddSingleton<JobRepository>();
|
||||
services.TryAddSingleton<LifecycleRuleRepository>();
|
||||
services.TryAddSingleton<RuntimeEventRepository>();
|
||||
services.TryAddSingleton<EntryTraceRepository>();
|
||||
services.AddSingleton<IEntryTraceResultStore, EntryTraceResultStore>();
|
||||
|
||||
services.AddHttpClient(RustFsArtifactObjectStore.HttpClientName)
|
||||
.ConfigureHttpClient((sp, client) =>
|
||||
|
||||
@@ -22,6 +22,7 @@ public sealed class MongoCollectionProvider
|
||||
public IMongoCollection<JobDocument> Jobs => GetCollection<JobDocument>(ScannerStorageDefaults.Collections.Jobs);
|
||||
public IMongoCollection<LifecycleRuleDocument> LifecycleRules => GetCollection<LifecycleRuleDocument>(ScannerStorageDefaults.Collections.LifecycleRules);
|
||||
public IMongoCollection<RuntimeEventDocument> RuntimeEvents => GetCollection<RuntimeEventDocument>(ScannerStorageDefaults.Collections.RuntimeEvents);
|
||||
public IMongoCollection<EntryTraceDocument> EntryTrace => GetCollection<EntryTraceDocument>(ScannerStorageDefaults.Collections.EntryTrace);
|
||||
|
||||
private IMongoCollection<TDocument> GetCollection<TDocument>(string name)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Scanner.Storage.Catalog;
|
||||
using StellaOps.Scanner.Storage.Mongo;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
public sealed class EntryTraceRepository
|
||||
{
|
||||
private readonly MongoCollectionProvider _collections;
|
||||
|
||||
public EntryTraceRepository(MongoCollectionProvider collections)
|
||||
{
|
||||
_collections = collections ?? throw new ArgumentNullException(nameof(collections));
|
||||
}
|
||||
|
||||
public async Task<EntryTraceDocument?> GetAsync(string scanId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
|
||||
return await _collections.EntryTrace
|
||||
.Find(x => x.ScanId == scanId)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(EntryTraceDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
var options = new ReplaceOptions { IsUpsert = true };
|
||||
await _collections.EntryTrace
|
||||
.ReplaceOneAsync(x => x.ScanId == document.ScanId, document, options, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ public static class ScannerStorageDefaults
|
||||
public const string Jobs = "jobs";
|
||||
public const string LifecycleRules = "lifecycle_rules";
|
||||
public const string RuntimeEvents = "runtime.events";
|
||||
public const string EntryTrace = "entrytrace";
|
||||
public const string Migrations = "schema_migrations";
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.EntryTrace;
|
||||
using StellaOps.Scanner.EntryTrace.Serialization;
|
||||
using StellaOps.Scanner.Storage.Catalog;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Services;
|
||||
|
||||
public sealed class EntryTraceResultStore : IEntryTraceResultStore
|
||||
{
|
||||
private readonly EntryTraceRepository _repository;
|
||||
|
||||
public EntryTraceResultStore(EntryTraceRepository repository)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
}
|
||||
|
||||
public async Task StoreAsync(EntryTraceResult result, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
|
||||
var document = new EntryTraceDocument
|
||||
{
|
||||
ScanId = result.ScanId,
|
||||
ImageDigest = result.ImageDigest,
|
||||
GeneratedAtUtc = result.GeneratedAtUtc.UtcDateTime,
|
||||
GraphJson = EntryTraceGraphSerializer.Serialize(result.Graph),
|
||||
Ndjson = result.Ndjson.ToList()
|
||||
};
|
||||
|
||||
await _repository.UpsertAsync(document, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<EntryTraceResult?> GetAsync(string scanId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
|
||||
|
||||
var document = await _repository.GetAsync(scanId, cancellationToken).ConfigureAwait(false);
|
||||
if (document is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var graph = EntryTraceGraphSerializer.Deserialize(document.GraphJson);
|
||||
var ndjson = document.Ndjson?.ToImmutableArray() ?? ImmutableArray<string>.Empty;
|
||||
var generatedAt = DateTime.SpecifyKind(document.GeneratedAtUtc, DateTimeKind.Utc);
|
||||
return new EntryTraceResult(
|
||||
document.ScanId,
|
||||
document.ImageDigest,
|
||||
new DateTimeOffset(generatedAt),
|
||||
graph,
|
||||
ndjson);
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,16 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
|
||||
<PackageReference Include="AWSSDK.S3" Version="3.7.305.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
|
||||
<PackageReference Include="AWSSDK.S3" Version="3.7.305.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\StellaOps.Scanner.EntryTrace\\StellaOps.Scanner.EntryTrace.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Scanner.Surface.Env.Tests")]
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Env;
|
||||
|
||||
/// <summary>
|
||||
/// Provides resolved surface environment settings for a component.
|
||||
/// </summary>
|
||||
public interface ISurfaceEnvironment
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the resolved settings for the current component.
|
||||
/// </summary>
|
||||
SurfaceEnvironmentSettings Settings { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw environment variables and configuration values that were used while building the settings.
|
||||
/// </summary>
|
||||
IReadOnlyDictionary<string, string> RawVariables { get; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Env;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddSurfaceEnvironment(
|
||||
this IServiceCollection services,
|
||||
Action<SurfaceEnvironmentOptions>? configure = null)
|
||||
{
|
||||
if (services is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(services));
|
||||
}
|
||||
|
||||
services.AddSingleton<ISurfaceEnvironment>(sp => SurfaceEnvironmentFactory.Create(sp, configure));
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<EnableDefaultItems>false</EnableDefaultItems>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="**\*.cs" Exclude="obj\**;bin\**" />
|
||||
<EmbeddedResource Include="**\*.json" Exclude="obj\**;bin\**" />
|
||||
<None Include="**\*" Exclude="**\*.cs;**\*.json;bin\**;obj\**" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Env;
|
||||
|
||||
internal sealed class SurfaceEnvironment : ISurfaceEnvironment
|
||||
{
|
||||
public SurfaceEnvironment(SurfaceEnvironmentSettings settings, IReadOnlyDictionary<string, string> raw)
|
||||
{
|
||||
Settings = settings ?? throw new ArgumentNullException(nameof(settings));
|
||||
RawVariables = raw ?? throw new ArgumentNullException(nameof(raw));
|
||||
}
|
||||
|
||||
public SurfaceEnvironmentSettings Settings { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, string> RawVariables { get; }
|
||||
}
|
||||
|
||||
internal static class SurfaceEnvironmentFactory
|
||||
{
|
||||
public static ISurfaceEnvironment Create(IServiceProvider services, Action<SurfaceEnvironmentOptions>? configure = null)
|
||||
{
|
||||
var options = new SurfaceEnvironmentOptions();
|
||||
configure?.Invoke(options);
|
||||
|
||||
if (options.Prefixes.Count == 0)
|
||||
{
|
||||
options.AddPrefix("SCANNER");
|
||||
}
|
||||
|
||||
var configuration = services.GetRequiredService<IConfiguration>();
|
||||
var logger = services.GetRequiredService<ILogger<SurfaceEnvironmentBuilder>>();
|
||||
|
||||
var builder = new SurfaceEnvironmentBuilder(services, configuration, logger, options);
|
||||
var settings = builder.Build();
|
||||
var raw = builder.GetRawVariables();
|
||||
return new SurfaceEnvironment(settings, raw);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Env;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves <see cref="SurfaceEnvironmentSettings"/> instances from configuration sources.
|
||||
/// </summary>
|
||||
public sealed class SurfaceEnvironmentBuilder
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<SurfaceEnvironmentBuilder> _logger;
|
||||
private readonly SurfaceEnvironmentOptions _options;
|
||||
private readonly Dictionary<string, string> _raw = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public SurfaceEnvironmentBuilder(
|
||||
IServiceProvider services,
|
||||
IConfiguration configuration,
|
||||
ILogger<SurfaceEnvironmentBuilder> logger,
|
||||
SurfaceEnvironmentOptions options)
|
||||
{
|
||||
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
|
||||
if (_options.Prefixes.Count == 0)
|
||||
{
|
||||
_options.AddPrefix("SCANNER");
|
||||
}
|
||||
}
|
||||
|
||||
public SurfaceEnvironmentSettings Build()
|
||||
{
|
||||
var endpoint = ResolveUri("SURFACE_FS_ENDPOINT", required: _options.RequireSurfaceEndpoint);
|
||||
var bucket = ResolveString("SURFACE_FS_BUCKET", "surface-cache", required: endpoint is not null);
|
||||
var region = ResolveOptionalString("SURFACE_FS_REGION");
|
||||
var cacheRoot = ResolveDirectory("SURFACE_CACHE_ROOT", new DirectoryInfo(Path.Combine(Path.GetTempPath(), "stellaops", "surface")));
|
||||
var cacheQuota = ResolveInt("SURFACE_CACHE_QUOTA_MB", 4096, min: 64, max: 262144);
|
||||
var prefetch = ResolveBool("SURFACE_PREFETCH_ENABLED", defaultValue: false);
|
||||
var featureFlags = ResolveFeatureFlags();
|
||||
var secrets = ResolveSecretsConfiguration();
|
||||
var tls = ResolveTlsConfiguration();
|
||||
var tenant = ResolveTenant() ?? "default";
|
||||
|
||||
var settings = new SurfaceEnvironmentSettings(
|
||||
endpoint ?? new Uri("https://surface.invalid"),
|
||||
bucket,
|
||||
region,
|
||||
cacheRoot,
|
||||
cacheQuota,
|
||||
prefetch,
|
||||
featureFlags,
|
||||
secrets,
|
||||
tenant,
|
||||
tls);
|
||||
|
||||
return settings with { CreatedAtUtc = DateTimeOffset.UtcNow };
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, string> GetRawVariables()
|
||||
=> new Dictionary<string, string>(_raw, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private Uri? ResolveUri(string suffix, bool required)
|
||||
{
|
||||
var value = ResolveString(suffix, required: required);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
|
||||
{
|
||||
throw new SurfaceEnvironmentException($"Value '{value}' for {suffix} is not a valid absolute URI.", suffix);
|
||||
}
|
||||
|
||||
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning("Surface environment endpoint {Endpoint} is not HTTPS.", uri);
|
||||
}
|
||||
|
||||
return uri;
|
||||
}
|
||||
|
||||
private string ResolveString(string suffix, string? defaultValue = null, bool required = false)
|
||||
{
|
||||
var value = ResolveOptionalString(suffix);
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value!;
|
||||
}
|
||||
|
||||
if (required && defaultValue is null)
|
||||
{
|
||||
throw new SurfaceEnvironmentException($"Required surface environment variable {FormatNames(suffix)} was not provided.", suffix);
|
||||
}
|
||||
|
||||
return defaultValue ?? string.Empty;
|
||||
}
|
||||
|
||||
private string? ResolveOptionalString(string suffix)
|
||||
{
|
||||
foreach (var name in EnumerateNames(suffix))
|
||||
{
|
||||
var value = Environment.GetEnvironmentVariable(name);
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
_raw[name] = value!;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
var configKey = BuildConfigurationKey(suffix);
|
||||
var configured = _configuration[configKey];
|
||||
if (!string.IsNullOrWhiteSpace(configured))
|
||||
{
|
||||
_raw[configKey] = configured!;
|
||||
return configured;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private DirectoryInfo ResolveDirectory(string suffix, DirectoryInfo fallback)
|
||||
{
|
||||
var path = ResolveOptionalString(suffix) ?? fallback.FullName;
|
||||
var directory = new DirectoryInfo(path);
|
||||
if (!directory.Exists)
|
||||
{
|
||||
directory.Create();
|
||||
}
|
||||
|
||||
return directory;
|
||||
}
|
||||
|
||||
private int ResolveInt(string suffix, int defaultValue, int min, int max)
|
||||
{
|
||||
var value = ResolveOptionalString(suffix);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
if (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
|
||||
{
|
||||
throw new SurfaceEnvironmentException($"Value '{value}' for {suffix} is not a valid integer.", suffix);
|
||||
}
|
||||
|
||||
if (parsed < min || parsed > max)
|
||||
{
|
||||
throw new SurfaceEnvironmentException($"Value '{parsed}' for {suffix} must be between {min} and {max}.", suffix);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private bool ResolveBool(string suffix, bool defaultValue)
|
||||
{
|
||||
var value = ResolveOptionalString(suffix);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
if (!bool.TryParse(value, out var parsed))
|
||||
{
|
||||
throw new SurfaceEnvironmentException($"Value '{value}' for {suffix} is not a valid boolean.", suffix);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private IReadOnlyCollection<string> ResolveFeatureFlags()
|
||||
{
|
||||
var rawFlags = ResolveOptionalString("SURFACE_FEATURES");
|
||||
if (string.IsNullOrWhiteSpace(rawFlags))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var flags = rawFlags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(flag => flag.ToLowerInvariant())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
foreach (var flag in flags)
|
||||
{
|
||||
if (_options.KnownFeatureFlags.Count > 0 && !_options.KnownFeatureFlags.Contains(flag))
|
||||
{
|
||||
_logger.LogWarning("Unknown surface feature flag '{Flag}' detected for component {Component}.", flag, _options.ComponentName);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Surface environment feature flag detected: {Flag}.", flag);
|
||||
}
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
private SurfaceSecretsConfiguration ResolveSecretsConfiguration()
|
||||
{
|
||||
var provider = ResolveString("SURFACE_SECRETS_PROVIDER", "kubernetes");
|
||||
var root = ResolveOptionalString("SURFACE_SECRETS_ROOT");
|
||||
var ns = ResolveOptionalString("SURFACE_SECRETS_NAMESPACE");
|
||||
var fallback = ResolveOptionalString("SURFACE_SECRETS_FALLBACK_PROVIDER");
|
||||
var allowInline = ResolveBool("SURFACE_SECRETS_ALLOW_INLINE", defaultValue: false);
|
||||
var tenant = ResolveOptionalString("SURFACE_SECRETS_TENANT") ?? ResolveTenant() ?? "default";
|
||||
|
||||
return new SurfaceSecretsConfiguration(provider, tenant, root, ns, fallback, allowInline);
|
||||
}
|
||||
|
||||
private SurfaceTlsConfiguration ResolveTlsConfiguration()
|
||||
{
|
||||
var certPath = ResolveOptionalString("SURFACE_TLS_CERT_PATH");
|
||||
var keyPath = ResolveOptionalString("SURFACE_TLS_KEY_PATH");
|
||||
|
||||
X509Certificate2Collection? certificates = null;
|
||||
if (!string.IsNullOrWhiteSpace(certPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(certPath))
|
||||
{
|
||||
throw new FileNotFoundException("TLS certificate path not found.", certPath);
|
||||
}
|
||||
|
||||
var certificate = X509CertificateLoader.LoadCertificateFromFile(certPath);
|
||||
certificates = new X509Certificate2Collection { certificate };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new SurfaceEnvironmentException($"Failed to load TLS certificate from '{certPath}': {ex.Message}", "SURFACE_TLS_CERT_PATH", ex);
|
||||
}
|
||||
}
|
||||
|
||||
return new SurfaceTlsConfiguration(certPath, keyPath, certificates);
|
||||
}
|
||||
|
||||
private string? ResolveTenant()
|
||||
{
|
||||
var tenant = ResolveOptionalString("SURFACE_TENANT");
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return tenant;
|
||||
}
|
||||
|
||||
if (_options.TenantResolver is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
tenant = _options.TenantResolver(_services);
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return tenant;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Tenant resolver for component {Component} threw an exception.", _options.ComponentName);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private IEnumerable<string> EnumerateNames(string suffix)
|
||||
{
|
||||
foreach (var prefix in _options.Prefixes)
|
||||
{
|
||||
yield return $"{prefix}_{suffix}";
|
||||
}
|
||||
|
||||
yield return suffix;
|
||||
}
|
||||
|
||||
private string BuildConfigurationKey(string suffix)
|
||||
{
|
||||
var withoutPrefix = suffix.StartsWith("SURFACE_", StringComparison.OrdinalIgnoreCase)
|
||||
? suffix[8..]
|
||||
: suffix;
|
||||
|
||||
return $"Surface:{withoutPrefix.Replace('_', ':')}";
|
||||
}
|
||||
|
||||
private string FormatNames(string suffix)
|
||||
=> string.Join(", ", EnumerateNames(suffix));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Env;
|
||||
|
||||
public sealed class SurfaceEnvironmentException : Exception
|
||||
{
|
||||
public SurfaceEnvironmentException(string message, string variable)
|
||||
: base(message)
|
||||
{
|
||||
Variable = variable;
|
||||
}
|
||||
|
||||
public SurfaceEnvironmentException(string message, string variable, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
Variable = variable;
|
||||
}
|
||||
|
||||
public string Variable { get; }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Env;
|
||||
|
||||
/// <summary>
|
||||
/// Options controlling how the surface environment is resolved.
|
||||
/// </summary>
|
||||
public sealed class SurfaceEnvironmentOptions
|
||||
{
|
||||
private readonly List<string> _prefixes = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the logical component name (e.g. "Scanner.Worker", "Zastava.Observer").
|
||||
/// </summary>
|
||||
public string ComponentName { get; set; } = "Scanner.Worker";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ordered list of environment variable prefixes that will be probed when resolving configuration values.
|
||||
/// The prefixes are evaluated in order; the first match wins.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Prefixes => _prefixes;
|
||||
|
||||
/// <summary>
|
||||
/// Adds a prefix to the ordered prefix list.
|
||||
/// </summary>
|
||||
public void AddPrefix(string prefix)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(prefix))
|
||||
{
|
||||
throw new ArgumentException("Prefix cannot be null or whitespace.", nameof(prefix));
|
||||
}
|
||||
|
||||
if (!_prefixes.Contains(prefix, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
_prefixes.Add(prefix);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When set to <c>true</c>, a missing Surface FS endpoint raises an exception.
|
||||
/// </summary>
|
||||
public bool RequireSurfaceEndpoint { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional delegate used to resolve the tenant when not explicitly provided via environment variables.
|
||||
/// </summary>
|
||||
public Func<IServiceProvider, string?>? TenantResolver { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the set of recognised feature flags. Unknown flags produce validation warnings.
|
||||
/// </summary>
|
||||
public ISet<string> KnownFeatureFlags { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Env;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the resolved surface environment configuration for a component.
|
||||
/// </summary>
|
||||
public sealed record SurfaceEnvironmentSettings(
|
||||
Uri SurfaceFsEndpoint,
|
||||
string SurfaceFsBucket,
|
||||
string? SurfaceFsRegion,
|
||||
DirectoryInfo CacheRoot,
|
||||
int CacheQuotaMegabytes,
|
||||
bool PrefetchEnabled,
|
||||
IReadOnlyCollection<string> FeatureFlags,
|
||||
SurfaceSecretsConfiguration Secrets,
|
||||
string Tenant,
|
||||
SurfaceTlsConfiguration Tls)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the timestamp (UTC) when the configuration snapshot was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAtUtc { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace StellaOps.Scanner.Surface.Env;
|
||||
|
||||
/// <summary>
|
||||
/// Represents secret provider configuration resolved for the current component.
|
||||
/// </summary>
|
||||
public sealed record SurfaceSecretsConfiguration(
|
||||
string Provider,
|
||||
string Tenant,
|
||||
string? Root,
|
||||
string? Namespace,
|
||||
string? FallbackProvider,
|
||||
bool AllowInline)
|
||||
{
|
||||
public bool HasFallback => !string.IsNullOrWhiteSpace(FallbackProvider);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Env;
|
||||
|
||||
/// <summary>
|
||||
/// TLS configuration associated with the surface endpoints.
|
||||
/// </summary>
|
||||
public sealed record SurfaceTlsConfiguration(
|
||||
string? CertificatePath,
|
||||
string? PrivateKeyPath,
|
||||
X509Certificate2Collection? ClientCertificates)
|
||||
{
|
||||
public bool HasClientCertificates => ClientCertificates is { Count: > 0 };
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| SURFACE-ENV-01 | TODO | Scanner Guild, Zastava Guild | ARCH-SURFACE-EPIC | Draft `docs/modules/scanner/design/surface-env.md` enumerating environment variables, defaults, and air-gap behaviour. | Spec merged; env matrix reviewed by Ops + Security. |
|
||||
| SURFACE-ENV-02 | TODO | Scanner Guild | SURFACE-ENV-01 | Implement strongly-typed env accessors in `StellaOps.Scanner.Surface.Env` with validation and deterministic logging. | Library published; unit tests cover parsing, fallbacks, and error paths. |
|
||||
| SURFACE-ENV-01 | DOING (2025-11-01) | Scanner Guild, Zastava Guild | ARCH-SURFACE-EPIC | Draft `docs/modules/scanner/design/surface-env.md` enumerating environment variables, defaults, and air-gap behaviour. | Spec merged; env matrix reviewed by Ops + Security. |
|
||||
| SURFACE-ENV-02 | DOING (2025-11-02) | Scanner Guild | SURFACE-ENV-01 | Implement strongly-typed env accessors in `StellaOps.Scanner.Surface.Env` with validation and deterministic logging. | Library published; unit tests cover parsing, fallbacks, and error paths. |
|
||||
| SURFACE-ENV-03 | TODO | Scanner Guild | SURFACE-ENV-02 | Adopt env helper across Scanner Worker/WebService/BuildX plug-ins. | Services use helper; manifests updated; smoke tests green. |
|
||||
| SURFACE-ENV-04 | TODO | Zastava Guild | SURFACE-ENV-02 | Wire env helper into Zastava Observer/Webhook containers. | Zastava builds reference env helper; admission tests validated. |
|
||||
| SURFACE-ENV-05 | TODO | Ops Guild | SURFACE-ENV-03..04 | Update Helm/Compose/offline kit templates with new env knobs and documentation. | Templates merged; docs include configuration table; air-gap scripts updated. |
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.FS;
|
||||
|
||||
public sealed class FileSurfaceCache : ISurfaceCache
|
||||
{
|
||||
private readonly string _root;
|
||||
private readonly ILogger<FileSurfaceCache> _logger;
|
||||
private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new(StringComparer.Ordinal);
|
||||
|
||||
public FileSurfaceCache(
|
||||
IOptions<SurfaceCacheOptions> options,
|
||||
ILogger<FileSurfaceCache> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
var root = options?.Value?.ResolveRoot();
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
throw new ArgumentException("Surface cache root directory must be provided.", nameof(options));
|
||||
}
|
||||
|
||||
_root = root!;
|
||||
}
|
||||
|
||||
public async Task<T> GetOrCreateAsync<T>(
|
||||
SurfaceCacheKey key,
|
||||
Func<CancellationToken, Task<T>> factory,
|
||||
Func<T, ReadOnlyMemory<byte>> serializer,
|
||||
Func<ReadOnlyMemory<byte>, T> deserializer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (key is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(key));
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var path = ResolvePath(key);
|
||||
if (TryRead(path, deserializer, out var value))
|
||||
{
|
||||
_logger.LogTrace("Surface cache hit for {Key}.", key);
|
||||
return value!;
|
||||
}
|
||||
|
||||
var gate = _locks.GetOrAdd(path, _ => new SemaphoreSlim(1, 1));
|
||||
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
if (TryRead(path, deserializer, out value))
|
||||
{
|
||||
_logger.LogTrace("Surface cache race recovered for {Key}.", key);
|
||||
return value!;
|
||||
}
|
||||
|
||||
value = await factory(cancellationToken).ConfigureAwait(false);
|
||||
var payload = serializer(value);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
await File.WriteAllBytesAsync(path, payload.ToArray(), cancellationToken).ConfigureAwait(false);
|
||||
return value;
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public Task<T?> TryGetAsync<T>(
|
||||
SurfaceCacheKey key,
|
||||
Func<ReadOnlyMemory<byte>, T> deserializer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (key is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(key));
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var path = ResolvePath(key);
|
||||
return Task.FromResult(TryRead(path, deserializer, out var value) ? value : default);
|
||||
}
|
||||
|
||||
public async Task SetAsync(
|
||||
SurfaceCacheKey key,
|
||||
ReadOnlyMemory<byte> payload,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (key is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(key));
|
||||
}
|
||||
|
||||
var path = ResolvePath(key);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
await File.WriteAllBytesAsync(path, payload.ToArray(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private string ResolvePath(SurfaceCacheKey key)
|
||||
{
|
||||
var hash = ComputeHash(key.ContentKey);
|
||||
var tenant = Sanitize(key.Tenant);
|
||||
var ns = Sanitize(key.Namespace);
|
||||
return Path.Combine(_root, ns, tenant, hash[..2], hash[2..4], $"{hash}.bin");
|
||||
}
|
||||
|
||||
private static string ComputeHash(string input)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var bytes = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string Sanitize(string value)
|
||||
=> string.IsNullOrWhiteSpace(value)
|
||||
? "default"
|
||||
: value.Replace('/', '_').Replace('\\', '_');
|
||||
|
||||
private static bool TryRead<T>(string path, Func<ReadOnlyMemory<byte>, T> deserializer, out T? value)
|
||||
{
|
||||
value = default;
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = File.ReadAllBytes(path);
|
||||
value = deserializer(bytes);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace StellaOps.Scanner.Surface.FS;
|
||||
|
||||
/// <summary>
|
||||
/// Provides content-addressable storage for surface artefacts.
|
||||
/// </summary>
|
||||
public interface ISurfaceCache
|
||||
{
|
||||
Task<T> GetOrCreateAsync<T>(
|
||||
SurfaceCacheKey key,
|
||||
Func<CancellationToken, Task<T>> factory,
|
||||
Func<T, ReadOnlyMemory<byte>> serializer,
|
||||
Func<ReadOnlyMemory<byte>, T> deserializer,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<T?> TryGetAsync<T>(
|
||||
SurfaceCacheKey key,
|
||||
Func<ReadOnlyMemory<byte>, T> deserializer,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task SetAsync(
|
||||
SurfaceCacheKey key,
|
||||
ReadOnlyMemory<byte> payload,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.FS;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
private const string ConfigurationSection = "Surface:Cache";
|
||||
|
||||
public static IServiceCollection AddSurfaceFileCache(
|
||||
this IServiceCollection services,
|
||||
Action<SurfaceCacheOptions>? configure = null)
|
||||
{
|
||||
if (services is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(services));
|
||||
}
|
||||
|
||||
services.AddOptions<SurfaceCacheOptions>()
|
||||
.BindConfiguration(ConfigurationSection);
|
||||
|
||||
if (configure is not null)
|
||||
{
|
||||
services.Configure(configure);
|
||||
}
|
||||
|
||||
services.TryAddSingleton<ISurfaceCache, FileSurfaceCache>();
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IValidateOptions<SurfaceCacheOptions>, SurfaceCacheOptionsValidator>());
|
||||
return services;
|
||||
}
|
||||
|
||||
private sealed class SurfaceCacheOptionsValidator : IValidateOptions<SurfaceCacheOptions>
|
||||
{
|
||||
public ValidateOptionsResult Validate(string? name, SurfaceCacheOptions options)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
return ValidateOptionsResult.Fail("Options cannot be null.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var root = options.ResolveRoot();
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
return ValidateOptionsResult.Fail("Root directory cannot be empty.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ValidateOptionsResult.Fail($"Failed to resolve surface cache root: {ex.Message}");
|
||||
}
|
||||
|
||||
return ValidateOptionsResult.Success;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<EnableDefaultItems>false</EnableDefaultItems>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="**\*.cs" Exclude="obj\**;bin\**" />
|
||||
<EmbeddedResource Include="**\*.json" Exclude="obj\**;bin\**" />
|
||||
<None Include="**\*" Exclude="**\*.cs;**\*.json;bin\**;obj\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0-preview.7.25380.108" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.FS;
|
||||
|
||||
internal static class SurfaceCacheJsonSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
|
||||
static SurfaceCacheJsonSerializer()
|
||||
{
|
||||
Options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
|
||||
}
|
||||
|
||||
public static ReadOnlyMemory<byte> Serialize<T>(T value)
|
||||
{
|
||||
return JsonSerializer.SerializeToUtf8Bytes(value, Options);
|
||||
}
|
||||
|
||||
public static T Deserialize<T>(ReadOnlyMemory<byte> payload)
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(payload.Span, Options)!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.Scanner.Surface.FS;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies a cached artefact within the surface file store.
|
||||
/// </summary>
|
||||
public sealed record SurfaceCacheKey(string Namespace, string Tenant, string ContentKey)
|
||||
{
|
||||
public override string ToString()
|
||||
=> $"{Namespace}/{Tenant}/{ContentKey}";
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace StellaOps.Scanner.Surface.FS;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the on-disk storage used by the surface cache.
|
||||
/// </summary>
|
||||
public sealed class SurfaceCacheOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Root directory where cached payloads are stored. Defaults to a deterministic path under the temporary directory.
|
||||
/// </summary>
|
||||
public string? RootDirectory { get; set; }
|
||||
|
||||
internal string ResolveRoot()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(RootDirectory))
|
||||
{
|
||||
return RootDirectory!;
|
||||
}
|
||||
|
||||
return Path.Combine(Path.GetTempPath(), "stellaops", "surface-cache");
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| SURFACE-FS-01 | TODO | Scanner Guild, Zastava Guild | ARCH-SURFACE-EPIC | Author `docs/modules/scanner/design/surface-fs.md` defining cache layout, pointer schema, tenancy, and offline handling. | Spec merged; reviewers from Scanner/Zastava sign off; component map cross-link drafted. |
|
||||
| SURFACE-FS-02 | TODO | Scanner Guild | SURFACE-FS-01 | Implement `StellaOps.Scanner.Surface.FS` core abstractions (writer, reader, manifest models) with deterministic serialization + unit tests. | Library compiles; tests pass; XML docs cover public types. |
|
||||
| SURFACE-FS-01 | DOING (2025-11-02) | Scanner Guild, Zastava Guild | ARCH-SURFACE-EPIC | Author `docs/modules/scanner/design/surface-fs.md` defining cache layout, pointer schema, tenancy, and offline handling. | Spec merged; reviewers from Scanner/Zastava sign off; component map cross-link drafted. |
|
||||
| SURFACE-FS-02 | DOING (2025-11-02) | Scanner Guild | SURFACE-FS-01 | Implement `StellaOps.Scanner.Surface.FS` core abstractions (writer, reader, manifest models) with deterministic serialization + unit tests. | Library compiles; tests pass; XML docs cover public types. |
|
||||
| SURFACE-FS-03 | TODO | Scanner Guild | SURFACE-FS-02 | Integrate Surface.FS writer into Scanner Worker analyzer pipeline to persist layer + entry-trace fragments. | Worker produces cache entries in integration tests; observability counters emitted. |
|
||||
| SURFACE-FS-04 | TODO | Zastava Guild | SURFACE-FS-02 | Integrate Surface.FS reader into Zastava Observer runtime drift loop. | Observer validates runtime artefacts via cache; regression tests updated. |
|
||||
| SURFACE-FS-05 | TODO | Scanner Guild, Scheduler Guild | SURFACE-FS-03 | Expose Surface.FS pointers via Scanner WebService reports and coordinate rescan planning with Scheduler. | API contracts updated; Scheduler consumes pointers; docs refreshed. |
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Scanner.Surface.Secrets.Tests")]
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Secrets;
|
||||
|
||||
public interface ISurfaceSecretProvider
|
||||
{
|
||||
ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Secrets.Providers;
|
||||
|
||||
internal sealed class CompositeSurfaceSecretProvider : ISurfaceSecretProvider
|
||||
{
|
||||
private readonly IReadOnlyList<ISurfaceSecretProvider> _providers;
|
||||
|
||||
public CompositeSurfaceSecretProvider(IEnumerable<ISurfaceSecretProvider> providers)
|
||||
{
|
||||
_providers = providers?.ToArray() ?? throw new ArgumentNullException(nameof(providers));
|
||||
if (_providers.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one provider must be supplied.", nameof(providers));
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var provider in _providers)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await provider.GetAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (SurfaceSecretNotFoundException)
|
||||
{
|
||||
// try next provider
|
||||
}
|
||||
}
|
||||
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Secrets.Providers;
|
||||
|
||||
internal sealed class FileSurfaceSecretProvider : ISurfaceSecretProvider
|
||||
{
|
||||
private readonly string _root;
|
||||
|
||||
public FileSurfaceSecretProvider(string root)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
throw new ArgumentException("File secret provider root cannot be null or whitespace.", nameof(root));
|
||||
}
|
||||
|
||||
_root = root;
|
||||
}
|
||||
|
||||
public async ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
var path = ResolvePath(request);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
await using var stream = File.OpenRead(path);
|
||||
var descriptor = await JsonSerializer.DeserializeAsync<FileSecretDescriptor>(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (descriptor is null)
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(descriptor.Payload))
|
||||
{
|
||||
return SurfaceSecretHandle.Empty;
|
||||
}
|
||||
|
||||
var bytes = Convert.FromBase64String(descriptor.Payload);
|
||||
return SurfaceSecretHandle.FromBytes(bytes, descriptor.Metadata);
|
||||
}
|
||||
|
||||
private string ResolvePath(SurfaceSecretRequest request)
|
||||
{
|
||||
var name = request.Name ?? "default";
|
||||
return Path.Combine(_root, request.Tenant, request.Component, request.SecretType, name + ".json");
|
||||
}
|
||||
|
||||
private sealed class FileSecretDescriptor
|
||||
{
|
||||
public string? Payload { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Secrets.Providers;
|
||||
|
||||
public sealed class InMemorySurfaceSecretProvider : ISurfaceSecretProvider
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, SurfaceSecretHandle> _secrets = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public void Add(SurfaceSecretRequest request, SurfaceSecretHandle handle)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
if (handle is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(handle));
|
||||
}
|
||||
|
||||
_secrets[request.CacheKey] = handle;
|
||||
}
|
||||
|
||||
public ValueTask<SurfaceSecretHandle> GetAsync(SurfaceSecretRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
if (_secrets.TryGetValue(request.CacheKey, out var handle))
|
||||
{
|
||||
return ValueTask.FromResult(handle);
|
||||
}
|
||||
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Secrets.Providers;
|
||||
|
||||
internal sealed class InlineSurfaceSecretProvider : ISurfaceSecretProvider
|
||||
{
|
||||
private readonly SurfaceSecretsConfiguration _configuration;
|
||||
|
||||
public InlineSurfaceSecretProvider(SurfaceSecretsConfiguration configuration)
|
||||
{
|
||||
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
|
||||
}
|
||||
|
||||
public ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_configuration.AllowInline)
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
var envKey = BuildEnvironmentKey(request);
|
||||
var value = Environment.GetEnvironmentVariable(envKey);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
var bytes = Convert.FromBase64String(value);
|
||||
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["source"] = "inline-env",
|
||||
["key"] = envKey
|
||||
};
|
||||
|
||||
return ValueTask.FromResult(SurfaceSecretHandle.FromBytes(bytes, metadata));
|
||||
}
|
||||
|
||||
private static string BuildEnvironmentKey(SurfaceSecretRequest request)
|
||||
{
|
||||
var name = string.IsNullOrWhiteSpace(request.Name) ? "DEFAULT" : request.Name.ToUpperInvariant();
|
||||
return $"SURFACE_SECRET_{request.Tenant.ToUpperInvariant()}_{request.Component.ToUpperInvariant()}_{request.SecretType.ToUpperInvariant()}_{name}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Secrets.Providers;
|
||||
|
||||
internal sealed class KubernetesSurfaceSecretProvider : ISurfaceSecretProvider
|
||||
{
|
||||
private readonly SurfaceSecretsConfiguration _configuration;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public KubernetesSurfaceSecretProvider(SurfaceSecretsConfiguration configuration, ILogger logger)
|
||||
{
|
||||
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(configuration.Root))
|
||||
{
|
||||
throw new ArgumentException("Kubernetes secret provider requires a root directory where secrets are mounted.", nameof(configuration));
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var directory = Path.Combine(_configuration.Root!, request.Tenant, request.Component, request.SecretType);
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
_logger.LogDebug("Kubernetes secret directory {Directory} not found.", directory);
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
var name = request.Name ?? "default";
|
||||
var payloadPath = Path.Combine(directory, name);
|
||||
if (!File.Exists(payloadPath))
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
var bytes = await File.ReadAllBytesAsync(payloadPath, cancellationToken).ConfigureAwait(false);
|
||||
return SurfaceSecretHandle.FromBytes(bytes, new Dictionary<string, string>
|
||||
{
|
||||
["source"] = "kubernetes",
|
||||
["path"] = payloadPath
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.Surface.Secrets.Providers;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Secrets;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddSurfaceSecrets(
|
||||
this IServiceCollection services,
|
||||
Action<SurfaceSecretsOptions>? configure = null)
|
||||
{
|
||||
if (services is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(services));
|
||||
}
|
||||
|
||||
services.AddOptions<SurfaceSecretsOptions>();
|
||||
if (configure is not null)
|
||||
{
|
||||
services.Configure(configure);
|
||||
}
|
||||
|
||||
services.TryAddSingleton<ISurfaceSecretProvider>(sp =>
|
||||
{
|
||||
var env = sp.GetRequiredService<ISurfaceEnvironment>();
|
||||
var options = sp.GetRequiredService<IOptions<SurfaceSecretsOptions>>().Value;
|
||||
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger("SurfaceSecrets");
|
||||
return CreateProvider(env.Settings.Secrets, logger);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static ISurfaceSecretProvider CreateProvider(SurfaceSecretsConfiguration configuration, ILogger logger)
|
||||
{
|
||||
var providers = new List<ISurfaceSecretProvider>();
|
||||
|
||||
switch (configuration.Provider.ToLowerInvariant())
|
||||
{
|
||||
case "kubernetes":
|
||||
providers.Add(new KubernetesSurfaceSecretProvider(configuration, logger));
|
||||
break;
|
||||
case "file":
|
||||
providers.Add(new FileSurfaceSecretProvider(configuration.Root ?? throw new ArgumentException("Secrets root is required for file provider.")));
|
||||
break;
|
||||
case "inline":
|
||||
providers.Add(new InlineSurfaceSecretProvider(configuration));
|
||||
break;
|
||||
default:
|
||||
logger.LogWarning("Unknown surface secret provider '{Provider}'. Falling back to inline provider.", configuration.Provider);
|
||||
providers.Add(new InlineSurfaceSecretProvider(configuration));
|
||||
break;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(configuration.FallbackProvider))
|
||||
{
|
||||
providers.Add(new InlineSurfaceSecretProvider(configuration with { Provider = configuration.FallbackProvider }));
|
||||
}
|
||||
|
||||
return providers.Count == 1 ? providers[0] : new CompositeSurfaceSecretProvider(providers);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<EnableDefaultItems>false</EnableDefaultItems>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="**\*.cs" Exclude="obj\**;bin\**" />
|
||||
<EmbeddedResource Include="**\*.json" Exclude="obj\**;bin\**" />
|
||||
<None Include="**\*" Exclude="**\*.cs;**\*.json;bin\**;obj\**" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,89 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Secrets;
|
||||
|
||||
public sealed class SurfaceSecretHandle : IDisposable
|
||||
{
|
||||
private readonly byte[]? _buffer;
|
||||
private readonly int _length;
|
||||
private readonly X509Certificate2Collection? _certificates;
|
||||
private bool _disposed;
|
||||
|
||||
private SurfaceSecretHandle(byte[]? buffer, int length, X509Certificate2Collection? certificates, IReadOnlyDictionary<string, string> metadata)
|
||||
{
|
||||
_buffer = buffer;
|
||||
_length = length;
|
||||
_certificates = certificates;
|
||||
Metadata = metadata;
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; }
|
||||
|
||||
public ReadOnlyMemory<byte> AsBytes()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
return _buffer is null ? ReadOnlyMemory<byte>.Empty : new ReadOnlyMemory<byte>(_buffer, 0, _length);
|
||||
}
|
||||
|
||||
public X509Certificate2Collection? AsCertificateCollection()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
return _certificates;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_buffer is not null)
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(_buffer.AsSpan(0, _length));
|
||||
ArrayPool<byte>.Shared.Return(_buffer);
|
||||
}
|
||||
|
||||
if (_certificates is not null)
|
||||
{
|
||||
foreach (var certificate in _certificates)
|
||||
{
|
||||
certificate.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(SurfaceSecretHandle));
|
||||
}
|
||||
}
|
||||
|
||||
public static SurfaceSecretHandle FromBytes(ReadOnlySpan<byte> bytes, IDictionary<string, string>? metadata = null)
|
||||
{
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(bytes.Length);
|
||||
bytes.CopyTo(buffer);
|
||||
var readOnlyMetadata = metadata is null
|
||||
? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
: new Dictionary<string, string>(metadata, StringComparer.OrdinalIgnoreCase);
|
||||
return new SurfaceSecretHandle(buffer, bytes.Length, null, readOnlyMetadata);
|
||||
}
|
||||
|
||||
public static SurfaceSecretHandle Empty { get; } = new SurfaceSecretHandle(null, 0, null, new Dictionary<string, string>());
|
||||
|
||||
public static SurfaceSecretHandle FromCertificates(X509Certificate2Collection certificates, IDictionary<string, string>? metadata = null)
|
||||
{
|
||||
var readOnlyMetadata = metadata is null
|
||||
? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
: new Dictionary<string, string>(metadata, StringComparer.OrdinalIgnoreCase);
|
||||
return new SurfaceSecretHandle(null, 0, certificates, readOnlyMetadata);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.Scanner.Surface.Secrets;
|
||||
|
||||
public sealed class SurfaceSecretNotFoundException : Exception
|
||||
{
|
||||
public SurfaceSecretNotFoundException(SurfaceSecretRequest request)
|
||||
: base($"Surface secret not found for tenant '{request.Tenant}', component '{request.Component}', type '{request.SecretType}'.")
|
||||
{
|
||||
Request = request;
|
||||
}
|
||||
|
||||
public SurfaceSecretRequest Request { get; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.Scanner.Surface.Secrets;
|
||||
|
||||
public sealed record SurfaceSecretRequest(
|
||||
string Tenant,
|
||||
string Component,
|
||||
string SecretType,
|
||||
string? Name = null)
|
||||
{
|
||||
public string CacheKey => string.Join(':', Tenant, Component, SecretType, Name ?? "default");
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.Scanner.Surface.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the surface secrets subsystem.
|
||||
/// </summary>
|
||||
public sealed class SurfaceSecretsOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the logical component name requesting secrets.
|
||||
/// </summary>
|
||||
public string ComponentName { get; set; } = "Scanner.Worker";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the set of secret types that should be eagerly validated at startup.
|
||||
/// </summary>
|
||||
public ISet<string> RequiredSecretTypes { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| SURFACE-SECRETS-01 | TODO | Scanner Guild, Security Guild | ARCH-SURFACE-EPIC | Produce `docs/modules/scanner/design/surface-secrets.md` defining secret reference schema, storage backends, scopes, and rotation. | Spec approved by Security + Authority guilds; threat model ticket logged. |
|
||||
| SURFACE-SECRETS-02 | TODO | Scanner Guild | SURFACE-SECRETS-01 | Implement `StellaOps.Scanner.Surface.Secrets` core provider interfaces, secret models, and in-memory test backend. | Library builds; tests pass; XML docs cover public API. |
|
||||
| SURFACE-SECRETS-01 | DOING (2025-11-02) | Scanner Guild, Security Guild | ARCH-SURFACE-EPIC | Produce `docs/modules/scanner/design/surface-secrets.md` defining secret reference schema, storage backends, scopes, and rotation. | Spec approved by Security + Authority guilds; threat model ticket logged. |
|
||||
| SURFACE-SECRETS-02 | DOING (2025-11-02) | Scanner Guild | SURFACE-SECRETS-01 | Implement `StellaOps.Scanner.Surface.Secrets` core provider interfaces, secret models, and in-memory test backend. | Library builds; tests pass; XML docs cover public API. |
|
||||
| SURFACE-SECRETS-03 | TODO | Scanner Guild | SURFACE-SECRETS-02 | Add Kubernetes/File/Offline backends with deterministic caching and audit hooks. | Backends integrated; integration tests simulate rotation + offline bundles. |
|
||||
| SURFACE-SECRETS-04 | TODO | Scanner Guild | SURFACE-SECRETS-02 | Integrate Surface.Secrets into Scanner Worker/WebService/BuildX for registry + CAS creds. | Scanner components consume library; legacy secret code removed; smoke tests updated. |
|
||||
| SURFACE-SECRETS-05 | TODO | Zastava Guild | SURFACE-SECRETS-02 | Invoke Surface.Secrets from Zastava Observer/Webhook for CAS & attestation secrets. | Zastava uses shared provider; admission + observer tests cover secret errors. |
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace StellaOps.Scanner.Surface.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Reports validation outcomes for observability purposes.
|
||||
/// </summary>
|
||||
public interface ISurfaceValidationReporter
|
||||
{
|
||||
void Report(SurfaceValidationContext context, SurfaceValidationResult result);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Contract implemented by components that validate surface prerequisites.
|
||||
/// </summary>
|
||||
public interface ISurfaceValidator
|
||||
{
|
||||
ValueTask<SurfaceValidationResult> ValidateAsync(
|
||||
SurfaceValidationContext context,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace StellaOps.Scanner.Surface.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Executes registered surface validators and aggregates their results.
|
||||
/// </summary>
|
||||
public interface ISurfaceValidatorRunner
|
||||
{
|
||||
ValueTask<SurfaceValidationResult> RunAllAsync(
|
||||
SurfaceValidationContext context,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
ValueTask EnsureAsync(
|
||||
SurfaceValidationContext context,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Validation;
|
||||
|
||||
internal sealed class LoggingSurfaceValidationReporter : ISurfaceValidationReporter
|
||||
{
|
||||
private readonly ILogger<LoggingSurfaceValidationReporter> _logger;
|
||||
|
||||
public LoggingSurfaceValidationReporter(ILogger<LoggingSurfaceValidationReporter> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public void Report(SurfaceValidationContext context, SurfaceValidationResult result)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(result));
|
||||
}
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_logger.LogInformation("Surface validation succeeded for component {Component}.", context.ComponentName);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var issue in result.Issues)
|
||||
{
|
||||
var logLevel = issue.Severity switch
|
||||
{
|
||||
SurfaceValidationSeverity.Info => LogLevel.Information,
|
||||
SurfaceValidationSeverity.Warning => LogLevel.Warning,
|
||||
_ => LogLevel.Error
|
||||
};
|
||||
|
||||
_logger.Log(logLevel,
|
||||
"Surface validation issue for component {Component}: {Code} - {Message}. Hint: {Hint}",
|
||||
context.ComponentName,
|
||||
issue.Code,
|
||||
issue.Message,
|
||||
issue.Hint);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Surface.Validation.Validators;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Validation;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddSurfaceValidation(
|
||||
this IServiceCollection services,
|
||||
Action<SurfaceValidationBuilder>? configure = null)
|
||||
{
|
||||
if (services is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(services));
|
||||
}
|
||||
|
||||
services.TryAddSingleton<ISurfaceValidationReporter, LoggingSurfaceValidationReporter>();
|
||||
services.TryAddSingleton<ISurfaceValidatorRunner, SurfaceValidatorRunner>();
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<ISurfaceValidator, SurfaceEndpointValidator>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<ISurfaceValidator, SurfaceCacheValidator>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<ISurfaceValidator, SurfaceSecretsValidator>());
|
||||
services.TryAddSingleton<IConfigureOptions<SurfaceValidationOptions>, SurfaceValidationOptionsConfigurator>();
|
||||
|
||||
if (configure is not null)
|
||||
{
|
||||
var builder = new SurfaceValidationBuilder(services);
|
||||
configure(builder);
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private sealed class SurfaceValidationOptionsConfigurator : IConfigureOptions<SurfaceValidationOptions>
|
||||
{
|
||||
public void Configure(SurfaceValidationOptions options)
|
||||
{
|
||||
options ??= new SurfaceValidationOptions();
|
||||
options.ThrowOnFailure = true;
|
||||
options.ContinueOnError = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<EnableDefaultItems>false</EnableDefaultItems>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="**\*.cs" Exclude="obj\**;bin\**" />
|
||||
<EmbeddedResource Include="**\*.json" Exclude="obj\**;bin\**" />
|
||||
<None Include="**\*" Exclude="**\*.cs;**\*.json;bin\**;obj\**" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Validation;
|
||||
|
||||
public sealed class SurfaceValidationBuilder
|
||||
{
|
||||
private readonly IServiceCollection _services;
|
||||
|
||||
internal SurfaceValidationBuilder(IServiceCollection services)
|
||||
{
|
||||
_services = services;
|
||||
}
|
||||
|
||||
public SurfaceValidationBuilder AddValidator<TValidator>()
|
||||
where TValidator : class, ISurfaceValidator
|
||||
{
|
||||
_services.AddSingleton<ISurfaceValidator, TValidator>();
|
||||
return this;
|
||||
}
|
||||
|
||||
public SurfaceValidationBuilder AddValidator(Func<IServiceProvider, ISurfaceValidator> factory)
|
||||
{
|
||||
if (factory is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(factory));
|
||||
}
|
||||
|
||||
_services.AddSingleton(provider => factory(provider));
|
||||
return this;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Context supplied to validation checks to describe the surface configuration.
|
||||
/// </summary>
|
||||
public sealed record SurfaceValidationContext(
|
||||
IServiceProvider Services,
|
||||
string ComponentName,
|
||||
SurfaceEnvironmentSettings Environment,
|
||||
IReadOnlyDictionary<string, object?> Properties)
|
||||
{
|
||||
public static SurfaceValidationContext Create(
|
||||
IServiceProvider services,
|
||||
string componentName,
|
||||
SurfaceEnvironmentSettings environment,
|
||||
IReadOnlyDictionary<string, object?>? properties = null)
|
||||
{
|
||||
if (services is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(services));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(componentName))
|
||||
{
|
||||
throw new ArgumentException("Component name cannot be null or whitespace.", nameof(componentName));
|
||||
}
|
||||
|
||||
if (environment is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(environment));
|
||||
}
|
||||
|
||||
return new SurfaceValidationContext(
|
||||
services,
|
||||
componentName,
|
||||
environment,
|
||||
properties ?? new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Validation;
|
||||
|
||||
public sealed class SurfaceValidationException : Exception
|
||||
{
|
||||
public SurfaceValidationException(string message, IEnumerable<SurfaceValidationIssue> issues)
|
||||
: base(message)
|
||||
{
|
||||
Issues = issues.ToImmutableArray();
|
||||
}
|
||||
|
||||
public ImmutableArray<SurfaceValidationIssue> Issues { get; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single validation finding produced by a surface validator.
|
||||
/// </summary>
|
||||
public sealed record SurfaceValidationIssue(
|
||||
string Code,
|
||||
string Message,
|
||||
SurfaceValidationSeverity Severity,
|
||||
string? Hint = null)
|
||||
{
|
||||
public static SurfaceValidationIssue Info(string code, string message, string? hint = null)
|
||||
=> new(code, message, SurfaceValidationSeverity.Info, hint);
|
||||
|
||||
public static SurfaceValidationIssue Warning(string code, string message, string? hint = null)
|
||||
=> new(code, message, SurfaceValidationSeverity.Warning, hint);
|
||||
|
||||
public static SurfaceValidationIssue Error(string code, string message, string? hint = null)
|
||||
=> new(code, message, SurfaceValidationSeverity.Error, hint);
|
||||
|
||||
[MemberNotNullWhen(true, nameof(Hint))]
|
||||
public bool HasHint => !string.IsNullOrWhiteSpace(Hint);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace StellaOps.Scanner.Surface.Validation;
|
||||
|
||||
public static class SurfaceValidationIssueCodes
|
||||
{
|
||||
public const string SurfaceEndpointMissing = "SURFACE_ENV_MISSING_ENDPOINT";
|
||||
public const string SurfaceEndpointInvalid = "SURFACE_ENV_ENDPOINT_INVALID";
|
||||
public const string CacheDirectoryUnwritable = "SURFACE_ENV_CACHE_DIR_UNWRITABLE";
|
||||
public const string CacheQuotaInvalid = "SURFACE_ENV_CACHE_QUOTA_INVALID";
|
||||
public const string SecretsProviderUnknown = "SURFACE_SECRET_PROVIDER_UNKNOWN";
|
||||
public const string SecretsConfigurationMissing = "SURFACE_SECRET_CONFIGURATION_MISSING";
|
||||
public const string TenantMissing = "SURFACE_ENV_TENANT_MISSING";
|
||||
public const string BucketMissing = "SURFACE_FS_BUCKET_MISSING";
|
||||
public const string FeatureUnknown = "SURFACE_FEATURE_UNKNOWN";
|
||||
public const string ValidatorException = "SURFACE_VALIDATOR_EXCEPTION";
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace StellaOps.Scanner.Surface.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Controls behaviour of the surface validation runner.
|
||||
/// </summary>
|
||||
public sealed class SurfaceValidationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the runner should continue invoking validators after an error is recorded.
|
||||
/// </summary>
|
||||
public bool ContinueOnError { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the runner should throw a <see cref="SurfaceValidationException"/> when validation fails.
|
||||
/// Defaults to <c>true</c> to align with fail-fast expectations.
|
||||
/// </summary>
|
||||
public bool ThrowOnFailure { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate outcome emitted after running all registered validators.
|
||||
/// </summary>
|
||||
public sealed record SurfaceValidationResult
|
||||
{
|
||||
private SurfaceValidationResult(bool isSuccess, ImmutableArray<SurfaceValidationIssue> issues)
|
||||
{
|
||||
IsSuccess = isSuccess;
|
||||
Issues = issues;
|
||||
}
|
||||
|
||||
public bool IsSuccess { get; }
|
||||
|
||||
public ImmutableArray<SurfaceValidationIssue> Issues { get; }
|
||||
|
||||
public static SurfaceValidationResult Success()
|
||||
=> new(true, ImmutableArray<SurfaceValidationIssue>.Empty);
|
||||
|
||||
public static SurfaceValidationResult FromIssues(IEnumerable<SurfaceValidationIssue> issues)
|
||||
{
|
||||
var immutable = issues.ToImmutableArray();
|
||||
var success = immutable.All(issue => issue.Severity != SurfaceValidationSeverity.Error);
|
||||
return new SurfaceValidationResult(success, immutable);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace StellaOps.Scanner.Surface.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Severity classification for surface validation issues.
|
||||
/// </summary>
|
||||
public enum SurfaceValidationSeverity
|
||||
{
|
||||
Info = 0,
|
||||
Warning = 1,
|
||||
Error = 2,
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Validation;
|
||||
|
||||
internal sealed class SurfaceValidatorRunner : ISurfaceValidatorRunner
|
||||
{
|
||||
private readonly IReadOnlyList<ISurfaceValidator> _validators;
|
||||
private readonly ILogger<SurfaceValidatorRunner> _logger;
|
||||
private readonly ISurfaceValidationReporter _reporter;
|
||||
private readonly SurfaceValidationOptions _options;
|
||||
|
||||
public SurfaceValidatorRunner(
|
||||
IEnumerable<ISurfaceValidator> validators,
|
||||
ILogger<SurfaceValidatorRunner> logger,
|
||||
ISurfaceValidationReporter reporter,
|
||||
IOptions<SurfaceValidationOptions> options)
|
||||
{
|
||||
_validators = validators?.ToArray() ?? Array.Empty<ISurfaceValidator>();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_reporter = reporter ?? throw new ArgumentNullException(nameof(reporter));
|
||||
_options = options?.Value ?? new SurfaceValidationOptions();
|
||||
}
|
||||
|
||||
public async ValueTask<SurfaceValidationResult> RunAllAsync(
|
||||
SurfaceValidationContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
if (_validators.Count == 0)
|
||||
{
|
||||
var success = SurfaceValidationResult.Success();
|
||||
_reporter.Report(context, success);
|
||||
return success;
|
||||
}
|
||||
|
||||
var issues = new List<SurfaceValidationIssue>();
|
||||
foreach (var validator in _validators)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var result = await validator.ValidateAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
issues.AddRange(result.Issues);
|
||||
|
||||
if (!_options.ContinueOnError && result.Issues.Any(issue => issue.Severity == SurfaceValidationSeverity.Error))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Surface validator {Validator} threw an exception.", validator.GetType().FullName);
|
||||
issues.Add(SurfaceValidationIssue.Error(
|
||||
SurfaceValidationIssueCodes.ValidatorException,
|
||||
$"Validator '{validator.GetType().FullName}' threw an exception: {ex.Message}",
|
||||
"Inspect logs for stack trace."));
|
||||
|
||||
if (!_options.ContinueOnError)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var resultAggregate = issues.Count == 0
|
||||
? SurfaceValidationResult.Success()
|
||||
: SurfaceValidationResult.FromIssues(issues);
|
||||
|
||||
_reporter.Report(context, resultAggregate);
|
||||
return resultAggregate;
|
||||
}
|
||||
|
||||
public async ValueTask EnsureAsync(
|
||||
SurfaceValidationContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await RunAllAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
if (!result.IsSuccess && _options.ThrowOnFailure)
|
||||
{
|
||||
throw new SurfaceValidationException(
|
||||
$"Surface validation failed for component '{context.ComponentName}'.",
|
||||
result.Issues);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| SURFACE-VAL-01 | TODO | Scanner Guild, Security Guild | SURFACE-FS-01, SURFACE-ENV-01 | Define validation framework (design doc `surface-validation.md`) covering SOLID extension points and queryable checks for env/cache/secrets. | Spec merged; architecture sign-off from Scanner + Security; checklist of baseline validators established. |
|
||||
| SURFACE-VAL-01 | DOING (2025-11-01) | Scanner Guild, Security Guild | SURFACE-FS-01, SURFACE-ENV-01 | Define validation framework (design doc `surface-validation.md`) covering SOLID extension points and queryable checks for env/cache/secrets. | Spec merged; architecture sign-off from Scanner + Security; checklist of baseline validators established. |
|
||||
| SURFACE-VAL-02 | TODO | Scanner Guild | SURFACE-VAL-01, SURFACE-ENV-02, SURFACE-FS-02 | Implement base validation library (interfaces, check registry, default validators for env/cached manifests, secret refs) with unit tests. | Library published; validation registry supports DI; tests cover success/failure; XML docs added. |
|
||||
| SURFACE-VAL-03 | TODO | Scanner Guild, Analyzer Guild | SURFACE-VAL-02 | Integrate validation pipeline into Scanner analyzers (Lang, EntryTrace, etc.) to ensure consistent checks before processing. | Analyzers call validation hooks; integration tests updated; performance baseline measured. |
|
||||
| SURFACE-VAL-04 | TODO | Scanner Guild, Zastava Guild | SURFACE-VAL-02 | Expose validation helpers to Zastava and other runtime consumers (Observer/Webhook) for preflight checks. | Zastava uses shared validators; admission tests include validation failure scenarios. |
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scanner.Surface.Validation;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Validation.Validators;
|
||||
|
||||
internal sealed class SurfaceCacheValidator : ISurfaceValidator
|
||||
{
|
||||
public ValueTask<SurfaceValidationResult> ValidateAsync(
|
||||
SurfaceValidationContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
var issues = new List<SurfaceValidationIssue>();
|
||||
var directory = context.Environment.CacheRoot;
|
||||
try
|
||||
{
|
||||
if (!directory.Exists)
|
||||
{
|
||||
directory.Create();
|
||||
}
|
||||
|
||||
var testFile = Path.Combine(directory.FullName, ".validation");
|
||||
using (File.Open(testFile, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None))
|
||||
{
|
||||
}
|
||||
|
||||
File.Delete(testFile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
issues.Add(SurfaceValidationIssue.Error(
|
||||
SurfaceValidationIssueCodes.CacheDirectoryUnwritable,
|
||||
$"Surface cache directory '{directory.FullName}' is not writable: {ex.Message}",
|
||||
"Ensure the cache directory exists and is writable by the process user."));
|
||||
}
|
||||
|
||||
if (context.Environment.CacheQuotaMegabytes <= 0)
|
||||
{
|
||||
issues.Add(SurfaceValidationIssue.Error(
|
||||
SurfaceValidationIssueCodes.CacheQuotaInvalid,
|
||||
"Surface cache quota must be greater than zero.",
|
||||
"Set SCANNER_SURFACE_CACHE_QUOTA_MB to a positive value."));
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(issues.Count == 0
|
||||
? SurfaceValidationResult.Success()
|
||||
: SurfaceValidationResult.FromIssues(issues));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Validation.Validators;
|
||||
|
||||
internal sealed class SurfaceEndpointValidator : ISurfaceValidator
|
||||
{
|
||||
public ValueTask<SurfaceValidationResult> ValidateAsync(SurfaceValidationContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
var issues = new List<SurfaceValidationIssue>();
|
||||
if (context.Environment.SurfaceFsEndpoint is null || string.Equals(context.Environment.SurfaceFsEndpoint.Host, "surface.invalid", StringComparison.Ordinal))
|
||||
{
|
||||
issues.Add(SurfaceValidationIssue.Error(
|
||||
SurfaceValidationIssueCodes.SurfaceEndpointMissing,
|
||||
"Surface FS endpoint is missing or invalid.",
|
||||
"Set SCANNER_SURFACE_FS_ENDPOINT to the RustFS/S3 endpoint."));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(context.Environment.SurfaceFsBucket))
|
||||
{
|
||||
issues.Add(SurfaceValidationIssue.Error(
|
||||
SurfaceValidationIssueCodes.BucketMissing,
|
||||
"Surface FS bucket must be provided.",
|
||||
"Set SCANNER_SURFACE_FS_BUCKET"));
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(issues.Count == 0
|
||||
? SurfaceValidationResult.Success()
|
||||
: SurfaceValidationResult.FromIssues(issues));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.Surface.Validation;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Validation.Validators;
|
||||
|
||||
internal sealed class SurfaceSecretsValidator : ISurfaceValidator
|
||||
{
|
||||
private static readonly HashSet<string> KnownProviders = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"kubernetes",
|
||||
"file",
|
||||
"inline"
|
||||
};
|
||||
|
||||
public ValueTask<SurfaceValidationResult> ValidateAsync(
|
||||
SurfaceValidationContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
var issues = new List<SurfaceValidationIssue>();
|
||||
var secrets = context.Environment.Secrets;
|
||||
|
||||
if (!KnownProviders.Contains(secrets.Provider))
|
||||
{
|
||||
issues.Add(SurfaceValidationIssue.Error(
|
||||
SurfaceValidationIssueCodes.SecretsProviderUnknown,
|
||||
$"Surface secrets provider '{secrets.Provider}' is not recognised.",
|
||||
"Set SCANNER_SURFACE_SECRETS_PROVIDER to 'kubernetes', 'file', or another supported provider."));
|
||||
}
|
||||
|
||||
if (string.Equals(secrets.Provider, "kubernetes", StringComparison.OrdinalIgnoreCase) &&
|
||||
string.IsNullOrWhiteSpace(secrets.Namespace))
|
||||
{
|
||||
issues.Add(SurfaceValidationIssue.Error(
|
||||
SurfaceValidationIssueCodes.SecretsConfigurationMissing,
|
||||
"Kubernetes secrets provider requires a namespace.",
|
||||
"Set SCANNER_SURFACE_SECRETS_NAMESPACE to the target namespace."));
|
||||
}
|
||||
|
||||
if (string.Equals(secrets.Provider, "file", StringComparison.OrdinalIgnoreCase) &&
|
||||
string.IsNullOrWhiteSpace(secrets.Root))
|
||||
{
|
||||
issues.Add(SurfaceValidationIssue.Error(
|
||||
SurfaceValidationIssueCodes.SecretsConfigurationMissing,
|
||||
"File secrets provider requires a root directory.",
|
||||
"Set SCANNER_SURFACE_SECRETS_ROOT to a directory path."));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(secrets.Tenant))
|
||||
{
|
||||
issues.Add(SurfaceValidationIssue.Error(
|
||||
SurfaceValidationIssueCodes.TenantMissing,
|
||||
"Surface secrets tenant cannot be empty.",
|
||||
"Set SCANNER_SURFACE_SECRETS_TENANT or ensure the tenant resolver provides a value."));
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(issues.Count == 0
|
||||
? SurfaceValidationResult.Success()
|
||||
: SurfaceValidationResult.FromIssues(issues));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user