up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-01 21:16:22 +02:00
parent c11d87d252
commit 909d9b6220
208 changed files with 860954 additions and 832 deletions

View File

@@ -0,0 +1,32 @@
using StellaOps.Scanner.Analyzers.Lang;
namespace StellaOps.Scanner.Analyzers.Lang.Node.SmokeTests;
internal static class LanguageAnalyzerSmokeHarness
{
public static async Task AssertDeterministicAsync(string fixturePath, string goldenPath, ILanguageAnalyzer analyzer, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(fixturePath)) throw new ArgumentException("fixturePath required", nameof(fixturePath));
if (string.IsNullOrWhiteSpace(goldenPath)) throw new ArgumentException("goldenPath required", nameof(goldenPath));
var engine = new LanguageAnalyzerEngine(new[] { analyzer });
var context = new LanguageAnalyzerContext(fixturePath, TimeProvider.System);
var result = await engine.AnalyzeAsync(context, cancellationToken).ConfigureAwait(false);
var actual = Normalize(result.ToJson(indent: true));
var expected = Normalize(await File.ReadAllTextAsync(goldenPath, cancellationToken).ConfigureAwait(false));
if (!string.Equals(actual, expected, StringComparison.Ordinal))
{
var actualPath = goldenPath + ".actual";
Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
await File.WriteAllTextAsync(actualPath, actual, cancellationToken).ConfigureAwait(false);
}
Assert.Equal(expected, actual);
}
private static string Normalize(string value)
{
return value.Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd();
}
}

View File

