Add Authority Advisory AI and API Lifecycle Configuration

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

View File

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