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,52 +1,64 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.EntryTrace.Diagnostics;
using Xunit;
namespace StellaOps.Scanner.EntryTrace.Tests;
public sealed class EntryTraceAnalyzerTests
{
private static EntryTraceAnalyzer CreateAnalyzer()
{
var options = Options.Create(new EntryTraceAnalyzerOptions
{
MaxDepth = 32,
FollowRunParts = true
});
return new EntryTraceAnalyzer(options, new EntryTraceMetrics(), NullLogger<EntryTraceAnalyzer>.Instance);
}
[Fact]
public async Task ResolveAsync_FollowsShellIncludeAndPythonModule()
{
var fs = new TestRootFileSystem();
fs.AddFile("/entrypoint.sh", """
#!/bin/sh
source /opt/setup.sh
exec python -m app.main --flag
""");
fs.AddFile("/opt/setup.sh", """
#!/bin/sh
run-parts /opt/setup.d
""");
fs.AddDirectory("/opt/setup.d");
fs.AddFile("/opt/setup.d/001-node.sh", """
#!/bin/sh
exec node /app/server.js
""");
fs.AddFile("/opt/setup.d/010-java.sh", """
#!/bin/sh
java -jar /app/app.jar
""");
fs.AddFile("/usr/bin/python", "#!/usr/bin/env python3\n", executable: true);
fs.AddFile("/usr/bin/node", "#!/usr/bin/env node\n", executable: true);
fs.AddFile("/usr/bin/java", "", executable: true);
fs.AddFile("/app/server.js", "console.log('hello');", executable: true);
fs.AddFile("/app/app.jar", string.Empty, executable: true);
var analyzer = CreateAnalyzer();
using System.Collections.Immutable;
using System.IO;
using System.IO.Compression;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.EntryTrace.Diagnostics;
using Xunit;
using Xunit.Abstractions;
using Xunit.Sdk;
namespace StellaOps.Scanner.EntryTrace.Tests;
public sealed class EntryTraceAnalyzerTests
{
private readonly ITestOutputHelper _output;
public EntryTraceAnalyzerTests(ITestOutputHelper output)
{
_output = output;
}
private static EntryTraceAnalyzer CreateAnalyzer()
{
var options = Options.Create(new EntryTraceAnalyzerOptions
{
MaxDepth = 32,
FollowRunParts = true
});
return new EntryTraceAnalyzer(options, new EntryTraceMetrics(), NullLogger<EntryTraceAnalyzer>.Instance);
}
[Fact]
public async Task ResolveAsync_FollowsShellIncludeAndPythonModule()
{
var fs = new TestRootFileSystem();
fs.AddFile("/entrypoint.sh", """
#!/bin/sh
source /opt/setup.sh
exec python -m app.main --flag
""");
fs.AddFile("/opt/setup.sh", """
#!/bin/sh
run-parts /opt/setup.d
""");
fs.AddDirectory("/opt/setup.d");
fs.AddFile("/opt/setup.d/001-node.sh", """
#!/bin/sh
exec node /app/server.js
""");
fs.AddFile("/opt/setup.d/010-java.sh", """
#!/bin/sh
java -jar /app/app.jar
""");
fs.AddFile("/usr/bin/python", "#!/usr/bin/env python3\n", executable: true);
fs.AddFile("/usr/bin/node", "#!/usr/bin/env node\n", executable: true);
fs.AddFile("/usr/bin/java", "", executable: true);
fs.AddFile("/app/server.js", "console.log('hello');", executable: true);
fs.AddFile("/app/app.jar", string.Empty, executable: true);
var analyzer = CreateAnalyzer();
var context = new EntryTraceContext(
fs,
ImmutableDictionary<string, string>.Empty,
@@ -56,36 +68,40 @@ public sealed class EntryTraceAnalyzerTests
"sha256:image",
"scan-entrytrace-1",
NullLogger.Instance);
var spec = EntrypointSpecification.FromExecForm(new[] { "/entrypoint.sh" }, Array.Empty<string>());
var result = await analyzer.ResolveAsync(spec, context);
Assert.Equal(EntryTraceOutcome.Resolved, result.Outcome);
Assert.Empty(result.Diagnostics);
var nodeNames = result.Nodes.Select(n => (n.Kind, n.DisplayName)).ToArray();
Assert.Contains((EntryTraceNodeKind.Command, "/entrypoint.sh"), nodeNames);
Assert.Contains((EntryTraceNodeKind.Include, "/opt/setup.sh"), nodeNames);
Assert.Contains(nodeNames, tuple => tuple.Kind == EntryTraceNodeKind.Command && tuple.DisplayName == "python");
Assert.Contains(nodeNames, tuple => tuple.Kind == EntryTraceNodeKind.Command && tuple.DisplayName == "node");
Assert.Contains(nodeNames, tuple => tuple.Kind == EntryTraceNodeKind.Command && tuple.DisplayName == "java");
Assert.Contains(nodeNames, tuple => tuple.Kind == EntryTraceNodeKind.RunPartsDirectory && tuple.DisplayName == "/opt/setup.d");
Assert.Contains(result.Edges, edge => edge.Relationship == "python-module" && edge.Metadata is { } metadata && metadata.TryGetValue("module", out var module) && module == "app.main");
}
[Fact]
public async Task ResolveAsync_RecordsDiagnosticsForMissingInclude()
{
var fs = new TestRootFileSystem();
fs.AddFile("/entrypoint.sh", """
#!/bin/sh
source /missing/setup.sh
exec /bin/true
""");
fs.AddFile("/bin/true", string.Empty, executable: true);
var analyzer = CreateAnalyzer();
var spec = EntrypointSpecification.FromExecForm(new[] { "/entrypoint.sh" }, Array.Empty<string>());
var result = await analyzer.ResolveAsync(spec, context);
if (result.Outcome != EntryTraceOutcome.Resolved)
{
var details = string.Join(", ", result.Diagnostics.Select(d => d.Severity + ":" + d.Reason));
throw new XunitException("Unexpected outcome: " + result.Outcome + "; diagnostics=" + details);
}
Assert.Empty(result.Diagnostics);
var nodeNames = result.Nodes.Select(n => (n.Kind, n.DisplayName)).ToArray();
Assert.Contains((EntryTraceNodeKind.Command, "/entrypoint.sh"), nodeNames);
Assert.Contains((EntryTraceNodeKind.Include, "/opt/setup.sh"), nodeNames);
Assert.Contains(nodeNames, tuple => tuple.Kind == EntryTraceNodeKind.Command && tuple.DisplayName == "python");
Assert.Contains(nodeNames, tuple => tuple.Kind == EntryTraceNodeKind.Command && tuple.DisplayName == "node");
Assert.Contains(nodeNames, tuple => tuple.Kind == EntryTraceNodeKind.Command && tuple.DisplayName == "java");
Assert.Contains(nodeNames, tuple => tuple.Kind == EntryTraceNodeKind.RunPartsDirectory && tuple.DisplayName == "/opt/setup.d");
Assert.Contains(result.Edges, edge => edge.Relationship == "python-module" && edge.Metadata is { } metadata && metadata.TryGetValue("module", out var module) && module == "app.main");
}
[Fact]
public async Task ResolveAsync_RecordsDiagnosticsForMissingInclude()
{
var fs = new TestRootFileSystem();
fs.AddFile("/entrypoint.sh", """
#!/bin/sh
source /missing/setup.sh
exec /bin/true
""");
fs.AddFile("/bin/true", string.Empty, executable: true);
var analyzer = CreateAnalyzer();
var context = new EntryTraceContext(
fs,
ImmutableDictionary<string, string>.Empty,
@@ -95,27 +111,27 @@ public sealed class EntryTraceAnalyzerTests
"sha256:image",
"scan-entrytrace-2",
NullLogger.Instance);
var spec = EntrypointSpecification.FromExecForm(new[] { "/entrypoint.sh" }, Array.Empty<string>());
var result = await analyzer.ResolveAsync(spec, context);
Assert.Equal(EntryTraceOutcome.PartiallyResolved, result.Outcome);
Assert.Single(result.Diagnostics);
Assert.Equal(EntryTraceUnknownReason.MissingFile, result.Diagnostics[0].Reason);
}
[Fact]
public async Task ResolveAsync_IsDeterministic()
{
var fs = new TestRootFileSystem();
fs.AddFile("/entrypoint.sh", """
#!/bin/sh
exec node /app/index.js
""");
fs.AddFile("/usr/bin/node", string.Empty, executable: true);
fs.AddFile("/app/index.js", "console.log('deterministic');", executable: true);
var analyzer = CreateAnalyzer();
var spec = EntrypointSpecification.FromExecForm(new[] { "/entrypoint.sh" }, Array.Empty<string>());
var result = await analyzer.ResolveAsync(spec, context);
Assert.Equal(EntryTraceOutcome.PartiallyResolved, result.Outcome);
Assert.Single(result.Diagnostics);
Assert.Equal(EntryTraceUnknownReason.MissingFile, result.Diagnostics[0].Reason);
}
[Fact]
public async Task ResolveAsync_IsDeterministic()
{
var fs = new TestRootFileSystem();
fs.AddFile("/entrypoint.sh", """
#!/bin/sh
exec node /app/index.js
""");
fs.AddFile("/usr/bin/node", string.Empty, executable: true);
fs.AddFile("/app/index.js", "console.log('deterministic');", executable: true);
var analyzer = CreateAnalyzer();
var context = new EntryTraceContext(
fs,
ImmutableDictionary<string, string>.Empty,
@@ -125,13 +141,13 @@ public sealed class EntryTraceAnalyzerTests
"sha256:image",
"scan-entrytrace-3",
NullLogger.Instance);
var spec = EntrypointSpecification.FromExecForm(new[] { "/entrypoint.sh" }, Array.Empty<string>());
var first = await analyzer.ResolveAsync(spec, context);
var second = await analyzer.ResolveAsync(spec, context);
Assert.Equal(first.Outcome, second.Outcome);
Assert.Equal(first.Diagnostics, second.Diagnostics);
var spec = EntrypointSpecification.FromExecForm(new[] { "/entrypoint.sh" }, Array.Empty<string>());
var first = await analyzer.ResolveAsync(spec, context);
var second = await analyzer.ResolveAsync(spec, context);
Assert.Equal(first.Outcome, second.Outcome);
Assert.Equal(first.Diagnostics, second.Diagnostics);
Assert.Equal(first.Nodes.Select(n => (n.Kind, n.DisplayName)).ToArray(), second.Nodes.Select(n => (n.Kind, n.DisplayName)).ToArray());
Assert.Equal(first.Edges.Select(e => (e.FromNodeId, e.ToNodeId, e.Relationship)).ToArray(),
second.Edges.Select(e => (e.FromNodeId, e.ToNodeId, e.Relationship)).ToArray());
@@ -144,6 +160,7 @@ public sealed class EntryTraceAnalyzerTests
fs.AddFile("/windows/system32/cmd.exe", string.Empty, executable: true);
fs.AddFile("/scripts/start.bat", "@echo off\r\necho start\r\n", executable: true);
var analyzer = CreateAnalyzer();
var context = new EntryTraceContext(
fs,
@@ -158,8 +175,230 @@ public sealed class EntryTraceAnalyzerTests
var spec = EntrypointSpecification.FromExecForm(new[] { "cmd.exe", "/c", "/scripts/start.bat" }, Array.Empty<string>());
var result = await analyzer.ResolveAsync(spec, context);
Assert.Equal(EntryTraceOutcome.PartiallyResolved, result.Outcome);
Assert.Equal(EntryTraceOutcome.Resolved, result.Outcome);
Assert.Contains(result.Nodes, node => node.Kind == EntryTraceNodeKind.Script && node.DisplayName == "/scripts/start.bat");
Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Reason == EntryTraceUnknownReason.UnsupportedSyntax);
}
[Fact]
public async Task ResolveAsync_ClassifiesGoBinaryWithPlan()
{
var fs = new TestRootFileSystem();
var goBinary = CreateGoBinary();
fs.AddBinaryFile("/usr/local/bin/goapp", goBinary, executable: true);
var analyzer = CreateAnalyzer();
var context = new EntryTraceContext(
fs,
ImmutableDictionary<string, string>.Empty,
ImmutableArray.Create("/usr/local/bin"),
"/",
"root",
"sha256:go-image",
"scan-go",
NullLogger.Instance);
var spec = EntrypointSpecification.FromExecForm(new[] { "/usr/local/bin/goapp", "--serve" }, null);
var result = await analyzer.ResolveAsync(spec, context);
Assert.Equal(EntryTraceOutcome.Resolved, result.Outcome);
var terminal = Assert.Single(result.Terminals);
Assert.Equal("/usr/local/bin/goapp", terminal.Path);
Assert.Equal(EntryTraceTerminalType.Native, terminal.Type);
Assert.Equal("go", terminal.Runtime);
Assert.True(terminal.Confidence >= 70d);
Assert.False(terminal.Arguments.IsDefault);
Assert.Equal(2, terminal.Arguments.Length);
Assert.Equal("--serve", terminal.Arguments[1]);
Assert.Contains("runtime", terminal.Evidence.Keys);
var plan = Assert.Single(result.Plans);
Assert.Equal(terminal.Path, plan.TerminalPath);
Assert.Equal("go", plan.Runtime);
Assert.Equal(terminal.Confidence, plan.Confidence);
Assert.Equal(terminal.Arguments, plan.Command);
Assert.Equal("/", plan.WorkingDirectory);
}
[Fact]
public async Task ResolveAsync_ExtractsJarManifestEvidence()
{
var fs = new TestRootFileSystem();
var jarBytes = CreateJarWithManifest("com.example.Main");
fs.AddBinaryFile("/app/example.jar", jarBytes, executable: true);
fs.AddFile("/usr/bin/java", string.Empty, executable: true);
Assert.True(fs.TryResolveExecutable("/app/example.jar", Array.Empty<string>(), out _));
var analyzer = CreateAnalyzer();
var context = new EntryTraceContext(
fs,
ImmutableDictionary<string, string>.Empty,
ImmutableArray.Create("/usr/bin"),
"/",
"root",
"sha256:java-image",
"scan-jar",
NullLogger.Instance);
var spec = EntrypointSpecification.FromExecForm(new[] { "java", "-jar", "/app/example.jar" }, null);
var result = await analyzer.ResolveAsync(spec, context);
if (result.Terminals.Length == 0)
{
var nodeSummary = string.Join(", ", result.Nodes.Select(n => $"{n.Kind}:{n.DisplayName}"));
var diagSummary = string.Join(", ", result.Diagnostics.Select(d => d.Reason.ToString()));
throw new XunitException($"Terminals empty; nodes={nodeSummary}; diags={diagSummary}");
}
var terminal = Assert.Single(result.Terminals);
Assert.Equal(EntryTraceTerminalType.Managed, terminal.Type);
Assert.Equal("java", terminal.Runtime);
Assert.True(terminal.Evidence.ContainsKey("jar.manifest"));
Assert.Equal("com.example.Main", terminal.Evidence["jar.main-class"]);
Assert.Contains("-jar", terminal.Arguments);
var plan = Assert.Single(result.Plans);
Assert.Equal(terminal.Evidence, plan.Evidence);
Assert.Equal("java", plan.Runtime);
Assert.Equal(terminal.Confidence, plan.Confidence);
}
[Fact]
public async Task ResolveAsync_UsesHistoryCandidateWhenEntrypointMissing()
{
var fs = new TestRootFileSystem();
fs.AddBinaryFile("/usr/bin/node", CreateGoBinary(), executable: true);
var config = new OciImageConfig
{
History = ImmutableArray.Create(new OciHistoryEntry("/bin/sh -c #(nop) CMD [\"/usr/bin/node\",\"/app/server.js\"]", false))
};
var options = new EntryTraceAnalyzerOptions();
var imageContext = EntryTraceImageContextFactory.Create(
config,
fs,
options,
"sha256:image-history",
"scan-history",
NullLogger.Instance);
var analyzer = CreateAnalyzer();
var result = await analyzer.ResolveAsync(imageContext.Entrypoint, imageContext.Context);
Assert.Equal(EntryTraceOutcome.Resolved, result.Outcome);
Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Reason == EntryTraceUnknownReason.InferredEntrypointFromHistory);
var terminal = Assert.Single(result.Terminals);
Assert.Equal("/usr/bin/node", terminal.Path);
Assert.Contains("/usr/bin/node", terminal.Arguments[0]);
}
[Fact]
public async Task ResolveAsync_DiscoversSupervisorCommand()
{
var fs = new TestRootFileSystem();
fs.AddBinaryFile("/usr/bin/gunicorn", CreateGoBinary(), executable: true);
fs.AddDirectory("/etc/supervisor");
fs.AddFile("/etc/supervisor/app.conf", """
[program:web]
command=gunicorn app:app
""");
var config = new OciImageConfig();
var options = new EntryTraceAnalyzerOptions();
var imageContext = EntryTraceImageContextFactory.Create(
config,
fs,
options,
"sha256:image-supervisor",
"scan-supervisor",
NullLogger.Instance);
var analyzer = CreateAnalyzer();
var result = await analyzer.ResolveAsync(imageContext.Entrypoint, imageContext.Context);
Assert.Equal(EntryTraceOutcome.Resolved, result.Outcome);
Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Reason == EntryTraceUnknownReason.InferredEntrypointFromSupervisor);
var terminal = Assert.Single(result.Terminals);
Assert.Equal("/usr/bin/gunicorn", terminal.Path);
Assert.Contains("gunicorn", terminal.Arguments[0]);
}
[Fact]
public async Task ResolveAsync_DiscoversServiceRunScript()
{
var fs = new TestRootFileSystem();
fs.AddDirectory("/etc");
fs.AddDirectory("/etc/services.d");
fs.AddDirectory("/etc/services.d/web");
fs.AddBinaryFile("/usr/bin/python", CreateGoBinary(), executable: true);
fs.AddFile("/etc/services.d/web/run", """
#!/bin/sh
/usr/bin/python -m app.main
""");
var config = new OciImageConfig();
var options = new EntryTraceAnalyzerOptions();
var imageContext = EntryTraceImageContextFactory.Create(
config,
fs,
options,
"sha256:image-service",
"scan-service",
NullLogger.Instance);
var candidates = imageContext.Context.Candidates;
var candidateSummary = string.Join(", ", candidates.Select(c => $"{c.Source}:{string.Join(' ', c.Command)}"));
Assert.True(
candidates.Length > 0,
$"Candidates discovered: {candidateSummary}");
var inferred = Assert.Single(candidates);
Assert.Equal("service-directory", inferred.Source);
Assert.Equal("/etc/services.d/web/run", inferred.Command[0]);
Assert.NotNull(inferred.Evidence);
Assert.True(inferred.Evidence!.Metadata?.ContainsKey("service_dir") ?? false);
Assert.Equal("/etc/services.d/web", inferred.Evidence!.Metadata!["service_dir"]);
var analyzer = CreateAnalyzer();
var result = await analyzer.ResolveAsync(imageContext.Entrypoint, imageContext.Context);
var nonInfoDiagnostics = result.Diagnostics.Where(d => d.Severity != EntryTraceDiagnosticSeverity.Info).ToArray();
Assert.True(nonInfoDiagnostics.Length == 0, string.Join(", ", nonInfoDiagnostics.Select(d => d.Severity + ":" + d.Reason)));
Assert.Equal(EntryTraceOutcome.Resolved, result.Outcome);
Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Reason == EntryTraceUnknownReason.InferredEntrypointFromServices);
var terminal = Assert.Single(result.Terminals);
Assert.Equal("/usr/bin/python", terminal.Path);
Assert.Contains("/usr/bin/python", terminal.Arguments[0]);
Assert.Contains(result.Nodes, node => node.Kind == EntryTraceNodeKind.Script && node.DisplayName == "/etc/services.d/web/run");
Assert.Equal(EntryTraceTerminalType.Native, terminal.Type);
}
private static byte[] CreateGoBinary()
{
var buffer = new byte[256];
buffer[0] = 0x7F;
buffer[1] = (byte)'E';
buffer[2] = (byte)'L';
buffer[3] = (byte)'F';
var signature = Encoding.ASCII.GetBytes("Go build ID");
signature.CopyTo(buffer, 32);
return buffer;
}
private static byte[] CreateJarWithManifest(string mainClass)
{
using var stream = new MemoryStream();
using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true))
{
var manifest = archive.CreateEntry("META-INF/MANIFEST.MF");
using var writer = new StreamWriter(manifest.Open(), Encoding.UTF8);
writer.WriteLine("Manifest-Version: 1.0");
writer.WriteLine($"Main-Class: {mainClass}");
writer.Flush();
}
return stream.ToArray();
}
}

