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

@@ -0,0 +1,25 @@
[
{
"analyzerId": "rust",
"componentKey": "bin::sha256:10f3c03766e4403be40add0467a2b2d07fd7006e4b8515ab88740ffa327ea775",
"purl": null,
"name": "opaque_bin",
"version": null,
"type": "bin",
"usedByEntrypoint": true,
"metadata": {
"binary.path": "usr/local/bin/opaque_bin",
"binary.sha256": "10f3c03766e4403be40add0467a2b2d07fd7006e4b8515ab88740ffa327ea775",
"provenance": "binary"
},
"evidence": [
{
"kind": "file",
"source": "binary",
"locator": "usr/local/bin/opaque_bin",
"value": null,
"sha256": "10f3c03766e4403be40add0467a2b2d07fd7006e4b8515ab88740ffa327ea775"
}
]
}
]

View File

@@ -0,0 +1,8 @@
{
"detectedCrates": [
{
"name": "serde",
"note": "Binary symbol scan matched only serde"
}
]
}

View File

@@ -0,0 +1,68 @@
[
{
"analyzerId": "rust",
"componentKey": "rust::heuristic::reqwest::usr/local/bin/heuristic_app",
"name": "reqwest",
"type": "cargo",
"usedByEntrypoint": true,
"metadata": {
"binary.paths": "usr/local/bin/heuristic_app",
"binary.sha256": "4caf60c501a594b5d4b8d909b3e91fccc4447692b9e144f322a333255909310b",
"crate": "reqwest",
"provenance": "heuristic"
},
"evidence": [
{
"kind": "derived",
"source": "rust.heuristic",
"locator": "usr/local/bin/heuristic_app",
"value": "reqwest",
"sha256": "4caf60c501a594b5d4b8d909b3e91fccc4447692b9e144f322a333255909310b"
}
]
},
{
"analyzerId": "rust",
"componentKey": "rust::heuristic::serde::usr/local/bin/heuristic_app",
"name": "serde",
"type": "cargo",
"usedByEntrypoint": true,
"metadata": {
"binary.paths": "usr/local/bin/heuristic_app",
"binary.sha256": "4caf60c501a594b5d4b8d909b3e91fccc4447692b9e144f322a333255909310b",
"crate": "serde",
"provenance": "heuristic"
},
"evidence": [
{
"kind": "derived",
"source": "rust.heuristic",
"locator": "usr/local/bin/heuristic_app",
"value": "serde",
"sha256": "4caf60c501a594b5d4b8d909b3e91fccc4447692b9e144f322a333255909310b"
}
]
},
{
"analyzerId": "rust",
"componentKey": "rust::heuristic::tokio::usr/local/bin/heuristic_app",
"name": "tokio",
"type": "cargo",
"usedByEntrypoint": true,
"metadata": {
"binary.paths": "usr/local/bin/heuristic_app",
"binary.sha256": "4caf60c501a594b5d4b8d909b3e91fccc4447692b9e144f322a333255909310b",
"crate": "tokio",
"provenance": "heuristic"
},
"evidence": [
{
"kind": "derived",
"source": "rust.heuristic",
"locator": "usr/local/bin/heuristic_app",
"value": "tokio",
"sha256": "4caf60c501a594b5d4b8d909b3e91fccc4447692b9e144f322a333255909310b"
}
]
}
]

View File

@@ -0,0 +1,77 @@
using System.Linq;
using System.Text.Json;
using StellaOps.Scanner.Analyzers.Lang.Rust;
using StellaOps.Scanner.Analyzers.Lang.Tests.Harness;
using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities;
namespace StellaOps.Scanner.Analyzers.Lang.Tests.Rust;
public sealed class RustHeuristicCoverageComparisonTests
{
[Fact]
public async Task HeuristicCoverageExceedsCompetitorBaselineAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var fixturePath = TestPaths.ResolveFixture("lang", "rust", "heuristics");
var baselinePath = Path.Combine(fixturePath, "competitor-baseline.json");
var analyzers = new ILanguageAnalyzer[]
{
new RustLanguageAnalyzer()
};
var output = await LanguageAnalyzerTestHarness.RunToJsonAsync(
fixturePath,
analyzers,
cancellationToken);
using var ours = JsonDocument.Parse(output);
var heuristicNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var element in ours.RootElement.EnumerateArray())
{
if (!element.TryGetProperty("metadata", out var metadata) || metadata.ValueKind != JsonValueKind.Object)
{
continue;
}
var provenance = metadata.EnumerateObject()
.FirstOrDefault(p => string.Equals(p.Name, "provenance", StringComparison.OrdinalIgnoreCase));
if (provenance.Value.ValueKind == JsonValueKind.String &&
string.Equals(provenance.Value.GetString(), "heuristic", StringComparison.OrdinalIgnoreCase))
{
if (element.TryGetProperty("name", out var nameProperty) && nameProperty.ValueKind == JsonValueKind.String)
{
var value = nameProperty.GetString();
if (!string.IsNullOrWhiteSpace(value))
{
heuristicNames.Add(value);
}
}
}
}
using var competitor = JsonDocument.Parse(await File.ReadAllTextAsync(baselinePath, cancellationToken));
var competitorNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (competitor.RootElement.TryGetProperty("detectedCrates", out var detectedCrates) && detectedCrates.ValueKind == JsonValueKind.Array)
{
foreach (var entry in detectedCrates.EnumerateArray())
{
if (entry.ValueKind == JsonValueKind.Object && entry.TryGetProperty("name", out var nameProperty) && nameProperty.ValueKind == JsonValueKind.String)
{
var name = nameProperty.GetString();
if (!string.IsNullOrWhiteSpace(name))
{
competitorNames.Add(name);
}
}
}
}
Assert.NotEmpty(competitorNames);
Assert.True(heuristicNames.IsSupersetOf(competitorNames));
var improvement = (double)heuristicNames.Count / competitorNames.Count;
Assert.True(improvement >= 1.15, $"Expected at least 15% improvement; got {improvement:P2} ({heuristicNames.Count} vs {competitorNames.Count}).");
}
}

View File