@@ -0,0 +1,22 @@
using System.IO;
using StellaOps.Scanner.Analyzers.Lang.Node;
using Xunit;
namespace StellaOps.Scanner.Analyzers.Lang.Node.SmokeTests;
public class Phase22SmokeTests
{
[Fact]
public async Task Phase22_Fixture_Matches_Golden()
{
var cancellationToken = TestContext.Current.CancellationToken;
var fixturePath = Path.GetFullPath(Path.Combine("..", "StellaOps.Scanner.Analyzers.Lang.Node.Tests", "Fixtures", "lang", "node", "phase22"));
var goldenPath = Path.Combine(fixturePath, "expected.json");
await LanguageAnalyzerSmokeHarness.AssertDeterministicAsync(
fixturePath,
goldenPath,
new NodeLanguageAnalyzer(),
cancellationToken);
}
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<!-- Stay scoped: disable implicit restore sources beyond local nugets -->
<RestoreSources>$(StellaOpsLocalNuGetSource)</RestoreSources>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit.v3" Version="3.0.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.Lang/StellaOps.Scanner.Analyzers.Lang.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.Lang.Node/StellaOps.Scanner.Analyzers.Lang.Node.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Core/StellaOps.Scanner.Core.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="../StellaOps.Scanner.Analyzers.Lang.Node.Tests/Fixtures/lang/node/phase22/**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,2 @@
FROM node:22-alpine
ENV NODE_OPTIONS="--require ./bootstrap.js"

View File

@@ -0,0 +1,25 @@
[
{
"analyzerId": "node",
"componentKey": "warning:node-options:Dockerfile#2",
"purl": null,
"name": "NODE_OPTIONS warning",
"version": null,
"type": "node:warning",
"usedByEntrypoint": false,
"metadata": {
"locator": "Dockerfile#2",
"reason": "NODE_OPTIONS",
"source": "Dockerfile",
"value": "--require ./bootstrap.js"
},
"evidence": [
{
"kind": "metadata",
"source": "node.env",
"locator": "Dockerfile#2",
"value": "--require ./bootstrap.js"
}
]
}
]

View File

@@ -0,0 +1,4 @@
{
"name": "container-env",
"version": "1.0.0"
}

View File

@@ -0,0 +1,28 @@
[
{
"analyzerId": "node",
"componentKey": "purl::pkg:npm/layer-lib@0.1.0",
"purl": "pkg:npm/layer-lib@0.1.0",
"name": "layer-lib",
"version": "0.1.0",
"type": "npm",
"usedByEntrypoint": false,
"metadata": {
"entrypoint": "layers/layer1/node_modules/layer-lib/index.js",
"path": "layers/layer1/node_modules/layer-lib"
},
"evidence": [
{
"kind": "file",
"source": "package.json",
"locator": "layers/layer1/node_modules/layer-lib/package.json"
},
{
"kind": "metadata",
"source": "package.json:entrypoint",
"locator": "layers/layer1/node_modules/layer-lib/package.json#entrypoint",
"value": "layers/layer1/node_modules/layer-lib/index.js;index.js"
}
]
}
]

View File

@@ -0,0 +1,49 @@
[
{
"analyzerId": "node",
"componentKey": "observation::node-phase22",
"name": "Node Observation (Phase 22)",
"type": "node-observation",
"usedByEntrypoint": false,
"metadata": {
"node.observation.components": "1",
"node.observation.edges": "0",
"node.observation.entrypoints": "1"
},
"evidence": [
{
"kind": "derived",
"source": "node.observation",
"locator": "phase22.ndjson",
"value": "{\u0022type\u0022:\u0022component\u0022,\u0022componentType\u0022:\u0022pkg\u0022,\u0022path\u0022:\u0022/original.ts\u0022,\u0022format\u0022:\u0022esm\u0022,\u0022fromBundle\u0022:true,\u0022reason\u0022:\u0022source-map\u0022,\u0022confidence\u0022:0.87,\u0022resolverTrace\u0022:[\u0022bundle:/src/index.js\u0022,\u0022map:/src/index.js.map\u0022,\u0022source:/original.ts\u0022]}\n{\u0022type\u0022:\u0022entrypoint\u0022,\u0022path\u0022:\u0022/src/index.js\u0022,\u0022format\u0022:\u0022esm\u0022,\u0022reason\u0022:\u0022bundle-entrypoint\u0022,\u0022confidence\u0022:0.88,\u0022resolverTrace\u0022:[\u0022bundle:/src/index.js\u0022,\u0022map:/src/index.js.map\u0022]}",
"sha256": "b2d6ac4c2b422ab26943dab38c2a7b8e8fa2979122e0c2674adb5a48f9cdd2fb"
}
]
},
{
"analyzerId": "node",
"componentKey": "purl::pkg:npm/dynamic-imports@1.0.0",
"purl": "pkg:npm/dynamic-imports@1.0.0",
"name": "dynamic-imports",
"version": "1.0.0",
"type": "npm",
"usedByEntrypoint": false,
"metadata": {
"entrypoint": "src/index.js",
"path": "."
},
"evidence": [
{
"kind": "file",
"source": "package.json",
"locator": "package.json"
},
{
"kind": "metadata",
"source": "package.json:entrypoint",
"locator": "package.json#entrypoint",
"value": "src/index.js;src/index.js"
}
]
}
]

View File

@@ -0,0 +1,5 @@
{
"name": "dynamic-imports",
"version": "1.0.0",
"main": "src/index.js"
}

View File

@@ -0,0 +1,9 @@
import staticDep from './lib/static.js';
const concat = require('./lib/' + 'concat.js');
async function load(name) {
const mod = await import(`./lib/${name}/entry.js`);
return mod;
}
//# sourceMappingURL=index.js.map

View File

@@ -0,0 +1,7 @@
{
"version": 3,
"file": "index.js",
"sources": ["original.ts"],
"sourcesContent": ["import mapped from './lib/sourcemap.js';"],
"mappings": ""
}

View File

@@ -0,0 +1,27 @@
[
{
"analyzerId": "node",
"componentKey": "observation::node-phase22",
"purl": null,
"name": "Node Observation (Phase 22)",
"version": null,
"type": "node-observation",
"usedByEntrypoint": false,
"metadata": {
"node.observation.components": "3",
"node.observation.edges": "3",
"node.observation.entrypoints": "1",
"node.observation.native": "1",
"node.observation.wasm": "1"
},
"evidence": [
{
"kind": "derived",
"source": "node.observation",
"locator": "phase22.ndjson",
"value": "{\"type\":\"component\",\"componentType\":\"native\",\"path\":\"/native/addon.node\",\"reason\":\"native-addon-file\",\"confidence\":0.82,\"resolverTrace\":[\"file:/native/addon.node\"],\"arch\":\"x86_64\",\"platform\":\"linux\"}\n{\"type\":\"component\",\"componentType\":\"wasm\",\"path\":\"/pkg/pkg.wasm\",\"reason\":\"wasm-file\",\"confidence\":0.8,\"resolverTrace\":[\"file:/pkg/pkg.wasm\"]}\n{\"type\":\"component\",\"componentType\":\"pkg\",\"path\":\"/src/app.js\",\"format\":\"esm\",\"fromBundle\":true,\"reason\":\"source-map\",\"confidence\":0.87,\"resolverTrace\":[\"bundle:/dist/main.js\",\"map:/dist/main.js.map\",\"source:/src/app.js\"]}\n{\"type\":\"edge\",\"edgeType\":\"native-addon\",\"from\":\"/dist/main.js\",\"to\":\"/native/addon.node\",\"reason\":\"native-dlopen-string\",\"confidence\":0.76,\"resolverTrace\":[\"source:/dist/main.js\",\"call:process.dlopen('../native/addon.node')\"]}\n{\"type\":\"edge\",\"edgeType\":\"wasm\",\"from\":\"/dist/main.js\",\"to\":\"/pkg/pkg.wasm\",\"reason\":\"wasm-import\",\"confidence\":0.74,\"resolverTrace\":[\"source:/dist/main.js\",\"call:WebAssembly.instantiate('../pkg/pkg.wasm')\"]}\n{\"type\":\"edge\",\"edgeType\":\"capability\",\"from\":\"/dist/main.js\",\"to\":\"child_process.execFile\",\"reason\":\"capability-child-process\",\"confidence\":0.7,\"resolverTrace\":[\"source:/dist/main.js\",\"call:child_process.execFile\"]}\n{\"type\":\"entrypoint\",\"path\":\"/dist/main.js\",\"format\":\"esm\",\"reason\":\"bundle-entrypoint\",\"confidence\":0.88,\"resolverTrace\":[\"bundle:/dist/main.js\",\"map:/dist/main.js.map\"]}",
"sha256": "7e99e8fbd63eb2f29717ce6b03dc148d969b203e10a072d1bcd6ff0c5fe424bb"
}
]
}
]

View File

@@ -0,0 +1,5 @@
import childProcess from 'child_process';
export function start() {
childProcess.execFile('ls');
return WebAssembly.instantiateStreaming(fetch('./pkg/pkg.wasm'));
}

View File

@@ -0,0 +1,47 @@
[
{
"analyzerId": "node",
"componentKey": "purl::pkg:npm/pkg@1.2.3",
"purl": "pkg:npm/pkg@1.2.3",
"name": "pkg",
"version": "1.2.3",
"type": "npm",
"usedByEntrypoint": false,
"metadata": {
"entrypoint": "node_modules/.pnpm/pkg@1.2.3/node_modules/pkg/index.js",
"path": "node_modules/.pnpm/pkg@1.2.3/node_modules/pkg"
},
"evidence": [
{
"kind": "file",
"source": "package.json",
"locator": "node_modules/.pnpm/pkg@1.2.3/node_modules/pkg/package.json"
},
{
"kind": "metadata",
"source": "package.json:entrypoint",
"locator": "node_modules/.pnpm/pkg@1.2.3/node_modules/pkg/package.json#entrypoint",
"value": "node_modules/.pnpm/pkg@1.2.3/node_modules/pkg/index.js;index.js"
}
]
},
{
"analyzerId": "node",
"componentKey": "purl::pkg:npm/pnpm-demo@0.0.1",
"purl": "pkg:npm/pnpm-demo@0.0.1",
"name": "pnpm-demo",
"version": "0.0.1",
"type": "npm",
"usedByEntrypoint": false,
"metadata": {
"path": "."
},
"evidence": [
{
"kind": "file",
"source": "package.json",
"locator": "package.json"
}
]
}
]

View File

@@ -0,0 +1,7 @@
{
"name": "pnpm-demo",
"version": "0.0.1",
"dependencies": {
"pkg": "1.2.3"
}
}

View File

@@ -0,0 +1,45 @@
[
{
"analyzerId": "node-runtime",
"componentKey": "runtime-edge:src/index.js->./lib/runtime.js",
"purl": null,
"name": "runtime-edge",
"version": null,
"type": "node:runtime-edge",
"usedByEntrypoint": false,
"metadata": {
"from": "src/index.js",
"loaderId.sha256": "88d4266fd4e6338d13b845fcf289579d209c897823b9217da3e161936f031589",
"reason": "runtime-require",
"to": "./lib/runtime.js"
},
"evidence": [
{
"kind": "derived",
"source": "node.runtime",
"locator": "runtime-require|src/index.js|./lib/runtime.js"
}
]
},
{
"analyzerId": "node-runtime",
"componentKey": "/layers/app/node_modules/native/addon.node",
"purl": null,
"name": "addon.node",
"version": null,
"type": "node:runtime-component",
"usedByEntrypoint": false,
"metadata": {
"loaderId.sha256": "88d4266fd4e6338d13b845fcf289579d209c897823b9217da3e161936f031589",
"path": "/layers/app/node_modules/native/addon.node",
"reason": "runtime-load"
},
"evidence": [
{
"kind": "derived",
"source": "node.runtime",
"locator": "runtime-load"
}
]
}
]

View File

@@ -0,0 +1,2 @@
{"type":"edge","from":"src/index.js","to":"./lib/runtime.js","reason":"runtime-require","loaderId":"abcd"}
{"type":"component","path":"/layers/app/node_modules/native/addon.node","reason":"runtime-load","loaderId":"abcd"}

View File

@@ -0,0 +1,5 @@
{
"name": "runtime-evidence",
"version": "1.0.0",
"main": "src/index.js"
}

View File

@@ -0,0 +1 @@
console.log('runtime evidence fixture');

View File

@@ -138,4 +138,103 @@ public sealed class NodeLanguageAnalyzerTests
analyzers,
cancellationToken);
}
[Fact]
public async Task Phase22BundleNativeWasmObservationAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var fixturePath = TestPaths.ResolveFixture("lang", "node", "phase22");
var goldenPath = Path.Combine(fixturePath, "expected.json");
var analyzers = new ILanguageAnalyzer[]
{
new NodeLanguageAnalyzer()
};
await LanguageAnalyzerTestHarness.AssertDeterministicAsync(
fixturePath,
goldenPath,
analyzers,
cancellationToken);
}
[Fact]
public async Task ContainerLayersAreScannedAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var fixturePath = TestPaths.ResolveFixture("lang", "node", "container-layers");
var goldenPath = Path.Combine(fixturePath, "expected.json");
var analyzers = new ILanguageAnalyzer[] { new NodeLanguageAnalyzer() };
await LanguageAnalyzerTestHarness.AssertDeterministicAsync(
fixturePath,
goldenPath,
analyzers,
cancellationToken);
}
[Fact]
public async Task DynamicImportsEmitEvidenceAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var fixturePath = TestPaths.ResolveFixture("lang", "node", "imports-dynamic");
var goldenPath = Path.Combine(fixturePath, "expected.json");
var analyzers = new ILanguageAnalyzer[] { new NodeLanguageAnalyzer() };
await LanguageAnalyzerTestHarness.AssertDeterministicAsync(
fixturePath,
goldenPath,
analyzers,
cancellationToken);
}
[Fact]
public async Task PnpmVirtualStoreIsParsedAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var fixturePath = TestPaths.ResolveFixture("lang", "node", "pnpm-store");
var goldenPath = Path.Combine(fixturePath, "expected.json");
var analyzers = new ILanguageAnalyzer[] { new NodeLanguageAnalyzer() };
await LanguageAnalyzerTestHarness.AssertDeterministicAsync(
fixturePath,
goldenPath,
analyzers,
cancellationToken);
}
[Fact]
public async Task RuntimeEvidenceIsIngestedAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var fixturePath = TestPaths.ResolveFixture("lang", "node", "runtime-evidence");
var goldenPath = Path.Combine(fixturePath, "expected.json");
var analyzers = new ILanguageAnalyzer[] { new NodeLanguageAnalyzer() };
await LanguageAnalyzerTestHarness.AssertDeterministicAsync(
fixturePath,
goldenPath,
analyzers,
cancellationToken);
}
[Fact]
public async Task DockerfileNodeOptionsWarningIsEmittedAsync()
{
var cancellationToken = TestContext.Current.CancellationToken;
var fixturePath = TestPaths.ResolveFixture("lang", "node", "container-env");
var goldenPath = Path.Combine(fixturePath, "expected.json");
var analyzers = new ILanguageAnalyzer[] { new NodeLanguageAnalyzer() };
await LanguageAnalyzerTestHarness.AssertDeterministicAsync(
fixturePath,
goldenPath,
analyzers,
cancellationToken);
}
}

