Add Authority Advisory AI and API Lifecycle Configuration

- Introduced AuthorityAdvisoryAiOptions and related classes for managing advisory AI configurations, including remote inference options and tenant-specific settings.
- Added AuthorityApiLifecycleOptions to control API lifecycle settings, including legacy OAuth endpoint configurations.
- Implemented validation and normalization methods for both advisory AI and API lifecycle options to ensure proper configuration.
- Created AuthorityNotificationsOptions and its related classes for managing notification settings, including ack tokens, webhooks, and escalation options.
- Developed IssuerDirectoryClient and related models for interacting with the issuer directory service, including caching mechanisms and HTTP client configurations.
- Added support for dependency injection through ServiceCollectionExtensions for the Issuer Directory Client.
- Updated project file to include necessary package references for the new Issuer Directory Client library.
This commit is contained in:
master
2025-11-02 13:40:38 +02:00
parent 66cb6c4b8a
commit f98cea3bcf
516 changed files with 68157 additions and 24754 deletions

View File

@@ -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;
}

View File

@@ -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. |

View File

@@ -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";
}

View File

@@ -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";
}

View File

@@ -1,11 +1,14 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.EntryTrace.Diagnostics;
using StellaOps.Scanner.EntryTrace.Parsing;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.EntryTrace.Diagnostics;
using StellaOps.Scanner.EntryTrace.FileSystem;
using StellaOps.Scanner.EntryTrace.Parsing;
namespace StellaOps.Scanner.EntryTrace;
@@ -73,13 +76,17 @@ public sealed class EntryTraceAnalyzer : IEntryTraceAnalyzer
{
private readonly EntrypointSpecification _entrypoint;
private readonly EntryTraceContext _context;
private readonly EntryTraceAnalyzerOptions _options;
private readonly EntryTraceMetrics _metrics;
private readonly ILogger _logger;
private readonly ImmutableArray<string> _pathEntries;
private readonly List<EntryTraceNode> _nodes = new();
private readonly List<EntryTraceEdge> _edges = new();
private readonly List<EntryTraceDiagnostic> _diagnostics = new();
private readonly EntryTraceAnalyzerOptions _options;
private readonly EntryTraceMetrics _metrics;
private readonly ILogger _logger;
private readonly ImmutableArray<string> _pathEntries;
private readonly ImmutableArray<EntryTraceCandidate> _candidates;
private readonly List<EntryTraceNode> _nodes = new();
private readonly List<EntryTraceEdge> _edges = new();
private readonly List<EntryTraceDiagnostic> _diagnostics = new();
private readonly List<EntryTracePlan> _plans = new();
private readonly List<EntryTraceTerminal> _terminals = new();
private readonly HashSet<string> _terminalKeys = new(StringComparer.Ordinal);
private readonly HashSet<string> _visitedScripts = new(StringComparer.Ordinal);
private readonly HashSet<string> _visitedCommands = new(StringComparer.Ordinal);
private int _nextNodeId = 1;
@@ -89,15 +96,16 @@ public sealed class EntryTraceAnalyzer : IEntryTraceAnalyzer
EntryTraceContext context,
EntryTraceAnalyzerOptions options,
EntryTraceMetrics metrics,
ILogger logger)
{
_entrypoint = entrypoint;
_context = context;
_options = options;
_metrics = metrics;
_logger = logger;
_pathEntries = DeterminePath(context);
}
ILogger logger)
{
_entrypoint = entrypoint;
_context = context;
_options = options;
_metrics = metrics;
_logger = logger;
_pathEntries = DeterminePath(context);
_candidates = context.Candidates;
}
private static ImmutableArray<string> DeterminePath(EntryTraceContext context)
{
@@ -114,46 +122,65 @@ public sealed class EntryTraceAnalyzer : IEntryTraceAnalyzer
return ImmutableArray<string>.Empty;
}
public EntryTraceGraph BuildGraph()
{
var initialArgs = ComposeInitialCommand(_entrypoint);
if (initialArgs.Length == 0)
{
_diagnostics.Add(new EntryTraceDiagnostic(
EntryTraceDiagnosticSeverity.Error,
EntryTraceUnknownReason.CommandNotFound,
"ENTRYPOINT/CMD yielded no executable command.",
Span: null,
RelatedPath: null));
return ToGraph(EntryTraceOutcome.Unresolved);
}
public EntryTraceGraph BuildGraph()
{
var initialArgs = ComposeInitialCommand(_entrypoint);
if (initialArgs.Length == 0)
{
if (_candidates.Length == 0)
{
_diagnostics.Add(new EntryTraceDiagnostic(
EntryTraceDiagnosticSeverity.Warning,
EntryTraceUnknownReason.CommandNotFound,
"No ENTRYPOINT/CMD declared and no fallback candidates were discovered.",
Span: null,
RelatedPath: null));
return ToGraph(DetermineOutcome());
}
foreach (var candidate in _candidates)
{
_diagnostics.Add(new EntryTraceDiagnostic(
EntryTraceDiagnosticSeverity.Info,
MapCandidateReason(candidate.Source),
CreateCandidateMessage(candidate),
Span: null,
RelatedPath: candidate.Evidence?.Path));
ResolveCommand(candidate.Command, parent: null, originSpan: null, depth: 0, relationship: candidate.Source);
}
return ToGraph(DetermineOutcome());
}
ResolveCommand(initialArgs, parent: null, originSpan: null, depth: 0, relationship: "entrypoint");
var outcome = DetermineOutcome();
return ToGraph(outcome);
}
private EntryTraceOutcome DetermineOutcome()
{
var hasErrors = _diagnostics.Any(d => d.Severity == EntryTraceDiagnosticSeverity.Error);
if (hasErrors)
{
return EntryTraceOutcome.Unresolved;
}
var hasWarnings = _diagnostics.Any(d => d.Severity == EntryTraceDiagnosticSeverity.Warning);
return hasWarnings ? EntryTraceOutcome.PartiallyResolved : EntryTraceOutcome.Resolved;
}
ResolveCommand(initialArgs, parent: null, originSpan: null, depth: 0, relationship: "entrypoint");
var outcome = DetermineOutcome();
return ToGraph(outcome);
}
private EntryTraceOutcome DetermineOutcome()
{
if (_diagnostics.Count == 0)
{
return EntryTraceOutcome.Resolved;
}
return _diagnostics.Any(d => d.Severity == EntryTraceDiagnosticSeverity.Error)
? EntryTraceOutcome.Unresolved
: EntryTraceOutcome.PartiallyResolved;
}
private EntryTraceGraph ToGraph(EntryTraceOutcome outcome)
{
return new EntryTraceGraph(
outcome,
_nodes.ToImmutableArray(),
_edges.ToImmutableArray(),
_diagnostics.ToImmutableArray());
}
private EntryTraceGraph ToGraph(EntryTraceOutcome outcome)
{
return new EntryTraceGraph(
outcome,
_nodes.ToImmutableArray(),
_edges.ToImmutableArray(),
_diagnostics.ToImmutableArray(),
_plans.ToImmutableArray(),
_terminals.ToImmutableArray());
}
private ImmutableArray<string> ComposeInitialCommand(EntrypointSpecification specification)
{
@@ -185,17 +212,17 @@ public sealed class EntryTraceAnalyzer : IEntryTraceAnalyzer
return ImmutableArray<string>.Empty;
}
private void ResolveCommand(
ImmutableArray<string> arguments,
EntryTraceNode? parent,
EntryTraceSpan? originSpan,
int depth,
string relationship)
{
if (arguments.Length == 0)
{
return;
}
private void ResolveCommand(
ImmutableArray<string> arguments,
EntryTraceNode? parent,
EntryTraceSpan? originSpan,
int depth,
string relationship)
{
if (arguments.Length == 0)
{
return;
}
if (depth >= _options.MaxDepth)
{
@@ -242,18 +269,19 @@ public sealed class EntryTraceAnalyzer : IEntryTraceAnalyzer
return;
}
if (TryFollowInterpreter(node, descriptor, arguments, depth))
{
return;
}
if (TryFollowShell(node, descriptor, arguments, depth))
{
return;
}
// Terminal executable.
}
if (TryFollowInterpreter(node, descriptor, arguments, depth))
{
return;
}
if (TryFollowShell(node, descriptor, arguments, depth))
{
return;
}
ClassifyTerminal(node, descriptor, arguments);
// Terminal executable.
}
private bool TryResolveExecutable(
string commandName,
@@ -497,16 +525,16 @@ public sealed class EntryTraceAnalyzer : IEntryTraceAnalyzer
return true;
}
private bool HandleJava(
EntryTraceNode node,
ImmutableArray<string> arguments,
RootFileDescriptor descriptor,
int depth)
{
if (arguments.Length < 2)
{
return false;
}
private bool HandleJava(
EntryTraceNode node,
ImmutableArray<string> arguments,
RootFileDescriptor descriptor,
int depth)
{
if (arguments.Length < 2)
{
return false;
}
string? jar = null;
string? mainClass = null;
@@ -526,40 +554,42 @@ public sealed class EntryTraceAnalyzer : IEntryTraceAnalyzer
}
}
if (jar is not null)
{
if (!_context.FileSystem.TryResolveExecutable(jar, Array.Empty<string>(), out var jarDescriptor))
{
_diagnostics.Add(new EntryTraceDiagnostic(
EntryTraceDiagnosticSeverity.Warning,
EntryTraceUnknownReason.JarNotFound,
$"Java JAR '{jar}' not found.",
Span: null,
RelatedPath: jar));
}
else
{
var jarNode = AddNode(
EntryTraceNodeKind.Executable,
jarDescriptor.Path,
ImmutableArray<string>.Empty,
EntryTraceInterpreterKind.Java,
new EntryTraceEvidence(jarDescriptor.Path, jarDescriptor.LayerDigest, "jar", null),
null);
_edges.Add(new EntryTraceEdge(node.Id, jarNode.Id, "executes", null));
}
return true;
}
if (mainClass is not null)
{
_edges.Add(new EntryTraceEdge(node.Id, node.Id, "java-main", new Dictionary<string, string>
{
["class"] = mainClass
}));
return true;
}
if (jar is not null)
{
if (!_context.FileSystem.TryResolveExecutable(jar, _pathEntries, out var jarDescriptor) &&
!_context.FileSystem.TryResolveExecutable(jar, Array.Empty<string>(), out jarDescriptor))
{
_diagnostics.Add(new EntryTraceDiagnostic(
EntryTraceDiagnosticSeverity.Warning,
EntryTraceUnknownReason.JarNotFound,
$"Java JAR '{jar}' not found.",
Span: null,
RelatedPath: jar));
return true;
}
var jarNode = AddNode(
EntryTraceNodeKind.Executable,
jarDescriptor.Path,
ImmutableArray<string>.Empty,
EntryTraceInterpreterKind.Java,
new EntryTraceEvidence(jarDescriptor.Path, jarDescriptor.LayerDigest, "jar", null),
null);
_edges.Add(new EntryTraceEdge(node.Id, jarNode.Id, "executes", null));
ClassifyTerminal(jarNode, jarDescriptor, arguments);
return true;
}
if (mainClass is not null)
{
_edges.Add(new EntryTraceEdge(node.Id, node.Id, "java-main", new Dictionary<string, string>
{
["class"] = mainClass
}));
ClassifyTerminal(node, descriptor, arguments);
return true;
}
return false;
}
@@ -1018,26 +1048,325 @@ public sealed class EntryTraceAnalyzer : IEntryTraceAnalyzer
return content.Contains("#!/bin/sh", StringComparison.Ordinal);
}
private EntryTraceNode AddNode(
EntryTraceNodeKind kind,
string displayName,
ImmutableArray<string> arguments,
EntryTraceInterpreterKind interpreterKind,
EntryTraceEvidence? evidence,
EntryTraceSpan? span)
{
var node = new EntryTraceNode(
_nextNodeId++,
kind,
displayName,
arguments,
interpreterKind,
evidence,
span);
_nodes.Add(node);
return node;
}
private EntryTraceNode AddNode(
EntryTraceNodeKind kind,
string displayName,
ImmutableArray<string> arguments,
EntryTraceInterpreterKind interpreterKind,
EntryTraceEvidence? evidence,
EntryTraceSpan? span,
ImmutableDictionary<string, string>? metadata = null)
{
var node = new EntryTraceNode(
_nextNodeId++,
kind,
displayName,
arguments,
interpreterKind,
evidence,
span,
metadata);
_nodes.Add(node);
return node;
}
private void ClassifyTerminal(
EntryTraceNode node,
RootFileDescriptor descriptor,
ImmutableArray<string> arguments)
{
var signature = CreateCommandSignature(arguments, node.DisplayName);
var key = $"{descriptor.Path}|{_context.User}|{_context.WorkingDirectory}|{signature}";
if (!_terminalKeys.Add(key))
{
return;
}
var evidence = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
double score = descriptor.IsExecutable ? 50d : 40d;
string? runtime = null;
var type = EntryTraceTerminalType.Unknown;
if (!string.IsNullOrWhiteSpace(descriptor.ShebangInterpreter))
{
var shebang = descriptor.ShebangInterpreter!;
evidence["shebang"] = shebang;
runtime = InferRuntimeFromShebang(shebang);
type = EntryTraceTerminalType.Script;
score += 15d;
}
if (_context.FileSystem.TryReadBytes(descriptor.Path, 2_097_152, out _, out var binaryContent))
{
var span = binaryContent.Span;
if (TryClassifyElf(span, evidence, ref runtime))
{
type = EntryTraceTerminalType.Native;
score += 15d;
}
else if (TryClassifyPe(span, evidence, ref runtime))
{
type = EntryTraceTerminalType.Managed;
score += 15d;
}
else if (IsZipArchive(span))
{
runtime ??= "java";
type = EntryTraceTerminalType.Managed;
score += 10d;
if (TryReadJarManifest(span, descriptor.Path, evidence))
{
score += 5d;
}
}
}
runtime ??= InferRuntimeFromCommand(node.DisplayName, arguments);
if (runtime is "go" or "rust")
{
type = EntryTraceTerminalType.Native;
score += 10d;
}
else if (runtime is ".net" or "java" or "python" or "node" or "ruby" or "php" or "php-fpm")
{
if (type == EntryTraceTerminalType.Unknown)
{
type = EntryTraceTerminalType.Managed;
}
score += 5d;
}
if (runtime is "shell")
{
type = EntryTraceTerminalType.Script;
}
if (type == EntryTraceTerminalType.Unknown)
{
type = EntryTraceTerminalType.Native;
}
var boundedScore = Math.Min(95d, score);
var terminal = new EntryTraceTerminal(
descriptor.Path,
type,
runtime,
boundedScore,
evidence.ToImmutable(),
_context.User,
_context.WorkingDirectory,
arguments.IsDefault ? ImmutableArray<string>.Empty : arguments);
var plan = new EntryTracePlan(
terminal.Arguments,
_context.Environment,
_context.WorkingDirectory,
_context.User,
terminal.Path,
terminal.Type,
terminal.Runtime,
terminal.Confidence,
terminal.Evidence);
_terminals.Add(terminal);
_plans.Add(plan);
}
private static string CreateCommandSignature(ImmutableArray<string> command, string displayName)
{
if (command.IsDefaultOrEmpty || command.Length == 0)
{
return displayName;
}
return string.Join('\u001F', command);
}
private static EntryTraceUnknownReason MapCandidateReason(string source)
=> source switch
{
"history" => EntryTraceUnknownReason.InferredEntrypointFromHistory,
"service-directory" => EntryTraceUnknownReason.InferredEntrypointFromServices,
"supervisor" => EntryTraceUnknownReason.InferredEntrypointFromSupervisor,
"entrypoint-script" => EntryTraceUnknownReason.InferredEntrypointFromEntrypointScript,
_ => EntryTraceUnknownReason.CommandNotFound
};
private static string CreateCandidateMessage(EntryTraceCandidate candidate)
{
var primary = candidate.Command.Length > 0 ? candidate.Command[0] : candidate.Source;
return candidate.Source switch
{
"history" => "Inferred entrypoint from image history.",
"service-directory" => $"Inferred service run script '{primary}'.",
"supervisor" => candidate.Description is null
? "Inferred supervisor command."
: $"Inferred supervisor program '{candidate.Description}'.",
"entrypoint-script" => $"Inferred entrypoint script '{primary}'.",
_ => "Inferred entrypoint candidate."
};
}
private static string? InferRuntimeFromShebang(string shebang)
{
var normalized = shebang.ToLowerInvariant();
if (normalized.Contains("python"))
{
return "python";
}
if (normalized.Contains("node"))
{
return "node";
}
if (normalized.Contains("ruby"))
{
return "ruby";
}
if (normalized.Contains("php-fpm"))
{
return "php-fpm";
}
if (normalized.Contains("php"))
{
return "php";
}
if (normalized.Contains("sh") || normalized.Contains("bash"))
{
return "shell";
}
return null;
}
private static string? InferRuntimeFromCommand(string commandName, ImmutableArray<string> arguments)
{
var normalized = commandName.ToLowerInvariant();
if (normalized == "java" || arguments.Any(arg => arg.Equals("-jar", StringComparison.OrdinalIgnoreCase)))
{
return "java";
}
if (normalized.Contains("dotnet") || normalized.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
{
return ".net";
}
if (normalized.Contains("python") || normalized.EndsWith(".py", StringComparison.OrdinalIgnoreCase))
{
return "python";
}
if (normalized.Contains("node") || normalized.EndsWith(".js", StringComparison.OrdinalIgnoreCase))
{
return "node";
}
if (normalized.Contains("go"))
{
return "go";
}
return null;
}
private bool TryClassifyElf(ReadOnlySpan<byte> span, ImmutableDictionary<string, string>.Builder evidence, ref string? runtime)
{
if (span.Length < 4 || span[0] != 0x7F || span[1] != (byte)'E' || span[2] != (byte)'L' || span[3] != (byte)'F')
{
return false;
}
evidence["binary.format"] = "ELF";
if (ContainsAscii(span, "Go build ID") || ContainsAscii(span, ".gopclntab"))
{
runtime = "go";
evidence["runtime"] = "go";
}
else if (ContainsAscii(span, "rust_eh_personality") || ContainsAscii(span, ".rustc"))
{
runtime = "rust";
evidence["runtime"] = "rust";
}
return true;
}
private static bool ContainsAscii(ReadOnlySpan<byte> span, string value)
{
var bytes = Encoding.ASCII.GetBytes(value);
return span.IndexOf(bytes) >= 0;
}
private bool TryClassifyPe(ReadOnlySpan<byte> span, ImmutableDictionary<string, string>.Builder evidence, ref string? runtime)
{
if (span.Length < 2 || span[0] != 'M' || span[1] != 'Z')
{
return false;
}
evidence["binary.format"] = "PE";
if (ContainsAscii(span, "BSJB") || ContainsAscii(span, "CLR"))
{
runtime = ".net";
evidence["pe.cli"] = "true";
}
return true;
}
private bool TryReadJarManifest(ReadOnlySpan<byte> span, string path, ImmutableDictionary<string, string>.Builder evidence)
{
try
{
using var stream = new MemoryStream(span.ToArray(), writable: false);
using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: false);
var manifestEntry = archive.GetEntry("META-INF/MANIFEST.MF");
if (manifestEntry is null)
{
return false;
}
using var reader = new StreamReader(manifestEntry.Open(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
var content = reader.ReadToEnd();
evidence["jar.manifest"] = "true";
foreach (var line in content.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
{
var separator = line.IndexOf(':');
if (separator <= 0)
{
continue;
}
var key = line[..separator].Trim();
var value = line[(separator + 1)..].Trim();
if (key.Equals("Main-Class", StringComparison.OrdinalIgnoreCase))
{
evidence["jar.main-class"] = value;
}
}
return true;
}
catch (InvalidDataException ex)
{
_logger.LogDebug(ex, "Failed to read jar manifest for {JarPath}.", path);
return false;
}
}
private static bool IsZipArchive(ReadOnlySpan<byte> span)
=> span.Length >= 4 && span[0] == 0x50 && span[1] == 0x4B && span[2] == 0x03 && span[3] == 0x04;
private static string CombineUnixPath(string baseDirectory, string relative)
{
var normalizedBase = NormalizeUnixPath(baseDirectory);

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,14 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.EntryTrace;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.EntryTrace.FileSystem;
using StellaOps.Scanner.EntryTrace.Parsing;
namespace StellaOps.Scanner.EntryTrace;
/// <summary>
/// Combines OCI configuration and root filesystem data into the context required by the EntryTrace analyzer.
@@ -31,22 +36,447 @@ public static class EntryTraceImageContextFactory
var workingDir = NormalizeWorkingDirectory(config.WorkingDirectory);
var user = NormalizeUser(config.User);
var context = new EntryTraceContext(
fileSystem,
environment,
path,
workingDir,
user,
imageDigest,
scanId,
logger);
var entrypoint = EntrypointSpecification.FromExecForm(
config.Entrypoint.IsDefaultOrEmpty ? null : config.Entrypoint,
config.Command.IsDefaultOrEmpty ? null : config.Command);
return new EntryTraceImageContext(entrypoint, context);
}
var context = new EntryTraceContext(
fileSystem,
environment,
path,
workingDir,
user,
imageDigest,
scanId,
logger);
var candidates = BuildFallbackCandidates(config, fileSystem, logger);
context = context with { Candidates = candidates };
var entrypoint = EntrypointSpecification.FromExecForm(
config.Entrypoint.IsDefaultOrEmpty ? null : config.Entrypoint,
config.Command.IsDefaultOrEmpty ? null : config.Command);
return new EntryTraceImageContext(entrypoint, context);
}
private static ImmutableArray<EntryTraceCandidate> BuildFallbackCandidates(
OciImageConfig config,
IRootFileSystem fileSystem,
ILogger? logger)
{
if (config.Entrypoint.Length > 0 || config.Command.Length > 0)
{
return ImmutableArray<EntryTraceCandidate>.Empty;
}
var builder = ImmutableArray.CreateBuilder<EntryTraceCandidate>();
var seen = new HashSet<string>(StringComparer.Ordinal);
void AddCandidate(ImmutableArray<string> command, string source, EntryTraceEvidence? evidence, string? description)
{
if (command.IsDefaultOrEmpty || command.Length == 0)
{
return;
}
var signature = CreateSignature(command);
if (!seen.Add(signature))
{
return;
}
builder.Add(new EntryTraceCandidate(command, source, evidence, description));
}
if (TryAddHistoryCandidate(config, AddCandidate, logger))
{
// Preserve first viable history candidate only.
}
CollectEntrypointScripts(fileSystem, AddCandidate);
CollectSupervisorCommands(fileSystem, AddCandidate);
CollectServiceRunScripts(fileSystem, AddCandidate);
return builder.ToImmutable();
}
private static bool TryAddHistoryCandidate(
OciImageConfig config,
Action<ImmutableArray<string>, string, EntryTraceEvidence?, string?> addCandidate,
ILogger? logger)
{
if (config.History.IsDefaultOrEmpty || config.History.Length == 0)
{
return false;
}
for (var i = config.History.Length - 1; i >= 0; i--)
{
var entry = config.History[i];
if (TryExtractHistoryCommand(entry?.CreatedBy, out var command))
{
var metadata = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
if (!string.IsNullOrWhiteSpace(entry?.CreatedBy))
{
metadata["created_by"] = entry!.CreatedBy!;
}
var evidence = new EntryTraceEvidence(
Path: "/image/history",
LayerDigest: null,
Source: "history",
metadata.Count > 0 ? metadata.ToImmutable() : null);
addCandidate(command, "history", evidence, null);
return true;
}
}
return false;
}
private static void CollectEntrypointScripts(
IRootFileSystem fileSystem,
Action<ImmutableArray<string>, string, EntryTraceEvidence?, string?> addCandidate)
{
const string directory = "/usr/local/bin";
if (!fileSystem.DirectoryExists(directory))
{
return;
}
foreach (var entry in fileSystem.EnumerateDirectory(directory))
{
if (entry.IsDirectory || !entry.IsExecutable)
{
continue;
}
var name = Path.GetFileName(entry.Path);
if (!name.Contains("entrypoint", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var evidence = new EntryTraceEvidence(entry.Path, entry.LayerDigest, "entrypoint-script", null);
addCandidate(ImmutableArray.Create(entry.Path), "entrypoint-script", evidence, null);
}
}
private static void CollectSupervisorCommands(
IRootFileSystem fileSystem,
Action<ImmutableArray<string>, string, EntryTraceEvidence?, string?> addCandidate)
{
const string root = "/etc/supervisor";
if (!fileSystem.DirectoryExists(root))
{
return;
}
var pending = new Stack<string>();
pending.Push(root);
while (pending.Count > 0)
{
var current = pending.Pop();
foreach (var entry in fileSystem.EnumerateDirectory(current))
{
if (entry.IsDirectory)
{
pending.Push(entry.Path);
continue;
}
if (!entry.Path.EndsWith(".conf", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (!fileSystem.TryReadAllText(entry.Path, out var descriptor, out var content))
{
continue;
}
foreach (var (command, program) in ExtractSupervisorCommands(content))
{
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
if (!string.IsNullOrWhiteSpace(program))
{
metadataBuilder["program"] = program!;
}
var evidence = new EntryTraceEvidence(
entry.Path,
descriptor.LayerDigest,
"supervisor",
metadataBuilder.Count > 0 ? metadataBuilder.ToImmutable() : null);
addCandidate(command, "supervisor", evidence, program);
}
}
}
}
private static void CollectServiceRunScripts(
IRootFileSystem fileSystem,
Action<ImmutableArray<string>, string, EntryTraceEvidence?, string?> addCandidate)
{
string[] roots =
{
"/etc/services.d",
"/etc/services",
"/service",
"/s6",
"/etc/s6",
"/etc/s6-overlay/s6-rc.d"
};
foreach (var root in roots)
{
if (!fileSystem.DirectoryExists(root))
{
continue;
}
var pending = new Stack<string>();
pending.Push(root);
while (pending.Count > 0)
{
var current = pending.Pop();
foreach (var entry in fileSystem.EnumerateDirectory(current))
{
if (entry.IsDirectory)
{
pending.Push(entry.Path);
continue;
}
if (!entry.IsExecutable)
{
continue;
}
if (!entry.Path.EndsWith("/run", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var metadata = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
var directory = Path.GetDirectoryName(entry.Path);
if (!string.IsNullOrWhiteSpace(directory))
{
metadata["service_dir"] = directory!.Replace('\\', '/');
}
var evidence = new EntryTraceEvidence(
entry.Path,
entry.LayerDigest,
"service-directory",
metadata.Count > 0 ? metadata.ToImmutable() : null);
addCandidate(ImmutableArray.Create(entry.Path), "service-directory", evidence, null);
}
}
}
}
private static IEnumerable<(ImmutableArray<string> Command, string? Program)> ExtractSupervisorCommands(string content)
{
var results = new List<(ImmutableArray<string>, string?)>();
if (string.IsNullOrWhiteSpace(content))
{
return results;
}
string? currentProgram = null;
foreach (var rawLine in content.Split('\n'))
{
var line = rawLine.Trim();
if (line.Length == 0 || line.StartsWith("#", StringComparison.Ordinal))
{
continue;
}
if (line.StartsWith("[", StringComparison.Ordinal) && line.EndsWith("]", StringComparison.Ordinal))
{
var section = line[1..^1].Trim();
if (section.StartsWith("program:", StringComparison.OrdinalIgnoreCase))
{
currentProgram = section["program:".Length..].Trim();
}
else
{
currentProgram = null;
}
continue;
}
if (!line.StartsWith("command", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var equalsIndex = line.IndexOf('=', StringComparison.Ordinal);
if (equalsIndex < 0)
{
continue;
}
var commandText = line[(equalsIndex + 1)..].Trim();
if (commandText.Length == 0)
{
continue;
}
if (TryTokenizeShellCommand(commandText, out var command))
{
results.Add((command, currentProgram));
}
}
return results;
}
private static bool TryExtractHistoryCommand(string? createdBy, out ImmutableArray<string> command)
{
command = ImmutableArray<string>.Empty;
if (string.IsNullOrWhiteSpace(createdBy))
{
return false;
}
var text = createdBy.Trim();
if (TryParseHistoryJsonCommand(text, "CMD", out command) ||
TryParseHistoryJsonCommand(text, "ENTRYPOINT", out command))
{
return true;
}
var shIndex = text.IndexOf("/bin/sh", StringComparison.OrdinalIgnoreCase);
if (shIndex >= 0)
{
var dashC = text.IndexOf("-c", shIndex, StringComparison.OrdinalIgnoreCase);
if (dashC >= 0)
{
var after = text[(dashC + 2)..].Trim();
if (after.StartsWith("#(nop)", StringComparison.OrdinalIgnoreCase))
{
after = after[6..].Trim();
}
if (after.StartsWith("CMD", StringComparison.OrdinalIgnoreCase))
{
after = after[3..].Trim();
}
else if (after.StartsWith("ENTRYPOINT", StringComparison.OrdinalIgnoreCase))
{
after = after[10..].Trim();
}
if (after.StartsWith("[", StringComparison.Ordinal))
{
if (TryParseJsonArray(after, out command))
{
return true;
}
}
else if (TryTokenizeShellCommand(after, out command))
{
return true;
}
}
}
return false;
}
private static bool TryParseHistoryJsonCommand(string text, string keyword, out ImmutableArray<string> command)
{
command = ImmutableArray<string>.Empty;
var index = text.IndexOf(keyword, StringComparison.OrdinalIgnoreCase);
if (index < 0)
{
return false;
}
var bracket = text.IndexOf('[', index);
if (bracket < 0)
{
return false;
}
var end = text.IndexOf(']', bracket);
if (end < 0)
{
return false;
}
var json = text.Substring(bracket, end - bracket + 1);
return TryParseJsonArray(json, out command);
}
private static bool TryParseJsonArray(string json, out ImmutableArray<string> command)
{
try
{
using var document = JsonDocument.Parse(json);
if (document.RootElement.ValueKind != JsonValueKind.Array)
{
command = ImmutableArray<string>.Empty;
return false;
}
var builder = ImmutableArray.CreateBuilder<string>();
foreach (var element in document.RootElement.EnumerateArray())
{
if (element.ValueKind == JsonValueKind.String)
{
builder.Add(element.GetString() ?? string.Empty);
}
}
command = builder.ToImmutable();
return command.Length > 0;
}
catch (JsonException)
{
command = ImmutableArray<string>.Empty;
return false;
}
}
private static bool TryTokenizeShellCommand(string commandText, out ImmutableArray<string> command)
{
var tokenizer = new ShellTokenizer();
var tokens = tokenizer.Tokenize(commandText);
var builder = ImmutableArray.CreateBuilder<string>();
foreach (var token in tokens)
{
switch (token.Kind)
{
case ShellTokenKind.Word:
case ShellTokenKind.SingleQuoted:
case ShellTokenKind.DoubleQuoted:
builder.Add(token.Value);
break;
case ShellTokenKind.Operator:
case ShellTokenKind.NewLine:
case ShellTokenKind.EndOfFile:
if (builder.Count > 0)
{
command = builder.ToImmutable();
return command.Length > 0;
}
break;
}
}
command = builder.ToImmutable();
return command.Length > 0;
}
private static string CreateSignature(ImmutableArray<string> command)
=> string.Join('\u001F', command);
private static ImmutableDictionary<string, string> BuildEnvironment(ImmutableArray<string> raw)
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();
}

View File

@@ -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) =>

View File

@@ -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)
{

View File

@@ -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);
}
}

View File

@@ -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";
}

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Scanner.Surface.Env.Tests")]

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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));
}

View File

@@ -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; }
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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 };
}

View File

@@ -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. |

View File

@@ -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;
}
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}
}

View File

@@ -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>

View File

@@ -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)!;
}
}

View File

@@ -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}";
}

View File

@@ -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");
}
}

View File

@@ -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. |

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Scanner.Surface.Secrets.Tests")]

View File

@@ -0,0 +1,10 @@
using System.Threading.Tasks;
namespace StellaOps.Scanner.Surface.Secrets;
public interface ISurfaceSecretProvider
{
ValueTask<SurfaceSecretHandle> GetAsync(
SurfaceSecretRequest request,
CancellationToken cancellationToken = default);
}

View File

@@ -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);
}
}

View File

@@ -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; }
}
}

View File

@@ -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);
}
}

View File

@@ -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}";
}
}

View File

@@ -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
});
}
}

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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; }
}

View File

@@ -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");
}

View File

@@ -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);
}

View File

@@ -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. |

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View File

@@ -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; }
}

View File

@@ -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);
}

View File

@@ -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";
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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,
}

View File

@@ -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);
}
}
}

View File

@@ -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. |

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

@@ -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));
}
}