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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user