@@ -1,7 +1,8 @@
using System;
using System.IO;
using System.Linq;
using StellaOps.Scanner.Analyzers.Lang.Rust;
using System.Text.Json.Nodes;
using StellaOps.Scanner.Analyzers.Lang.Rust;
using StellaOps.Scanner.Analyzers.Lang.Tests.Harness;
using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities;
@@ -35,25 +36,86 @@ public sealed class RustLanguageAnalyzerTests
}
[Fact]
public async Task AnalyzerIsThreadSafeUnderConcurrencyAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var fixturePath = TestPaths.ResolveFixture("lang", "rust", "simple");
var analyzers = new ILanguageAnalyzer[]
{
new RustLanguageAnalyzer()
};
var workers = Math.Max(Environment.ProcessorCount, 4);
var tasks = Enumerable.Range(0, workers)
.Select(_ => LanguageAnalyzerTestHarness.RunToJsonAsync(fixturePath, analyzers, cancellationToken));
var results = await Task.WhenAll(tasks);
var baseline = results[0];
foreach (var result in results)
{
Assert.Equal(baseline, result);
}
}
}
public async Task AnalyzerIsThreadSafeUnderConcurrencyAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var fixturePath = TestPaths.ResolveFixture("lang", "rust", "simple");
var analyzers = new ILanguageAnalyzer[]
{
new RustLanguageAnalyzer()
};
var workers = Math.Max(Environment.ProcessorCount, 4);
var tasks = Enumerable.Range(0, workers)
.Select(_ => LanguageAnalyzerTestHarness.RunToJsonAsync(fixturePath, analyzers, cancellationToken));
var results = await Task.WhenAll(tasks);
var baseline = results[0];
foreach (var result in results)
{
Assert.Equal(baseline, result);
}
}
[Fact]
public async Task HeuristicFixtureProducesExpectedOutputAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var fixturePath = TestPaths.ResolveFixture("lang", "rust", "heuristics");
var goldenPath = Path.Combine(fixturePath, "expected.json");
var usageHints = new LanguageUsageHints(new[]
{
Path.Combine(fixturePath, "usr/local/bin/heuristic_app")
});
var analyzers = new ILanguageAnalyzer[]
{
new RustLanguageAnalyzer()
};
await LanguageAnalyzerTestHarness.AssertDeterministicAsync(
fixturePath,
goldenPath,
analyzers,
cancellationToken,
usageHints);
}
[Fact]
public async Task FallbackFixtureProducesExpectedOutputAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var fixturePath = TestPaths.ResolveFixture("lang", "rust", "fallback");
var goldenPath = Path.Combine(fixturePath, "expected.json");
var usageHints = new LanguageUsageHints(new[]
{
Path.Combine(fixturePath, "usr/local/bin/opaque_bin")
});
var analyzers = new ILanguageAnalyzer[]
{
new RustLanguageAnalyzer()
};
var actualJson = await LanguageAnalyzerTestHarness.RunToJsonAsync(
fixturePath,
analyzers,
cancellationToken,
usageHints);
var repeat = await LanguageAnalyzerTestHarness.RunToJsonAsync(
fixturePath,
analyzers,
cancellationToken,
usageHints);
Assert.Equal(actualJson, repeat);
var expectedJson = await File.ReadAllTextAsync(goldenPath, cancellationToken);
var actualNode = JsonNode.Parse(actualJson);
var expectedNode = JsonNode.Parse(expectedJson);
Assert.True(
JsonNode.DeepEquals(expectedNode, actualNode),
"Fallback fixture output does not match expected snapshot.");
}
}

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

View File

@@ -0,0 +1,139 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.EntryTrace;
using StellaOps.Scanner.EntryTrace.Serialization;
using StellaOps.Scanner.Storage;
using StellaOps.Scanner.Storage.Mongo;
using StellaOps.Scanner.Storage.Repositories;
using StellaOps.Scanner.Storage.Services;
using Xunit;
using MongoDB.Driver;
namespace StellaOps.Scanner.Storage.Tests;
public sealed class EntryTraceResultStoreTests : IClassFixture<ScannerMongoFixture>
{
private readonly ScannerMongoFixture _fixture;
public EntryTraceResultStoreTests(ScannerMongoFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task StoreAsync_ThrowsWhenResultNull()
{
var store = CreateStore();
await Assert.ThrowsAsync<ArgumentNullException>(async () =>
{
EntryTraceResult? result = null;
await store.StoreAsync(result!, CancellationToken.None);
});
}
[Fact]
public async Task GetAsync_ReturnsNullWhenMissing()
{
await ClearCollectionAsync();
var store = CreateStore();
var result = await store.GetAsync("scan-missing", CancellationToken.None);
Assert.Null(result);
}
[Fact]
public async Task StoreAsync_RoundTripsResult()
{
await ClearCollectionAsync();
var store = CreateStore();
var scanId = $"scan-{Guid.NewGuid():n}";
var generatedAt = new DateTimeOffset(2025, 11, 2, 10, 30, 0, TimeSpan.Zero);
var node = new EntryTraceNode(
1,
EntryTraceNodeKind.Command,
"python",
ImmutableArray.Create("python", "/app/main.py"),
EntryTraceInterpreterKind.None,
new EntryTraceEvidence("/usr/bin/python", "sha256:layer-a", "path", ImmutableDictionary<string, string>.Empty),
new EntryTraceSpan("/app/start.sh", 1, 0, 1, 14),
ImmutableDictionary<string, string>.Empty);
var plan = new EntryTracePlan(
ImmutableArray.Create("/app/main.py"),
ImmutableDictionary<string, string>.Empty,
"/workspace",
"scanner",
"/app/main.py",
EntryTraceTerminalType.Native,
"python",
0.95,
ImmutableDictionary<string, string>.Empty);
var terminal = new EntryTraceTerminal(
"/app/main.py",
EntryTraceTerminalType.Native,
"python",
0.95,
ImmutableDictionary<string, string>.Empty,
"scanner",
"/workspace",
ImmutableArray.Create("/app/main.py"));
var graph = new EntryTraceGraph(
EntryTraceOutcome.Resolved,
ImmutableArray.Create(node),
ImmutableArray<EntryTraceEdge>.Empty,
ImmutableArray<EntryTraceDiagnostic>.Empty,
ImmutableArray.Create(plan),
ImmutableArray.Create(terminal));
var ndjson = EntryTraceNdjsonWriter.Serialize(
graph,
new EntryTraceNdjsonMetadata(scanId, "sha256:image", generatedAt, Source: "storage.tests"));
var result = new EntryTraceResult(scanId, "sha256:image", generatedAt, graph, ndjson);
await store.StoreAsync(result, CancellationToken.None);
var stored = await store.GetAsync(scanId, CancellationToken.None);
Assert.NotNull(stored);
Assert.Equal(result.ScanId, stored!.ScanId);
Assert.Equal(result.ImageDigest, stored.ImageDigest);
Assert.Equal(result.GeneratedAtUtc, stored.GeneratedAtUtc);
Assert.Equal(result.Graph, stored.Graph);
Assert.Equal(result.Ndjson, stored.Ndjson);
}
private async Task ClearCollectionAsync()
{
var provider = CreateProvider();
await provider.EntryTrace.DeleteManyAsync(_ => true);
}
private EntryTraceResultStore CreateStore()
{
var provider = CreateProvider();
var repository = new EntryTraceRepository(provider);
return new EntryTraceResultStore(repository);
}
private MongoCollectionProvider CreateProvider()
{
var options = Options.Create(new ScannerStorageOptions
{
Mongo = new MongoOptions
{
ConnectionString = _fixture.Runner.ConnectionString,
DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName,
UseMajorityReadConcern = false,
UseMajorityWriteConcern = false
}
});
return new MongoCollectionProvider(_fixture.Database, options);
}
}

View File

@@ -146,7 +146,7 @@ public sealed class RustFsArtifactObjectStoreTests
}
// Materialize content to ensure downstream callers can inspect it.
_ = await request.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
_ = await request.Content.ReadAsByteArrayAsync(cancellationToken);
}
CapturedRequests.Add(new CapturedRequest(request.Method, request.RequestUri!, headerSnapshot));

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,80 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Surface.Env;
namespace StellaOps.Scanner.Surface.Env.Tests;
public sealed class SurfaceEnvironmentBuilderTests
{
[Fact]
public void Build_UsesDefaults_WhenVariablesMissing()
{
var services = CreateServices();
var environment = SurfaceEnvironmentFactory.Create(services, options =>
{
options.RequireSurfaceEndpoint = false;
});
Assert.Equal("surface-cache", environment.Settings.SurfaceFsBucket);
Assert.Equal(4096, environment.Settings.CacheQuotaMegabytes);
Assert.False(environment.Settings.PrefetchEnabled);
Assert.NotNull(environment.Settings.CacheRoot);
Assert.True(environment.Settings.CacheRoot.Exists);
}
[Fact]
public void Build_ReadsEnvironmentVariables_WithPrefixes()
{
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_BUCKET", "custom-bucket");
Environment.SetEnvironmentVariable("SCANNER_SURFACE_CACHE_QUOTA_MB", "512");
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", "https://surface.example.test");
try
{
var services = CreateServices();
var environment = SurfaceEnvironmentFactory.Create(services);
Assert.Equal("custom-bucket", environment.Settings.SurfaceFsBucket);
Assert.Equal(512, environment.Settings.CacheQuotaMegabytes);
}
finally
{
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_BUCKET", null);
Environment.SetEnvironmentVariable("SCANNER_SURFACE_CACHE_QUOTA_MB", null);
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", null);
}
}
[Fact]
public void Build_Throws_WhenIntegerOutOfRange()
{
Environment.SetEnvironmentVariable("SCANNER_SURFACE_CACHE_QUOTA_MB", "1");
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", "https://surface.example.test");
try
{
var services = CreateServices();
var exception = Assert.Throws<SurfaceEnvironmentException>(() => SurfaceEnvironmentFactory.Create(services));
Assert.Equal("SURFACE_CACHE_QUOTA_MB", exception.Variable);
}
finally
{
Environment.SetEnvironmentVariable("SCANNER_SURFACE_CACHE_QUOTA_MB", null);
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", null);
}
}
private static IServiceProvider CreateServices(Action<IServiceCollection>? configure = null)
{
var services = new ServiceCollection();
var configuration = new ConfigurationBuilder().Build();
services.AddSingleton<IConfiguration>(configuration);
services.AddLogging(builder => builder.ClearProviders());
configure?.Invoke(services);
return services.BuildServiceProvider();
}
}