View File

@@ -72,11 +72,11 @@ public sealed class ComponentGraphBuilderTests
}
[Fact]
public void Build_DeterministicOrdering()
{
var fragments = new[]
{
LayerComponentFragment.Create("sha256:layer1", new[]
public void Build_DeterministicOrdering()
{
var fragments = new[]
{
LayerComponentFragment.Create("sha256:layer1", new[]
{
new ComponentRecord
{
@@ -93,7 +93,27 @@ public sealed class ComponentGraphBuilderTests
var graph1 = ComponentGraphBuilder.Build(fragments);
var graph2 = ComponentGraphBuilder.Build(fragments);
Assert.Equal(graph1.Components.Select(c => c.Identity.Key), graph2.Components.Select(c => c.Identity.Key));
}
}
Assert.Equal(graph1.Components.Select(c => c.Identity.Key), graph2.Components.Select(c => c.Identity.Key));
}
[Fact]
public void Build_SortsLayersByDigest()
{
var fragments = new[]
{
LayerComponentFragment.Create("sha256:zzzz", Array.Empty<ComponentRecord>()),
LayerComponentFragment.Create("sha256:aaaa", Array.Empty<ComponentRecord>()),
LayerComponentFragment.Create("sha256:mmmm", Array.Empty<ComponentRecord>()),
};
var graph = ComponentGraphBuilder.Build(fragments);
Assert.Equal(new[]
{
"sha256:aaaa",
"sha256:mmmm",
"sha256:zzzz"
}, graph.Layers.Select(layer => layer.LayerDigest));
}
}

View File

@@ -75,16 +75,22 @@ public sealed class CycloneDxComposerTests
var first = composer.Compose(request);
var second = composer.Compose(request);
Assert.Equal(first.Inventory.JsonSha256, second.Inventory.JsonSha256);
Assert.Equal(first.Inventory.ProtobufSha256, second.Inventory.ProtobufSha256);
Assert.Equal(first.Inventory.SerialNumber, second.Inventory.SerialNumber);
Assert.NotNull(first.Usage);
Assert.NotNull(second.Usage);
Assert.Equal(first.Usage!.JsonSha256, second.Usage!.JsonSha256);
Assert.Equal(first.Usage.ProtobufSha256, second.Usage.ProtobufSha256);
Assert.Equal(first.Usage.SerialNumber, second.Usage.SerialNumber);
}
Assert.Equal(first.Inventory.JsonSha256, second.Inventory.JsonSha256);
Assert.Equal(first.Inventory.ContentHash, first.Inventory.JsonSha256);
Assert.Equal(first.Inventory.ProtobufSha256, second.Inventory.ProtobufSha256);
Assert.Equal(first.Inventory.SerialNumber, second.Inventory.SerialNumber);
Assert.Null(first.Inventory.MerkleRoot);
Assert.Null(first.Inventory.CompositionUri);
Assert.NotNull(first.Usage);
Assert.NotNull(second.Usage);
Assert.Equal(first.Usage!.JsonSha256, second.Usage!.JsonSha256);
Assert.Equal(first.Usage.ContentHash, first.Usage.JsonSha256);
Assert.Equal(first.Usage.ProtobufSha256, second.Usage.ProtobufSha256);
Assert.Equal(first.Usage.SerialNumber, second.Usage.SerialNumber);
Assert.Null(first.Usage.MerkleRoot);
Assert.Null(first.Usage.CompositionUri);
}
private static SbomCompositionRequest BuildRequest()
{

View File

@@ -654,6 +654,140 @@ public sealed class EntryTraceAnalyzerTests
Assert.Equal(EntryTraceTerminalType.Native, terminal.Type);
}
[Fact]
public async Task ResolveAsync_PropagatesUserSwitchWrapper()
{
var fs = new TestRootFileSystem();
fs.AddBinaryFile("/usr/bin/sudo", CreateGoBinary(), executable: true);
fs.AddBinaryFile("/usr/bin/python", CreateGoBinary(), executable: true);
fs.AddFile("/srv/app.py", "print('hi')\n", executable: false);
var context = new EntryTraceContext(
fs,
ImmutableDictionary<string, string>.Empty,
ImmutableArray.Create("/usr/bin"),
"/",
"root",
"sha256:user-switch",
"scan-sudo",
NullLogger.Instance);
var spec = EntrypointSpecification.FromExecForm(
new[] { "sudo", "-u", "app", "python", "/srv/app.py" },
null);
var analyzer = CreateAnalyzer();
var result = await analyzer.ResolveAsync(spec, context);
var terminal = Assert.Single(result.Terminals);
Assert.Equal("/usr/bin/python", terminal.Path);
Assert.Equal("app", terminal.User);
Assert.Contains("/srv/app.py", terminal.Arguments);
var edge = Assert.Single(result.Edges.Where(e => e.Relationship == "wrapper"));
Assert.Equal("true", edge.Metadata?["guarded"]);
Assert.Equal("user", edge.Metadata?["state-change"]);
Assert.Equal("app", edge.Metadata?["user"]);
}
[Fact]
public async Task ResolveAsync_PropagatesEnvWrapperIntoPlan()
{
var fs = new TestRootFileSystem();
fs.AddBinaryFile("/usr/bin/env", CreateGoBinary(), executable: true);
fs.AddBinaryFile("/usr/bin/python", CreateGoBinary(), executable: true);
fs.AddFile("/srv/app.py", "print('env')\n", executable: false);
var context = new EntryTraceContext(
fs,
ImmutableDictionary<string, string>.Empty,
ImmutableArray.Create("/usr/bin"),
"/",
"root",
"sha256:env-wrapper",
"scan-env",
NullLogger.Instance);
var spec = EntrypointSpecification.FromExecForm(
new[] { "env", "FOO=bar", "python", "/srv/app.py" },
null);
var analyzer = CreateAnalyzer();
var result = await analyzer.ResolveAsync(spec, context);
var plan = Assert.Single(result.Plans);
Assert.True(plan.Environment.TryGetValue("FOO", out var value) && value == "bar");
var terminal = Assert.Single(result.Terminals);
Assert.Equal("/usr/bin/python", terminal.Path);
var edge = Assert.Single(result.Edges.Where(e => e.Relationship == "wrapper"));
Assert.Equal("env", edge.Metadata?["state-change"]);
Assert.Equal("true", edge.Metadata?["guarded"]);
}
[Fact]
public async Task ResolveAsync_AccumulatesWorkingDirectoryFromShellCd()
{
var fs = new TestRootFileSystem();
fs.AddBinaryFile("/bin/sh", CreateGoBinary(), executable: true);
fs.AddBinaryFile("/usr/bin/python", CreateGoBinary(), executable: true);
fs.AddFile("/entry.sh", "#!/bin/sh\ncd /service\nexec python /srv/service.py\n", executable: true);
fs.AddFile("/srv/service.py", "print('svc')\n", executable: false);
var context = new EntryTraceContext(
fs,
ImmutableDictionary<string, string>.Empty,
ImmutableArray.Create("/bin", "/usr/bin"),
"/",
"root",
"sha256:cd-trace",
"scan-cd",
NullLogger.Instance);
var spec = EntrypointSpecification.FromExecForm(
new[] { "/entry.sh" },
null);
var analyzer = CreateAnalyzer();
var result = await analyzer.ResolveAsync(spec, context);
var terminal = Assert.Single(result.Terminals);
Assert.Equal("/usr/bin/python", terminal.Path);
Assert.Equal("/service", terminal.WorkingDirectory);
}
[Fact]
public async Task ResolveAsync_HandlesInitShimAndGuardsEdge()
{
var fs = new TestRootFileSystem();
fs.AddBinaryFile("/sbin/tini", CreateGoBinary(), executable: true);
fs.AddBinaryFile("/usr/bin/python", CreateGoBinary(), executable: true);
fs.AddFile("/srv/app.py", "print('shim')\n", executable: false);
var context = new EntryTraceContext(
fs,
ImmutableDictionary<string, string>.Empty,
ImmutableArray.Create("/sbin", "/usr/bin"),
"/",
"root",
"sha256:init-shim",
"scan-tini",
NullLogger.Instance);
var spec = EntrypointSpecification.FromExecForm(
new[] { "/sbin/tini", "--", "python", "/srv/app.py" },
null);
var analyzer = CreateAnalyzer();
var result = await analyzer.ResolveAsync(spec, context);
var terminal = Assert.Single(result.Terminals);
Assert.Equal("/usr/bin/python", terminal.Path);
var edge = Assert.Single(result.Edges.Where(e => e.Relationship == "wrapper"));
Assert.Equal("true", edge.Metadata?["guarded"]);
Assert.Equal("init", edge.Metadata?["shim"]);
}
private static byte[] CreateGoBinary()
{
var buffer = new byte[256];

View File

@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using StellaOps.Scanner.EntryTrace;
@@ -29,6 +30,26 @@ public sealed class EntryTraceRuntimeReconcilerTests
Assert.Contains(reconciled.Diagnostics, d => d.Reason == EntryTraceUnknownReason.RuntimeMatch);
}
[Fact]
public void Reconcile_EmitsRuntimeChain_InDiagnostics()
{
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);
var diag = Assert.Single(reconciled.Diagnostics, d => d.Reason == EntryTraceUnknownReason.RuntimeMatch);
Assert.Contains("tini", diag.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("/usr/local/bin/app", diag.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("->", diag.Message, StringComparison.Ordinal);
}
[Fact]
public void Reconcile_FlagsMismatch_WhenDifferentExecutable()
{

View File

@@ -246,13 +246,34 @@ public sealed class SurfaceManifestStageExecutorTests
Span: null,
Metadata: null);
var plan = new EntryTracePlan(
ImmutableArray.Create("/bin/entry"),
ImmutableDictionary<string, string>.Empty,
WorkingDirectory: "/",
User: "root",
TerminalPath: "/bin/entry",
Type: EntryTraceTerminalType.Native,
Runtime: "native",
Confidence: 92.5,
Evidence: ImmutableDictionary<string, string>.Empty);
var terminal = new EntryTraceTerminal(
Path: "/bin/entry",
Type: EntryTraceTerminalType.Native,
Runtime: "native",
Confidence: 92.5,
Evidence: ImmutableDictionary<string, string>.Empty,
User: "root",
WorkingDirectory: "/",
Arguments: ImmutableArray.Create("/bin/entry"));
var graph = new EntryTraceGraph(
Outcome: EntryTraceOutcome.Resolved,
Nodes: ImmutableArray.Create(node),
Edges: ImmutableArray<EntryTraceEdge>.Empty,
Diagnostics: ImmutableArray<EntryTraceDiagnostic>.Empty,
Plans: ImmutableArray<EntryTracePlan>.Empty,
Terminals: ImmutableArray<EntryTraceTerminal>.Empty);
Plans: ImmutableArray.Create(plan),
Terminals: ImmutableArray.Create(terminal));
context.Analysis.Set(ScanAnalysisKeys.EntryTraceGraph, graph);
@@ -301,6 +322,44 @@ public sealed class SurfaceManifestStageExecutorTests
Assert.NotEmpty(packageStore.LastInventory!.Packages);
}
[Fact]
public async Task ExecuteAsync_AddsBestEntryTraceMetadata()
{
var metrics = new ScannerWorkerMetrics();
var publisher = new TestSurfaceManifestPublisher("tenant-a");
var cache = new RecordingSurfaceCache();
var environment = new TestSurfaceEnvironment("tenant-a");
var hash = CreateCryptoHash();
var packageStore = new RecordingRubyPackageStore();
var executor = new SurfaceManifestStageExecutor(
publisher,
cache,
environment,
metrics,
NullLogger<SurfaceManifestStageExecutor>.Instance,
hash,
packageStore);
var context = CreateContext();
PopulateAnalysis(context);
await executor.ExecuteAsync(context, CancellationToken.None);
var entrytracePayloads = publisher.LastRequest!.Payloads
.Where(p => p.Kind.StartsWith("entrytrace", StringComparison.OrdinalIgnoreCase))
.ToList();
Assert.NotEmpty(entrytracePayloads);
foreach (var payload in entrytracePayloads)
{
Assert.Equal("/bin/entry", payload.Metadata!["best_terminal"]);
Assert.Equal("92.5000", payload.Metadata!["best_confidence"]);
Assert.Equal("root", payload.Metadata!["best_user"]);
Assert.Equal("/", payload.Metadata!["best_workdir"]);
}
}
private static async Task PopulateRubyAnalyzerResultsAsync(ScanJobContext context)
{
var fixturePath = Path.Combine(