View File

@@ -0,0 +1,152 @@
using System;
using System.Buffers;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using StellaOps.Scanner.EntryTrace;
using Xunit;
namespace StellaOps.Scanner.EntryTrace.Tests;
public sealed class EntryTraceNdjsonWriterTests
{
[Fact]
public void Serialize_ProducesDeterministicNdjsonLines()
{
var (graph, metadata) = CreateSampleGraph();
var lines = EntryTraceNdjsonWriter.Serialize(graph, metadata);
Assert.Equal(6, lines.Length);
var entryJson = Parse(lines[0]);
Assert.Equal("entrytrace.entry", entryJson.GetProperty("type").GetString());
Assert.Equal(metadata.ScanId, entryJson.GetProperty("scan_id").GetString());
Assert.Equal("resolved", entryJson.GetProperty("outcome").GetString());
Assert.Equal(1, entryJson.GetProperty("nodes").GetInt32());
Assert.Equal(1, entryJson.GetProperty("edges").GetInt32());
Assert.Equal(1, entryJson.GetProperty("targets").GetInt32());
Assert.Equal(1, entryJson.GetProperty("warnings").GetInt32());
var nodeJson = Parse(lines[1]);
Assert.Equal("entrytrace.node", nodeJson.GetProperty("type").GetString());
Assert.Equal("gosu", nodeJson.GetProperty("display_name").GetString());
Assert.Equal("command", nodeJson.GetProperty("kind").GetString());
Assert.Equal("user-switch", nodeJson.GetProperty("metadata").GetProperty("wrapper.category").GetString());
var edgeJson = Parse(lines[2]);
Assert.Equal("entrytrace.edge", edgeJson.GetProperty("type").GetString());
Assert.Equal("wraps", edgeJson.GetProperty("relationship").GetString());
var targetJson = Parse(lines[3]);
Assert.Equal("entrytrace.target", targetJson.GetProperty("type").GetString());
Assert.Equal("python", targetJson.GetProperty("runtime").GetString());
Assert.Equal("scanner", targetJson.GetProperty("user").GetString());
Assert.Equal(87.5, targetJson.GetProperty("confidence").GetDouble());
Assert.Equal("medium", targetJson.GetProperty("confidence_level").GetString());
var warningJson = Parse(lines[4]);
Assert.Equal("entrytrace.warning", warningJson.GetProperty("type").GetString());
Assert.Equal("dynamicevaluation", warningJson.GetProperty("reason").GetString());
var capabilityJson = Parse(lines[5]);
Assert.Equal("entrytrace.capability", capabilityJson.GetProperty("type").GetString());
Assert.Equal("user-switch", capabilityJson.GetProperty("category").GetString());
Assert.Equal("gosu", capabilityJson.GetProperty("name").GetString());
}
[Fact]
public void Serialize_ProducesStableSha256Hash()
{
var (graph, metadata) = CreateSampleGraph();
var lines = EntryTraceNdjsonWriter.Serialize(graph, metadata);
var buffer = new ArrayBufferWriter<byte>(128);
foreach (var line in lines)
{
var bytes = Encoding.UTF8.GetBytes(line);
buffer.Write(bytes);
}
var hash = SHA256.HashData(buffer.WrittenSpan);
var actual = Convert.ToHexString(hash).ToLowerInvariant();
const string ExpectedHash = "37444d7f68ceafd3e974c10ce5e78c973874d4a0abe73660f8ad204151b93c62";
Assert.Equal(ExpectedHash, actual);
}
private static (EntryTraceGraph Graph, EntryTraceNdjsonMetadata Metadata) CreateSampleGraph()
{
var nodeMetadata = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
nodeMetadata["wrapper.category"] = "user-switch";
nodeMetadata["wrapper.name"] = "gosu";
var node = new EntryTraceNode(
1,
EntryTraceNodeKind.Command,
"gosu",
ImmutableArray.Create("gosu", "scanner", "python", "/app/main.py"),
EntryTraceInterpreterKind.None,
new EntryTraceEvidence("/usr/bin/gosu", "sha256:layer-a", "path", ImmutableDictionary<string, string>.Empty),
new EntryTraceSpan("/scripts/entrypoint.sh", 1, 0, 1, 10),
nodeMetadata.ToImmutable());
var edge = new EntryTraceEdge(1, 2, "wraps", null);
var plan = new EntryTracePlan(
ImmutableArray.Create("/app/main.py", "--serve"),
ImmutableDictionary<string, string>.Empty,
"/app",
"scanner",
"/app/main.py",
EntryTraceTerminalType.Native,
"python",
87.5,
ImmutableDictionary<string, string>.Empty);
var terminal = new EntryTraceTerminal(
"/app/main.py",
EntryTraceTerminalType.Native,
"python",
87.5,
ImmutableDictionary<string, string>.Empty,
"scanner",
"/app",
ImmutableArray.Create("/app/main.py", "--serve"));
var diagnostic = new EntryTraceDiagnostic(
EntryTraceDiagnosticSeverity.Warning,
EntryTraceUnknownReason.DynamicEvaluation,
"Command 'eval' prevents static resolution.",
new EntryTraceSpan("/scripts/entrypoint.sh", 5, 0, 5, 30),
"/scripts/entrypoint.sh");
var graph = new EntryTraceGraph(
EntryTraceOutcome.Resolved,
ImmutableArray.Create(node),
ImmutableArray.Create(edge),
ImmutableArray.Create(diagnostic),
ImmutableArray.Create(plan),
ImmutableArray.Create(terminal));
var metadata = new EntryTraceNdjsonMetadata(
"scan-entrytrace-1",
"sha256:image",
DateTimeOffset.Parse("2025-01-01T00:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal),
Source: "worker");
return (graph, metadata);
}
private static JsonElement Parse(string ndjsonLine)
{
Assert.EndsWith("\n", ndjsonLine, StringComparison.Ordinal);
var json = ndjsonLine.TrimEnd('\n');
using var document = JsonDocument.Parse(json);
return document.RootElement.Clone();
}
}

View File

@@ -1,8 +1,9 @@
using System;
using System.Formats.Tar;
using System.IO;
using System.Text;
using Xunit;
using System;
using System.Formats.Tar;
using System.IO;
using System.Text;
using StellaOps.Scanner.EntryTrace.FileSystem;
using Xunit;
namespace StellaOps.Scanner.EntryTrace.Tests;
@@ -17,15 +18,21 @@ public sealed class LayeredRootFileSystemTests : IDisposable
}
[Fact]
public void FromDirectories_HandlesWhiteoutsAndResolution()
{
var layer1 = CreateLayerDirectory("layer1");
var layer2 = CreateLayerDirectory("layer2");
var usrBin1 = Path.Combine(layer1, "usr", "bin");
Directory.CreateDirectory(usrBin1);
var entrypointPath = Path.Combine(usrBin1, "entrypoint.sh");
File.WriteAllText(entrypointPath, "#!/bin/sh\necho layer1\n");
public void FromDirectories_HandlesWhiteoutsAndResolution()
{
var layer1 = CreateLayerDirectory("layer1");
var layer2 = CreateLayerDirectory("layer2");
var usrBin1 = Path.Combine(layer1, "usr", "bin");
Directory.CreateDirectory(usrBin1);
var entrypointPath = Path.Combine(usrBin1, "entrypoint.sh");
File.WriteAllText(entrypointPath, "#!/bin/sh\necho layer1\n");
#if NET8_0_OR_GREATER
File.SetUnixFileMode(entrypointPath,
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
#endif
var optDirectory1 = Path.Combine(layer1, "opt");
Directory.CreateDirectory(optDirectory1);
@@ -52,9 +59,28 @@ public sealed class LayeredRootFileSystemTests : IDisposable
Assert.False(fs.TryReadAllText("/opt/setup.sh", out _, out _));
var optEntries = fs.EnumerateDirectory("/opt");
Assert.DoesNotContain(optEntries, entry => entry.Path.EndsWith("setup.sh", StringComparison.Ordinal));
}
Assert.DoesNotContain(optEntries, entry => entry.Path.EndsWith("setup.sh", StringComparison.Ordinal));
}
[Fact]
public void TryReadBytes_ReturnsLimitedPreview()
{
var layer = CreateLayerDirectory("layer-bytes");
var usrBin = Path.Combine(layer, "usr", "bin");
Directory.CreateDirectory(usrBin);
File.WriteAllText(Path.Combine(usrBin, "tool"), "abcdefg");
var fs = LayeredRootFileSystem.FromDirectories(new[]
{
new LayeredRootFileSystem.LayerDirectory("sha256:bytes", layer)
});
Assert.True(fs.TryReadBytes("/usr/bin/tool", 4, out var descriptor, out var preview));
Assert.Equal("/usr/bin/tool", descriptor.Path);
Assert.Equal(4, preview.Length);
Assert.Equal("abcd", Encoding.UTF8.GetString(preview.Span));
}
[Fact]
public void FromArchives_ResolvesSymlinkAndWhiteout()
{

View File

@@ -0,0 +1,121 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using StellaOps.Scanner.EntryTrace;
using StellaOps.Scanner.EntryTrace.Runtime;
using Xunit;
namespace StellaOps.Scanner.EntryTrace.Tests.Runtime;
public sealed class EntryTraceRuntimeReconcilerTests
{
private static readonly ImmutableDictionary<string, string> EmptyDictionary = ImmutableDictionary<string, string>.Empty;
private static readonly ImmutableArray<string> EmptyArray = ImmutableArray<string>.Empty;
[Fact]
public void Reconcile_MatchesRuntimeTerminal()
{
var reconciler = new EntryTraceRuntimeReconciler();
var graph = CreateGraph("/usr/local/bin/app");
var procGraph = ProcGraphBuilder.Build(new FakeProvider(new[]
{
CreateProcess(1, 0, "/sbin/tini", "tini", 100),
CreateProcess(5, 1, "/usr/local/bin/app", "app", 200),
}));
var reconciled = reconciler.Reconcile(graph, procGraph);
Assert.Equal(95d, reconciled.Plans[0].Confidence);
Assert.Contains(reconciled.Diagnostics, d => d.Reason == EntryTraceUnknownReason.RuntimeMatch);
}
[Fact]
public void Reconcile_FlagsMismatch_WhenDifferentExecutable()
{
var reconciler = new EntryTraceRuntimeReconciler();
var graph = CreateGraph("/usr/local/bin/app");
var procGraph = ProcGraphBuilder.Build(new FakeProvider(new[]
{
CreateProcess(1, 0, "/sbin/init", "init", 100),
CreateProcess(2, 1, "/usr/bin/other", "other", 200),
}));
var reconciled = reconciler.Reconcile(graph, procGraph);
Assert.Equal(60d, reconciled.Plans[0].Confidence);
Assert.Contains(reconciled.Diagnostics, d => d.Reason == EntryTraceUnknownReason.RuntimeMismatch);
}
[Fact]
public void Reconcile_AddsDiagnostic_WhenSnapshotAbsent()
{
var reconciler = new EntryTraceRuntimeReconciler();
var graph = CreateGraph("/usr/local/bin/app");
var reconciled = reconciler.Reconcile(graph, procGraph: null);
Assert.Contains(reconciled.Diagnostics, d => d.Reason == EntryTraceUnknownReason.RuntimeSnapshotUnavailable);
}
private static EntryTraceGraph CreateGraph(string terminalPath)
{
var plan = new EntryTracePlan(
ImmutableArray.Create(terminalPath),
EmptyDictionary,
"/",
"root",
terminalPath,
EntryTraceTerminalType.Native,
Runtime: null,
Confidence: 50d,
EmptyDictionary);
var terminal = new EntryTraceTerminal(
terminalPath,
EntryTraceTerminalType.Native,
Runtime: null,
Confidence: 50d,
EmptyDictionary,
"root",
"/",
ImmutableArray<string>.Empty);
return new EntryTraceGraph(
EntryTraceOutcome.Resolved,
ImmutableArray<EntryTraceNode>.Empty,
ImmutableArray<EntryTraceEdge>.Empty,
ImmutableArray<EntryTraceDiagnostic>.Empty,
ImmutableArray.Create(plan),
ImmutableArray.Create(terminal));
}
private static ProcProcess CreateProcess(int pid, int parentPid, string executable, string commandName, ulong startTime)
{
return new ProcProcess(
pid,
parentPid,
executable,
ImmutableArray.Create(executable),
commandName,
startTime);
}
private sealed class FakeProvider : IProcSnapshotProvider
{
private readonly Dictionary<int, ProcProcess> _processes;
public FakeProvider(IEnumerable<ProcProcess> processes)
{
_processes = new Dictionary<int, ProcProcess>();
foreach (var process in processes)
{
_processes[process.Pid] = process;
}
}
public IEnumerable<int> EnumerateProcessIds() => _processes.Keys;
public bool TryReadProcess(int pid, out ProcProcess process) => _processes.TryGetValue(pid, out process);
}
}

View File

@@ -0,0 +1,87 @@
using System;
using System.IO;
using System.Text;
using StellaOps.Scanner.EntryTrace.Runtime;
using Xunit;
namespace StellaOps.Scanner.EntryTrace.Tests.Runtime;
public sealed class ProcFileSystemSnapshotTests : IDisposable
{
private readonly TempDirectory _tempDirectory = new();
[Fact]
public void EnumerateProcessIds_ReturnsNumericDirectories()
{
CreateProcessDirectory(101);
CreateProcessDirectory(5);
Directory.CreateDirectory(Path.Combine(_tempDirectory.Path, "not-a-pid"));
var snapshot = new ProcFileSystemSnapshot(_tempDirectory.Path);
var pids = snapshot.EnumerateProcessIds();
Assert.Contains(5, pids);
Assert.Contains(101, pids);
Assert.DoesNotContain(-1, pids);
}
[Fact]
public void TryReadProcess_ParsesStatAndCmdline()
{
var pid = 321;
var procPath = CreateProcessDirectory(pid);
File.WriteAllText(
Path.Combine(procPath, "stat"),
$"{pid} (bash) S 1 {pid} {pid} 0 -1 4194560 0 0 0 0 0 0 0 20 0 1 0 600 0 0 0 0 0 0 0 0 0 0 0 0");
File.WriteAllBytes(
Path.Combine(procPath, "cmdline"),
Encoding.UTF8.GetBytes("/bin/bash\0-c\0run.sh\0"));
var snapshot = new ProcFileSystemSnapshot(_tempDirectory.Path);
Assert.True(snapshot.TryReadProcess(pid, out var process));
Assert.Equal(1, process.ParentPid);
Assert.Equal("/bin/bash", process.CommandLine[0]);
Assert.Equal("bash", process.CommandName);
Assert.Equal((ulong)600, process.StartTimeTicks);
}
private string CreateProcessDirectory(int pid)
{
var procPath = Path.Combine(_tempDirectory.Path, pid.ToString());
Directory.CreateDirectory(procPath);
File.WriteAllText(Path.Combine(procPath, "stat"), $"{pid} (init) S 0 0 0 0 -1 4194560 0 0 0 0 0 0 0 20 0 1 0 100 0 0 0 0 0 0 0 0 0 0 0 0");
File.WriteAllBytes(Path.Combine(procPath, "cmdline"), Encoding.UTF8.GetBytes($"/proc/{pid}/exe\0"));
return procPath;
}
public void Dispose()
{
_tempDirectory.Dispose();
}
private sealed class TempDirectory : IDisposable
{
public TempDirectory()
{
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"entrytrace-proc-{Guid.NewGuid():n}");
Directory.CreateDirectory(Path);
}
public string Path { get; }
public void Dispose()
{
try
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
catch
{
// ignore cleanup errors for temp directory
}
}
}
}

View File

@@ -0,0 +1,57 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using StellaOps.Scanner.EntryTrace.Runtime;
using Xunit;
namespace StellaOps.Scanner.EntryTrace.Tests.Runtime;
public sealed class ProcGraphBuilderTests
{
[Fact]
public void Build_ReturnsGraph_WithOrderedChildren()
{
var processes = new[]
{
CreateProcess(1, 0, "/sbin/init", "init", startTime: 100),
CreateProcess(20, 1, "/usr/bin/httpd", "httpd", startTime: 400),
CreateProcess(10, 1, "/usr/local/bin/app", "app", startTime: 300)
};
var graph = ProcGraphBuilder.Build(new FakeProcSnapshotProvider(processes));
Assert.NotNull(graph);
Assert.Equal(1, graph!.RootPid);
Assert.Equal(3, graph.Processes.Count);
Assert.True(graph.Children.TryGetValue(1, out var children));
Assert.Equal(new[] { 10, 20 }, children);
}
private static ProcProcess CreateProcess(int pid, int parentPid, string executable, string commandName, ulong startTime)
{
return new ProcProcess(
pid,
parentPid,
executable,
ImmutableArray.Create(executable),
commandName,
startTime);
}
private sealed class FakeProcSnapshotProvider : IProcSnapshotProvider
{
private readonly Dictionary<int, ProcProcess> _processes;
public FakeProcSnapshotProvider(IEnumerable<ProcProcess> processes)
{
_processes = new Dictionary<int, ProcProcess>();
foreach (var process in processes)
{
_processes[process.Pid] = process;
}
}
public IEnumerable<int> EnumerateProcessIds() => _processes.Keys;
public bool TryReadProcess(int pid, out ProcProcess process) => _processes.TryGetValue(pid, out process);
}
}

View File

@@ -1,7 +1,8 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using StellaOps.Scanner.EntryTrace;
using System.IO;
using System.Text;
using StellaOps.Scanner.EntryTrace.FileSystem;
namespace StellaOps.Scanner.EntryTrace.Tests;
@@ -15,22 +16,35 @@ internal sealed class TestRootFileSystem : IRootFileSystem
_directories.Add("/");
}
public void AddFile(string path, string content, bool executable = true, string? layer = "sha256:layer-a")
{
var normalized = Normalize(path);
var directory = Path.GetDirectoryName(normalized);
if (!string.IsNullOrEmpty(directory))
{
_directories.Add(directory!);
}
_entries[normalized] = new FileEntry(normalized, content, executable, layer, IsDirectory: false);
}
public void AddFile(string path, string content, bool executable = true, string? layer = "sha256:layer-a")
{
var normalized = Normalize(path);
var directory = Path.GetDirectoryName(normalized);
if (!string.IsNullOrEmpty(directory))
{
EnsureDirectoryChain(directory!);
}
var bytes = Encoding.UTF8.GetBytes(content);
_entries[normalized] = FileEntry.Create(normalized, bytes, content, executable, layer, isDirectory: false);
}
public void AddBinaryFile(string path, byte[] content, bool executable = true, string? layer = "sha256:layer-a")
{
var normalized = Normalize(path);
var directory = Path.GetDirectoryName(normalized);
if (!string.IsNullOrEmpty(directory))
{
EnsureDirectoryChain(directory!);
}
_entries[normalized] = FileEntry.Create(normalized, content, text: null, executable, layer, isDirectory: false);
}
public void AddDirectory(string path)
{
var normalized = Normalize(path);
_directories.Add(normalized);
var normalized = Normalize(path);
EnsureDirectoryChain(normalized);
}
public bool TryResolveExecutable(string name, IReadOnlyList<string> searchPaths, out RootFileDescriptor descriptor)
@@ -62,37 +76,66 @@ internal sealed class TestRootFileSystem : IRootFileSystem
return false;
}
public bool TryReadAllText(string path, out RootFileDescriptor descriptor, out string content)
{
var normalized = Normalize(path);
if (_entries.TryGetValue(normalized, out var file))
{
descriptor = file.ToDescriptor();
content = file.Content;
return true;
}
public bool TryReadAllText(string path, out RootFileDescriptor descriptor, out string content)
{
var normalized = Normalize(path);
if (_entries.TryGetValue(normalized, out var file) && file.TryReadText(out content))
{
descriptor = file.ToDescriptor();
return true;
}
descriptor = null!;
content = string.Empty;
return false;
}
public bool TryReadBytes(string path, int maxBytes, out RootFileDescriptor descriptor, out ReadOnlyMemory<byte> content)
{
var normalized = Normalize(path);
if (_entries.TryGetValue(normalized, out var file) && file.TryReadBytes(maxBytes, out content))
{
descriptor = file.ToDescriptor();
return true;
}
descriptor = null!;
content = default;
return false;
}
descriptor = null!;
content = string.Empty;
return false;
}
public ImmutableArray<RootFileDescriptor> EnumerateDirectory(string path)
{
var normalized = Normalize(path);
var builder = ImmutableArray.CreateBuilder<RootFileDescriptor>();
foreach (var file in _entries.Values)
{
var directory = Normalize(Path.GetDirectoryName(file.Path) ?? "/");
if (string.Equals(directory, normalized, StringComparison.Ordinal))
{
builder.Add(file.ToDescriptor());
}
}
return builder.ToImmutable();
}
public ImmutableArray<RootFileDescriptor> EnumerateDirectory(string path)
{
var normalized = Normalize(path);
var entries = new List<RootFileDescriptor>();
foreach (var directory in _directories)
{
if (string.Equals(directory, normalized, StringComparison.Ordinal) ||
string.Equals(directory, "/", StringComparison.Ordinal))
{
continue;
}
var parent = Normalize(Path.GetDirectoryName(directory) ?? "/");
if (string.Equals(parent, normalized, StringComparison.Ordinal))
{
entries.Add(new RootFileDescriptor(directory, null, false, true, null));
}
}
foreach (var file in _entries.Values)
{
var directory = Normalize(Path.GetDirectoryName(file.Path) ?? "/");
if (string.Equals(directory, normalized, StringComparison.Ordinal))
{
entries.Add(file.ToDescriptor());
}
}
entries.Sort(static (left, right) => string.CompareOrdinal(left.Path, right.Path));
return entries.ToImmutableArray();
}
public bool DirectoryExists(string path)
{
@@ -100,7 +143,7 @@ internal sealed class TestRootFileSystem : IRootFileSystem
return _directories.Contains(normalized);
}
private static string Combine(string prefix, string name)
private static string Combine(string prefix, string name)
{
var normalizedPrefix = Normalize(prefix);
if (normalizedPrefix == "/")
@@ -111,7 +154,7 @@ internal sealed class TestRootFileSystem : IRootFileSystem
return Normalize($"{normalizedPrefix}/{name}");
}
private static string Normalize(string path)
private static string Normalize(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
@@ -144,37 +187,117 @@ internal sealed class TestRootFileSystem : IRootFileSystem
parts.Add(part);
}
return "/" + string.Join('/', parts);
}
return "/" + string.Join('/', parts);
}
private void EnsureDirectoryChain(string path)
{
var normalized = Normalize(path);
if (string.Equals(normalized, "/", StringComparison.Ordinal))
{
_directories.Add(normalized);
return;
}
var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries);
var current = "/";
foreach (var segment in segments)
{
current = current == "/" ? $"/{segment}" : $"{current}/{segment}";
_directories.Add(current);
}
}
private sealed record FileEntry(string Path, string Content, bool IsExecutable, string? Layer, bool IsDirectory)
{
public RootFileDescriptor ToDescriptor()
{
var shebang = ExtractShebang(Content);
return new RootFileDescriptor(Path, Layer, IsExecutable, IsDirectory, shebang);
}
}
private static string? ExtractShebang(string content)
{
if (string.IsNullOrEmpty(content))
{
return null;
}
using var reader = new StringReader(content);
var firstLine = reader.ReadLine();
if (firstLine is null)
{
return null;
}
if (!firstLine.StartsWith("#!", StringComparison.Ordinal))
{
return null;
}
return firstLine[2..].Trim();
}
private sealed class FileEntry
{
private readonly byte[] _content;
private readonly string? _text;
private FileEntry(string path, byte[] content, string? text, bool isExecutable, string? layer, bool isDirectory)
{
Path = path;
_content = content;
_text = text;
IsExecutable = isExecutable;
Layer = layer;
IsDirectory = isDirectory;
}
public string Path { get; }
public bool IsExecutable { get; }
public string? Layer { get; }
public bool IsDirectory { get; }
public static FileEntry Create(string path, byte[] content, string? text, bool isExecutable, string? layer, bool isDirectory)
=> new(path, content, text, isExecutable, layer, isDirectory);
public RootFileDescriptor ToDescriptor()
{
var shebang = ExtractShebang(_text, _content);
return new RootFileDescriptor(Path, Layer, IsExecutable, IsDirectory, shebang);
}
public bool TryReadText(out string content)
{
if (IsDirectory || _text is null)
{
content = string.Empty;
return false;
}
content = _text;
return true;
}
public bool TryReadBytes(int maxBytes, out ReadOnlyMemory<byte> content)
{
if (IsDirectory)
{
content = default;
return false;
}
var length = Math.Min(maxBytes, _content.Length);
content = new ReadOnlyMemory<byte>(_content, 0, length);
return true;
}
}
private static string? ExtractShebang(string? textContent, byte[] binaryContent)
{
if (!string.IsNullOrEmpty(textContent))
{
using var reader = new StringReader(textContent);
var firstLine = reader.ReadLine();
if (firstLine is not null && firstLine.StartsWith("#!", StringComparison.Ordinal))
{
return firstLine[2..].Trim();
}
}
if (binaryContent.Length >= 2 && binaryContent[0] == '#' && binaryContent[1] == '!')
{
var end = Array.IndexOf(binaryContent, (byte)'\n');
if (end < 0)
{
end = binaryContent.Length;
}
var shebangLength = Math.Max(0, end - 2);
while (shebangLength > 0 && (binaryContent[2 + shebangLength - 1] == '\r' || binaryContent[2 + shebangLength - 1] == 0))
{
shebangLength--;
}
if (shebangLength > 0)
{
return Encoding.UTF8.GetString(binaryContent, 2, shebangLength).Trim();
}
}
return null;
}
}