View File

@@ -0,0 +1,43 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Surface.Env;
namespace StellaOps.Scanner.Surface.Env.Tests;
public sealed class SurfaceEnvironmentFeatureFlagTests
{
[Fact]
public void Build_ReturnsFlags_LowerCased()
{
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FEATURES", "Validation,PreWarm , unknown");
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", "https://surface.example.test");
try
{
var services = CreateServices();
var environment = SurfaceEnvironmentFactory.Create(services, options =>
{
options.KnownFeatureFlags.Add("validation");
options.KnownFeatureFlags.Add("prewarm");
options.RequireSurfaceEndpoint = true;
});
Assert.Contains("validation", environment.Settings.FeatureFlags);
Assert.Contains("prewarm", environment.Settings.FeatureFlags);
Assert.Contains("unknown", environment.Settings.FeatureFlags);
}
finally
{
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FEATURES", null);
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", null);
}
}
private static IServiceProvider CreateServices()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
services.AddLogging(builder => builder.ClearProviders());
return services.BuildServiceProvider();
}
}

View File

@@ -0,0 +1,50 @@
using System;
using System.IO;
using System.Threading.Tasks;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.Surface.FS;
namespace StellaOps.Scanner.Surface.FS.Tests;
public sealed class FileSurfaceCacheTests
{
[Fact]
public async Task GetOrCreateAsync_PersistsValue()
{
var root = Directory.CreateTempSubdirectory();
try
{
var options = Microsoft.Extensions.Options.Options.Create(new SurfaceCacheOptions { RootDirectory = root.FullName });
var cache = new FileSurfaceCache(options, NullLogger<FileSurfaceCache>.Instance);
var key = new SurfaceCacheKey("entrytrace", "tenant", "digest");
var result = await cache.GetOrCreateAsync(
key,
_ => Task.FromResult(42),
Serialize,
Deserialize);
Assert.Equal(42, result);
var cached = await cache.GetOrCreateAsync(
key,
_ => Task.FromResult(99),
Serialize,
Deserialize);
Assert.Equal(42, cached);
}
finally
{
root.Delete(true);
}
static ReadOnlyMemory<byte> Serialize(int value)
=> JsonSerializer.SerializeToUtf8Bytes(value);
static int Deserialize(ReadOnlyMemory<byte> payload)
=> JsonSerializer.Deserialize<int>(payload.Span)!;
}
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Surface.FS/StellaOps.Scanner.Surface.FS.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,47 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.IO;
using System.Text.Json;
using StellaOps.Scanner.Surface.Secrets;
using StellaOps.Scanner.Surface.Secrets.Providers;
namespace StellaOps.Scanner.Surface.Secrets.Tests;
public sealed class FileSurfaceSecretProviderTests
{
[Fact]
public async Task GetAsync_ReturnsSecret_FromJson()
{
var rootDirectory = Directory.CreateTempSubdirectory();
var root = rootDirectory.FullName;
var request = new SurfaceSecretRequest("tenant", "component", "registry");
var path = Path.Combine(root, request.Tenant, request.Component, request.SecretType);
Directory.CreateDirectory(path);
var payloadPath = Path.Combine(path, "default.json");
await File.WriteAllTextAsync(payloadPath, JsonSerializer.Serialize(new
{
Payload = Convert.ToBase64String(new byte[] { 10, 20, 30 }),
Metadata = new Dictionary<string, string> { ["username"] = "demo" }
}));
try
{
var provider = new FileSurfaceSecretProvider(root);
var handle = await provider.GetAsync(request);
try
{
Assert.Equal(new byte[] { 10, 20, 30 }, handle.AsBytes().ToArray());
Assert.Equal("demo", handle.Metadata["username"]);
}
finally
{
handle.Dispose();
}
}
finally
{
rootDirectory.Delete(true);
}
}
}

View File

@@ -0,0 +1,38 @@
using System;
using System.Threading.Tasks;
using StellaOps.Scanner.Surface.Env;
using StellaOps.Scanner.Surface.Secrets;
using StellaOps.Scanner.Surface.Secrets.Providers;
namespace StellaOps.Scanner.Surface.Secrets.Tests;
public sealed class InlineSurfaceSecretProviderTests
{
[Fact]
public async Task GetAsync_ReturnsSecret_WhenInlineAllowed()
{
var configuration = new SurfaceSecretsConfiguration("inline", "tenant", null, null, null, AllowInline: true);
var provider = new InlineSurfaceSecretProvider(configuration);
var request = new SurfaceSecretRequest("tenant", "component", "registry");
var key = "SURFACE_SECRET_TENANT_COMPONENT_REGISTRY_DEFAULT";
try
{
Environment.SetEnvironmentVariable(key, Convert.ToBase64String(new byte[] { 1, 2, 3 }));
var handle = await provider.GetAsync(request);
Assert.Equal(new byte[] { 1, 2, 3 }, handle.AsBytes().ToArray());
}
finally
{
Environment.SetEnvironmentVariable(key, null);
}
}
[Fact]
public async Task GetAsync_Throws_WhenInlineDisallowed()
{
var configuration = new SurfaceSecretsConfiguration("inline", "tenant", null, null, null, AllowInline: false);
var provider = new InlineSurfaceSecretProvider(configuration);
var request = new SurfaceSecretRequest("tenant", "component", "registry");
await Assert.ThrowsAsync<SurfaceSecretNotFoundException>(async () => await provider.GetAsync(request));
}
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Surface.Secrets/StellaOps.Scanner.Surface.Secrets.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
using System.IO;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Surface.Env;
using StellaOps.Scanner.Surface.Secrets;
namespace StellaOps.Scanner.Surface.Secrets.Tests
{
public sealed class SurfaceSecretsServiceCollectionExtensionsTests
{
[Fact]
public void AddSurfaceSecrets_RegistersProvider()
{
var services = new ServiceCollection();
services.AddSingleton<ISurfaceEnvironment>(_ => new TestSurfaceEnvironment());
services.AddLogging(builder => builder.ClearProviders());
services.AddSurfaceSecrets();
using var provider = services.BuildServiceProvider();
var secretProvider = provider.GetRequiredService<ISurfaceSecretProvider>();
Assert.NotNull(secretProvider);
}
private sealed class TestSurfaceEnvironment : ISurfaceEnvironment
{
public SurfaceEnvironmentSettings Settings { get; }
public IReadOnlyDictionary<string, string> RawVariables { get; }
public TestSurfaceEnvironment()
{
Settings = new SurfaceEnvironmentSettings(
new Uri("https://surface.example"),
"surface",
null,
new DirectoryInfo(Path.GetTempPath()),
1024,
false,
Array.Empty<string>(),
new SurfaceSecretsConfiguration("file", "tenant", Root: Path.GetTempPath(), Namespace: null, FallbackProvider: null, AllowInline: true),
"tenant",
new SurfaceTlsConfiguration(null, null, null))
{
CreatedAtUtc = DateTimeOffset.UtcNow
};
RawVariables = new Dictionary<string, string>();
}
}
}
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Surface.Validation/StellaOps.Scanner.Surface.Validation.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,87 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Surface.Env;
using StellaOps.Scanner.Surface.Validation;
namespace StellaOps.Scanner.Surface.Validation.Tests;
public sealed class SurfaceValidatorRunnerTests
{
[Fact]
public async Task EnsureAsync_Throws_WhenValidationFails()
{
var services = CreateServices(services =>
{
services.Configure<SurfaceValidationOptions>(options =>
{
options.ThrowOnFailure = true;
options.ContinueOnError = false;
});
});
var runner = services.GetRequiredService<ISurfaceValidatorRunner>();
var environment = new SurfaceEnvironmentSettings(
new Uri("https://surface.invalid"),
string.Empty,
null,
new DirectoryInfo(Path.Combine(Path.GetTempPath(), "stellaops-tests", Guid.NewGuid().ToString())),
0,
false,
Array.Empty<string>(),
new SurfaceSecretsConfiguration("kubernetes", "", null, null, null, false),
string.Empty,
new SurfaceTlsConfiguration(null, null, null));
var context = SurfaceValidationContext.Create(services, "TestComponent", environment);
await Assert.ThrowsAsync<SurfaceValidationException>(() => runner.EnsureAsync(context));
}
[Fact]
public async Task RunAllAsync_ReturnsSuccess_ForValidConfiguration()
{
var directory = new DirectoryInfo(Path.Combine(Path.GetTempPath(), "stellaops-tests", Guid.NewGuid().ToString()))
{
Attributes = FileAttributes.Normal
};
var environment = new SurfaceEnvironmentSettings(
new Uri("https://surface.example.com"),
"surface-cache",
null,
directory,
1024,
false,
Array.Empty<string>(),
new SurfaceSecretsConfiguration("kubernetes", "tenant-a", null, "stellaops", null, false),
"tenant-a",
new SurfaceTlsConfiguration(null, null, null));
var services = CreateServices();
var runner = services.GetRequiredService<ISurfaceValidatorRunner>();
var context = SurfaceValidationContext.Create(services, "TestComponent", environment);
var result = await runner.RunAllAsync(context);
Assert.True(result.IsSuccess);
}
private static ServiceProvider CreateServices(Action<IServiceCollection>? configure = null)
{
var services = new ServiceCollection();
services.AddSingleton<ILogger<LoggingSurfaceValidationReporter>>(_ => NullLogger<LoggingSurfaceValidationReporter>.Instance);
services.AddSingleton<ILogger<SurfaceValidatorRunner>>(_ => NullLogger<SurfaceValidatorRunner>.Instance);
services.AddOptions();
services.AddSurfaceValidation();
configure?.Invoke(services);
return services.BuildServiceProvider();
}
}

View File

@@ -1,18 +1,24 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Net;
using System.Net.Http.Json;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using System.Threading;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Domain;
using StellaOps.Scanner.WebService.Services;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Scanner.EntryTrace;
using StellaOps.Scanner.EntryTrace.Serialization;
using StellaOps.Scanner.Storage.Catalog;
using StellaOps.Scanner.Storage.Repositories;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Domain;
using StellaOps.Scanner.WebService.Services;
namespace StellaOps.Scanner.WebService.Tests;
@@ -126,14 +132,74 @@ public sealed class ScansEndpointsTests
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
Assert.NotNull(coordinator);
Assert.True(coordinator.TokenMatched);
Assert.True(coordinator.LastToken.CanBeCanceled);
}
private sealed class RecordingCoordinator : IScanCoordinator
{
private readonly IHttpContextAccessor accessor;
private readonly InMemoryScanCoordinator inner;
Assert.True(coordinator.TokenMatched);
Assert.True(coordinator.LastToken.CanBeCanceled);
}
[Fact]
public async Task EntryTraceEndpointReturnsStoredResult()
{
using var factory = new ScannerApplicationFactory();
var scanId = $"scan-entrytrace-{Guid.NewGuid():n}";
var graph = new EntryTraceGraph(
EntryTraceOutcome.Resolved,
ImmutableArray<EntryTraceNode>.Empty,
ImmutableArray<EntryTraceEdge>.Empty,
ImmutableArray<EntryTraceDiagnostic>.Empty,
ImmutableArray.Create(new EntryTracePlan(
ImmutableArray.Create("/bin/bash", "-lc", "./start.sh"),
ImmutableDictionary<string, string>.Empty,
"/workspace",
"root",
"/bin/bash",
EntryTraceTerminalType.Script,
"bash",
0.9,
ImmutableDictionary<string, string>.Empty)),
ImmutableArray.Create(new EntryTraceTerminal(
"/bin/bash",
EntryTraceTerminalType.Script,
"bash",
0.9,
ImmutableDictionary<string, string>.Empty,
"root",
"/workspace",
ImmutableArray<string>.Empty)));
var ndjson = new List<string> { "{\"kind\":\"entry\"}" };
using (var scope = factory.Services.CreateScope())
{
var repository = scope.ServiceProvider.GetRequiredService<EntryTraceRepository>();
await repository.UpsertAsync(new EntryTraceDocument
{
ScanId = scanId,
ImageDigest = "sha256:entrytrace",
GeneratedAtUtc = DateTime.UtcNow,
GraphJson = EntryTraceGraphSerializer.Serialize(graph),
Ndjson = ndjson
}, CancellationToken.None).ConfigureAwait(false);
}
using var client = factory.CreateClient();
var response = await client.GetAsync($"/api/v1/scans/{scanId}/entrytrace");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<EntryTraceResponse>();
Assert.NotNull(payload);
Assert.Equal(scanId, payload!.ScanId);
Assert.Equal("sha256:entrytrace", payload.ImageDigest);
Assert.Equal(graph.Outcome, payload.Graph.Outcome);
Assert.Single(payload.Graph.Plans);
Assert.Equal("/bin/bash", payload.Graph.Plans[0].TerminalPath);
Assert.Single(payload.Graph.Terminals);
Assert.Equal(ndjson, payload.Ndjson);
}
private sealed class RecordingCoordinator : IScanCoordinator
{
private readonly IHttpContextAccessor accessor;
private readonly InMemoryScanCoordinator inner;
public RecordingCoordinator(IHttpContextAccessor accessor, TimeProvider timeProvider, IScanProgressPublisher publisher)
{
@@ -358,15 +424,111 @@ public sealed class ScansEndpointsTests
Assert.Equal(new[] { "alpha", "Beta", "zeta" }, names);
}
}
[Fact]
public async Task GetEntryTraceReturnsStoredResult()
{
var scanId = $"scan-{Guid.NewGuid():n}";
var generatedAt = new DateTimeOffset(2025, 11, 1, 12, 0, 0, TimeSpan.Zero);
var plan = new EntryTracePlan(
ImmutableArray.Create("/usr/local/bin/app"),
ImmutableDictionary<string, string>.Empty,
"/workspace",
"appuser",
"/usr/local/bin/app",
EntryTraceTerminalType.Native,
"go",
90d,
ImmutableDictionary<string, string>.Empty);
var terminal = new EntryTraceTerminal(
"/usr/local/bin/app",
EntryTraceTerminalType.Native,
"go",
90d,
ImmutableDictionary<string, string>.Empty,
"appuser",
"/workspace",
ImmutableArray<string>.Empty);
var graph = new EntryTraceGraph(
EntryTraceOutcome.Resolved,
ImmutableArray<EntryTraceNode>.Empty,
ImmutableArray<EntryTraceEdge>.Empty,
ImmutableArray<EntryTraceDiagnostic>.Empty,
ImmutableArray.Create(plan),
ImmutableArray.Create(terminal));
var ndjson = EntryTraceNdjsonWriter.Serialize(
graph,
new EntryTraceNdjsonMetadata(scanId, "sha256:test", generatedAt));
var storedResult = new EntryTraceResult(scanId, "sha256:test", generatedAt, graph, ndjson);
using var factory = new ScannerApplicationFactory(
configuration: null,
services =>
{
services.AddSingleton<IEntryTraceResultStore>(new StubEntryTraceResultStore(storedResult));
});
using var client = factory.CreateClient();
var response = await client.GetAsync($"/api/v1/scans/{scanId}/entrytrace");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<EntryTraceResponse>();
Assert.NotNull(payload);
Assert.Equal(storedResult.ScanId, payload!.ScanId);
Assert.Equal(storedResult.ImageDigest, payload.ImageDigest);
Assert.Equal(storedResult.GeneratedAtUtc, payload.GeneratedAt);
Assert.Equal(storedResult.Graph.Plans.Length, payload.Graph.Plans.Length);
Assert.Equal(storedResult.Ndjson, payload.Ndjson);
}
[Fact]
public async Task GetEntryTraceReturnsNotFoundWhenMissing()
{
using var factory = new ScannerApplicationFactory(
configuration: null,
services =>
{
services.AddSingleton<IEntryTraceResultStore>(new StubEntryTraceResultStore(null));
});
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/scans/scan-missing/entrytrace");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private sealed record ProgressEnvelope(
string ScanId,
int Sequence,
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private sealed record ProgressEnvelope(
string ScanId,
int Sequence,
string State,
string? Message,
DateTimeOffset Timestamp,
string CorrelationId,
Dictionary<string, JsonElement> Data);
}
string CorrelationId,
Dictionary<string, JsonElement> Data);
private sealed class StubEntryTraceResultStore : IEntryTraceResultStore
{
private readonly EntryTraceResult? _result;
public StubEntryTraceResultStore(EntryTraceResult? result)
{
_result = result;
}
public Task<EntryTraceResult?> GetAsync(string scanId, CancellationToken cancellationToken)
{
if (_result is not null && string.Equals(_result.ScanId, scanId, StringComparison.Ordinal))
{
return Task.FromResult<EntryTraceResult?>(_result);
}
return Task.FromResult<EntryTraceResult?>(null);
}
public Task StoreAsync(EntryTraceResult result, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
}

View File

@@ -1,179 +1,492 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.EntryTrace;
using StellaOps.Scanner.Worker.Options;
using StellaOps.Scanner.Worker.Processing;
using Xunit;
namespace StellaOps.Scanner.Worker.Tests;
public sealed class EntryTraceExecutionServiceTests : IDisposable
{
private readonly string _tempRoot;
public EntryTraceExecutionServiceTests()
{
_tempRoot = Path.Combine(Path.GetTempPath(), $"entrytrace-service-{Guid.NewGuid():n}");
Directory.CreateDirectory(_tempRoot);
}
[Fact]
public async Task ExecuteAsync_Skips_When_ConfigMetadataMissing()
{
var analyzer = new CapturingEntryTraceAnalyzer();
var service = CreateService(analyzer);
var context = CreateContext(new Dictionary<string, string>());
await service.ExecuteAsync(context, CancellationToken.None);
Assert.False(analyzer.Invoked);
Assert.False(context.Analysis.TryGet<EntryTraceGraph>(ScanAnalysisKeys.EntryTraceGraph, out _));
}
[Fact]
public async Task ExecuteAsync_BuildsContext_AndStoresGraph()
{
var configPath = Path.Combine(_tempRoot, "config.json");
File.WriteAllText(configPath, """
{
"config": {
"Env": ["PATH=/bin:/usr/bin"],
"Entrypoint": ["/entrypoint.sh"],
"WorkingDir": "/workspace",
"User": "scanner"
}
}
""");
var layerDirectory = Path.Combine(_tempRoot, "layer-1");
Directory.CreateDirectory(layerDirectory);
File.WriteAllText(Path.Combine(layerDirectory, "entrypoint.sh"), "#!/bin/sh\necho hello\n");
var metadata = new Dictionary<string, string>
{
[ScanMetadataKeys.ImageConfigPath] = configPath,
[ScanMetadataKeys.LayerDirectories] = layerDirectory,
["image.digest"] = "sha256:test-digest"
};
var analyzer = new CapturingEntryTraceAnalyzer();
var service = CreateService(analyzer);
var context = CreateContext(metadata);
await service.ExecuteAsync(context, CancellationToken.None);
Assert.True(analyzer.Invoked);
Assert.NotNull(analyzer.LastEntrypoint);
Assert.Equal("/entrypoint.sh", analyzer.LastEntrypoint!.Entrypoint[0]);
Assert.NotNull(analyzer.LastContext);
Assert.Equal("scanner", analyzer.LastContext!.User);
Assert.Equal("/workspace", analyzer.LastContext.WorkingDirectory);
Assert.Contains("/bin", analyzer.LastContext.Path);
Assert.True(context.Analysis.TryGet(ScanAnalysisKeys.EntryTraceGraph, out EntryTraceGraph stored));
Assert.Same(analyzer.Graph, stored);
}
private EntryTraceExecutionService CreateService(IEntryTraceAnalyzer analyzer)
{
var workerOptions = new ScannerWorkerOptions();
var entryTraceOptions = new EntryTraceAnalyzerOptions();
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Trace));
return new EntryTraceExecutionService(
analyzer,
Options.Create(entryTraceOptions),
Options.Create(workerOptions),
loggerFactory.CreateLogger<EntryTraceExecutionService>(),
loggerFactory);
}
private static ScanJobContext CreateContext(IReadOnlyDictionary<string, string> metadata)
{
var lease = new TestLease(metadata);
return new ScanJobContext(lease, TimeProvider.System, DateTimeOffset.UtcNow, CancellationToken.None);
}
public void Dispose()
{
try
{
if (Directory.Exists(_tempRoot))
{
Directory.Delete(_tempRoot, recursive: true);
}
}
catch
{
// ignore cleanup failures
}
}
private sealed class CapturingEntryTraceAnalyzer : IEntryTraceAnalyzer
{
public bool Invoked { get; private set; }
public EntrypointSpecification? LastEntrypoint { get; private set; }
public EntryTraceContext? LastContext { get; private set; }
public EntryTraceGraph Graph { get; } = new(
EntryTraceOutcome.Resolved,
ImmutableArray<EntryTraceNode>.Empty,
ImmutableArray<EntryTraceEdge>.Empty,
ImmutableArray<EntryTraceDiagnostic>.Empty);
public ValueTask<EntryTraceGraph> ResolveAsync(EntrypointSpecification entrypoint, EntryTraceContext context, CancellationToken cancellationToken = default)
{
Invoked = true;
LastEntrypoint = entrypoint;
LastContext = context;
return ValueTask.FromResult(Graph);
}
}
private sealed class TestLease : IScanJobLease
{
private readonly IReadOnlyDictionary<string, string> _metadata;
public TestLease(IReadOnlyDictionary<string, string> metadata)
{
_metadata = metadata;
EnqueuedAtUtc = DateTimeOffset.UtcNow;
LeasedAtUtc = EnqueuedAtUtc;
}
public string JobId { get; } = $"job-{Guid.NewGuid():n}";
public string ScanId { get; } = $"scan-{Guid.NewGuid():n}";
public int Attempt => 1;
public DateTimeOffset EnqueuedAtUtc { get; }
public DateTimeOffset LeasedAtUtc { get; }
public TimeSpan LeaseDuration => TimeSpan.FromMinutes(5);
public IReadOnlyDictionary<string, string> Metadata => _metadata;
public ValueTask RenewAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask CompleteAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask AbandonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask PoisonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.EntryTrace;
using StellaOps.Scanner.EntryTrace.Runtime;
using StellaOps.Scanner.Surface.Env;
using StellaOps.Scanner.Surface.FS;
using StellaOps.Scanner.Surface.Secrets;
using StellaOps.Scanner.Surface.Validation;
using StellaOps.Scanner.Worker.Options;
using StellaOps.Scanner.Worker.Processing;
using Xunit;
namespace StellaOps.Scanner.Worker.Tests;
public sealed class EntryTraceExecutionServiceTests : IDisposable
{
private readonly string _tempRoot;
public EntryTraceExecutionServiceTests()
{
_tempRoot = Path.Combine(Path.GetTempPath(), $"entrytrace-service-{Guid.NewGuid():n}");
Directory.CreateDirectory(_tempRoot);
}
[Fact]
public async Task ExecuteAsync_Skips_When_ConfigMetadataMissing()
{
var analyzer = new CapturingEntryTraceAnalyzer();
var store = new CapturingEntryTraceResultStore();
var service = CreateService(analyzer, store);
var context = CreateContext(new Dictionary<string, string>());
await service.ExecuteAsync(context, CancellationToken.None);
Assert.False(analyzer.Invoked);
Assert.False(context.Analysis.TryGet<EntryTraceGraph>(ScanAnalysisKeys.EntryTraceGraph, out _));
Assert.False(store.Stored);
}
[Fact]
public async Task ExecuteAsync_BuildsContext_AndStoresGraph()
{
var metadata = CreateMetadata("PATH=/bin:/usr/bin");
var analyzer = new CapturingEntryTraceAnalyzer();
var store = new CapturingEntryTraceResultStore();
var service = CreateService(analyzer, store);
var context = CreateContext(metadata);
await service.ExecuteAsync(context, CancellationToken.None);
Assert.True(analyzer.Invoked);
Assert.NotNull(analyzer.LastEntrypoint);
Assert.Equal("/entrypoint.sh", analyzer.LastEntrypoint!.Entrypoint[0]);
Assert.NotNull(analyzer.LastContext);
Assert.Equal("scanner", analyzer.LastContext!.User);
Assert.Equal("/workspace", analyzer.LastContext.WorkingDirectory);
Assert.Contains("/bin", analyzer.LastContext.Path);
Assert.True(context.Analysis.TryGet(ScanAnalysisKeys.EntryTraceGraph, out EntryTraceGraph stored));
Assert.Equal(analyzer.Graph.Outcome, stored.Outcome);
Assert.Contains(stored.Diagnostics, diagnostic => diagnostic.Reason == EntryTraceUnknownReason.RuntimeSnapshotUnavailable);
Assert.True(context.Analysis.TryGet(ScanAnalysisKeys.EntryTraceNdjson, out ImmutableArray<string> ndjsonPayload));
Assert.False(ndjsonPayload.IsDefaultOrEmpty);
Assert.True(store.Stored);
Assert.NotNull(store.LastResult);
Assert.Equal(context.ScanId, store.LastResult!.ScanId);
Assert.Equal("sha256:test-digest", store.LastResult.ImageDigest);
Assert.Equal(stored, store.LastResult.Graph);
Assert.Equal(ndjsonPayload, store.LastResult.Ndjson);
}
[Fact]
public async Task ExecuteAsync_UsesCachedGraphWhenAvailable()
{
var metadata = CreateMetadata("PATH=/bin:/usr/bin");
var analyzer = new CapturingEntryTraceAnalyzer();
var store = new CapturingEntryTraceResultStore();
var cache = new InMemorySurfaceCache();
var service = CreateService(analyzer, store, surfaceCache: cache);
await service.ExecuteAsync(CreateContext(metadata), CancellationToken.None);
Assert.True(analyzer.Invoked);
analyzer.Reset();
store.Reset();
await service.ExecuteAsync(CreateContext(metadata), CancellationToken.None);
Assert.False(analyzer.Invoked);
Assert.True(store.Stored);
}
[Fact]
public async Task ExecuteAsync_ReplacesSecretReferencesUsingSurfaceSecrets()
{
var metadata = CreateMetadata("API_KEY=secret://inline/api-key");
var analyzer = new CapturingEntryTraceAnalyzer();
var store = new CapturingEntryTraceResultStore();
var secrets = new StubSurfaceSecretProvider(new Dictionary<(string Type, string Name), byte[]>
{
{("inline", "api-key"), Encoding.UTF8.GetBytes("resolved-value")}
});
var service = CreateService(analyzer, store, surfaceSecrets: secrets);
await service.ExecuteAsync(CreateContext(metadata), CancellationToken.None);
Assert.True(analyzer.Invoked);
Assert.Equal("resolved-value", analyzer.LastContext!.Environment["API_KEY"]);
}
[Fact]
public async Task ExecuteAsync_FallsBackToBase64ForBinarySecrets()
{
var metadata = CreateMetadata("BLOB=secret://inline/blob");
var analyzer = new CapturingEntryTraceAnalyzer();
var store = new CapturingEntryTraceResultStore();
var payload = new byte[] { 0x00, 0xFF, 0x10 };
var secrets = new StubSurfaceSecretProvider(new Dictionary<(string Type, string Name), byte[]>
{
{("inline", "blob"), payload}
});
var service = CreateService(analyzer, store, surfaceSecrets: secrets);
await service.ExecuteAsync(CreateContext(metadata), CancellationToken.None);
Assert.True(analyzer.Invoked);
Assert.Equal(Convert.ToBase64String(payload), analyzer.LastContext!.Environment["BLOB"]);
}
[Fact]
public async Task ExecuteAsync_SkipsWhenSurfaceValidationFails()
{
var metadata = CreateMetadata("PATH=/bin:/usr/bin");
var analyzer = new CapturingEntryTraceAnalyzer();
var store = new CapturingEntryTraceResultStore();
var issues = new[]
{
SurfaceValidationIssue.Error("cache", "unwritable")
};
var validator = new StaticSurfaceValidatorRunner(SurfaceValidationResult.FromIssues(issues));
var service = CreateService(analyzer, store, surfaceValidator: validator);
await service.ExecuteAsync(CreateContext(metadata), CancellationToken.None);
Assert.False(analyzer.Invoked);
Assert.False(store.Stored);
}
private EntryTraceExecutionService CreateService(
IEntryTraceAnalyzer analyzer,
IEntryTraceResultStore store,
ISurfaceCache? surfaceCache = null,
ISurfaceValidatorRunner? surfaceValidator = null,
ISurfaceSecretProvider? surfaceSecrets = null,
ISurfaceEnvironment? surfaceEnvironment = null)
{
var workerOptions = new ScannerWorkerOptions();
var entryTraceOptions = new EntryTraceAnalyzerOptions();
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Trace));
surfaceEnvironment ??= new StubSurfaceEnvironment();
surfaceCache ??= new InMemorySurfaceCache();
surfaceValidator ??= new NoopSurfaceValidatorRunner();
surfaceSecrets ??= new StubSurfaceSecretProvider();
var serviceProvider = new ServiceCollection()
.AddSingleton<ISurfaceEnvironment>(surfaceEnvironment)
.BuildServiceProvider();
return new EntryTraceExecutionService(
analyzer,
Microsoft.Extensions.Options.Options.Create(entryTraceOptions),
Microsoft.Extensions.Options.Options.Create(workerOptions),
loggerFactory.CreateLogger<EntryTraceExecutionService>(),
loggerFactory,
new EntryTraceRuntimeReconciler(),
store,
surfaceValidator,
surfaceEnvironment,
surfaceCache,
surfaceSecrets,
serviceProvider);
}
private static ScanJobContext CreateContext(IReadOnlyDictionary<string, string> metadata)
{
var lease = new TestLease(metadata);
return new ScanJobContext(lease, TimeProvider.System, DateTimeOffset.UtcNow, CancellationToken.None);
}
private Dictionary<string, string> CreateMetadata(params string[] environmentEntries)
{
var configPath = Path.Combine(_tempRoot, $"config-{Guid.NewGuid():n}.json");
var env = environmentEntries.Length == 0
? new[] { "PATH=/bin:/usr/bin" }
: environmentEntries;
var envJson = string.Join(",", env.Select(value => $"\"{value}\""));
File.WriteAllText(configPath,
$$"""
{
"config": {
"Env": [{{envJson}}],
"Entrypoint": ["/entrypoint.sh"],
"WorkingDir": "/workspace",
"User": "scanner"
}
}
""");
var rootDirectory = Path.Combine(_tempRoot, $"root-{Guid.NewGuid():n}");
Directory.CreateDirectory(rootDirectory);
File.WriteAllText(Path.Combine(rootDirectory, "entrypoint.sh"), "#!/bin/sh\necho hello\n");
return new Dictionary<string, string>
{
[ScanMetadataKeys.ImageConfigPath] = configPath,
[ScanMetadataKeys.RootFilesystemPath] = rootDirectory,
[ScanMetadataKeys.LayerDirectories] = rootDirectory,
["image.digest"] = "sha256:test-digest"
};
}
public void Dispose()
{
try
{
if (Directory.Exists(_tempRoot))
{
Directory.Delete(_tempRoot, recursive: true);
}
}
catch
{
// ignore cleanup failures
}
}
private sealed class CapturingEntryTraceAnalyzer : IEntryTraceAnalyzer
{
public bool Invoked { get; private set; }
public EntrypointSpecification? LastEntrypoint { get; private set; }
public EntryTraceContext? LastContext { get; private set; }
public EntryTraceGraph Graph { get; } = new(
EntryTraceOutcome.Resolved,
ImmutableArray<EntryTraceNode>.Empty,
ImmutableArray<EntryTraceEdge>.Empty,
ImmutableArray<EntryTraceDiagnostic>.Empty,
ImmutableArray<EntryTracePlan>.Empty,
ImmutableArray<EntryTraceTerminal>.Empty);
public ValueTask<EntryTraceGraph> ResolveAsync(EntrypointSpecification entrypoint, EntryTraceContext context, CancellationToken cancellationToken = default)
{
Invoked = true;
LastEntrypoint = entrypoint;
LastContext = context;
return ValueTask.FromResult(Graph);
}
public void Reset()
{
Invoked = false;
LastEntrypoint = null;
LastContext = null;
}
}
private sealed class CapturingEntryTraceResultStore : IEntryTraceResultStore
{
public bool Stored { get; private set; }
public EntryTraceResult? LastResult { get; private set; }
public Task<EntryTraceResult?> GetAsync(string scanId, CancellationToken cancellationToken)
{
return Task.FromResult<EntryTraceResult?>(null);
}
public Task StoreAsync(EntryTraceResult result, CancellationToken cancellationToken)
{
Stored = true;
LastResult = result;
return Task.CompletedTask;
}
public void Reset()
{
Stored = false;
LastResult = null;
}
}
private sealed class TestLease : IScanJobLease
{
private readonly IReadOnlyDictionary<string, string> _metadata;
public TestLease(IReadOnlyDictionary<string, string> metadata)
{
_metadata = metadata;
EnqueuedAtUtc = DateTimeOffset.UtcNow;
LeasedAtUtc = EnqueuedAtUtc;
}
public string JobId { get; } = $"job-{Guid.NewGuid():n}";
public string ScanId { get; } = $"scan-{Guid.NewGuid():n}";
public int Attempt => 1;
public DateTimeOffset EnqueuedAtUtc { get; }
public DateTimeOffset LeasedAtUtc { get; }
public TimeSpan LeaseDuration => TimeSpan.FromMinutes(5);
public IReadOnlyDictionary<string, string> Metadata => _metadata;
public ValueTask RenewAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask CompleteAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask AbandonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask PoisonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
private sealed class StubSurfaceEnvironment : ISurfaceEnvironment
{
public StubSurfaceEnvironment()
{
var cacheRoot = new DirectoryInfo(Path.Combine(Path.GetTempPath(), "surface-cache-tests"));
Settings = new SurfaceEnvironmentSettings(
new Uri("https://surface.example"),
"surface-cache",
null,
cacheRoot,
1024,
false,
Array.Empty<string>(),
new SurfaceSecretsConfiguration("inline", "tenant", null, null, null, AllowInline: true),
"tenant",
new SurfaceTlsConfiguration(null, null, null));
RawVariables = new Dictionary<string, string>();
}
public SurfaceEnvironmentSettings Settings { get; }
public IReadOnlyDictionary<string, string> RawVariables { get; }
}
private sealed class NoopSurfaceValidatorRunner : ISurfaceValidatorRunner
{
public ValueTask<SurfaceValidationResult> RunAllAsync(SurfaceValidationContext context, CancellationToken cancellationToken = default)
{
return ValueTask.FromResult(SurfaceValidationResult.Success());
}
public ValueTask EnsureAsync(SurfaceValidationContext context, CancellationToken cancellationToken = default)
{
return ValueTask.CompletedTask;
}
}
private sealed class StaticSurfaceValidatorRunner : ISurfaceValidatorRunner
{
private readonly SurfaceValidationResult _result;
public StaticSurfaceValidatorRunner(SurfaceValidationResult result)
{
_result = result;
}
public ValueTask<SurfaceValidationResult> RunAllAsync(SurfaceValidationContext context, CancellationToken cancellationToken = default)
{
return ValueTask.FromResult(_result);
}
public ValueTask EnsureAsync(SurfaceValidationContext context, CancellationToken cancellationToken = default)
{
return ValueTask.CompletedTask;
}
}
private sealed class InMemorySurfaceCache : ISurfaceCache
{
private readonly Dictionary<string, byte[]> _store = new();
private readonly object _gate = new();
public async Task<T> GetOrCreateAsync<T>(
SurfaceCacheKey key,
Func<CancellationToken, Task<T>> factory,
Func<T, ReadOnlyMemory<byte>> serializer,
Func<ReadOnlyMemory<byte>, T> deserializer,
CancellationToken cancellationToken = default)
{
if (TryRead(key, deserializer, out var existing))
{
return existing;
}
var created = await factory(cancellationToken).ConfigureAwait(false);
var payload = serializer(created).ToArray();
lock (_gate)
{
_store[key.ToString()] = payload;
}
return created;
}
public Task<T?> TryGetAsync<T>(
SurfaceCacheKey key,
Func<ReadOnlyMemory<byte>, T> deserializer,
CancellationToken cancellationToken = default)
{
return Task.FromResult(TryRead(key, deserializer, out var value) ? value : default);
}
public Task SetAsync(
SurfaceCacheKey key,
ReadOnlyMemory<byte> payload,
CancellationToken cancellationToken = default)
{
lock (_gate)
{
_store[key.ToString()] = payload.ToArray();
}
return Task.CompletedTask;
}
private bool TryRead<T>(SurfaceCacheKey key, Func<ReadOnlyMemory<byte>, T> deserializer, out T value)
{
lock (_gate)
{
if (_store.TryGetValue(key.ToString(), out var bytes))
{
value = deserializer(new ReadOnlyMemory<byte>(bytes));
return true;
}
}
value = default!;
return false;
}
}
private sealed class StubSurfaceSecretProvider : ISurfaceSecretProvider
{
private readonly Dictionary<(string Type, string Name), byte[]> _secrets;
private readonly bool _throwOnMissing;
public StubSurfaceSecretProvider(Dictionary<(string Type, string Name), byte[]>? secrets = null, bool throwOnMissing = false)
{
_secrets = secrets ?? new Dictionary<(string Type, string Name), byte[]>();
_throwOnMissing = throwOnMissing;
}
public ValueTask<SurfaceSecretHandle> GetAsync(SurfaceSecretRequest request, CancellationToken cancellationToken = default)
{
var key = (request.SecretType, request.Name ?? string.Empty);
if (_secrets.TryGetValue(key, out var payload))
{
return ValueTask.FromResult(SurfaceSecretHandle.FromBytes(payload));
}
if (_throwOnMissing)
{
throw new SurfaceSecretNotFoundException(request);
}
return ValueTask.FromResult(SurfaceSecretHandle.Empty);
}
}
}

View File

@@ -153,7 +153,7 @@ public sealed class RedisWorkerSmokeTests
public async Task<IScanJobLease?> TryAcquireAsync(CancellationToken cancellationToken)
{
var request = new QueueLeaseRequest(_consumerName, 1, _queueOptions.DefaultLeaseDuration);
var leases = await _queue.LeaseAsync(request, cancellationToken).ConfigureAwait(false);
var leases = await _queue.LeaseAsync(request, cancellationToken);
if (leases.Count == 0)
{
return null;
@@ -221,23 +221,23 @@ public sealed class RedisWorkerSmokeTests
public async ValueTask RenewAsync(CancellationToken cancellationToken)
{
await _lease.RenewAsync(_options.DefaultLeaseDuration, cancellationToken).ConfigureAwait(false);
await _lease.RenewAsync(_options.DefaultLeaseDuration, cancellationToken);
}
public async ValueTask CompleteAsync(CancellationToken cancellationToken)
{
await _lease.AcknowledgeAsync(cancellationToken).ConfigureAwait(false);
await _lease.AcknowledgeAsync(cancellationToken);
_deps.JobCompleted.TrySetResult();
}
public async ValueTask AbandonAsync(string reason, CancellationToken cancellationToken)
{
await _lease.ReleaseAsync(QueueReleaseDisposition.Retry, cancellationToken).ConfigureAwait(false);
await _lease.ReleaseAsync(QueueReleaseDisposition.Retry, cancellationToken);
}
public async ValueTask PoisonAsync(string reason, CancellationToken cancellationToken)
{
await _lease.DeadLetterAsync(reason, cancellationToken).ConfigureAwait(false);
await _lease.DeadLetterAsync(reason, cancellationToken);
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;