Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,23 @@
C:Q1
P:busybox
V:1.37.0-r0
A:x86_64
S:4096
I:8192
T:BusyBox utility set
U:https://busybox.net/
L:GPL-2.0-only
o:busybox
m:Stella Ops <ops@stella-ops.org>
t:1729286400
c:deadbeef12345678
D:musl>=1.2.5-r0 ssl_client
p:/bin/sh
F:bin
R:busybox
Z:0f1e2d3c4b5a6978ffeeddccbbaa9988
F:etc
R:profile
Z:11223344556677889900aabbccddeeff
a:0:0:0644

View File

@@ -0,0 +1 @@
/etc/bash.bashrc abcdef1234567890

View File

@@ -0,0 +1,3 @@
/bin/bash
/etc/bash.bashrc
/usr/share/doc/bash/changelog.Debian.gz

View File

@@ -0,0 +1,2 @@
0123456789abcdef0123456789abcdef /bin/bash
abcdef1234567890abcdef1234567890 /etc/bash.bashrc

View File

@@ -0,0 +1,15 @@
Package: bash
Status: install ok installed
Priority: important
Section: shells
Installed-Size: 1024
Maintainer: Debian Developers <debian-devel@lists.debian.org>
Architecture: amd64
Version: 5.2.21-2
Source: bash (5.2.21-2)
Homepage: https://www.gnu.org/software/bash/
Description: GNU Bourne Again Shell
This is the GNU Bourne Again Shell.
Conffiles:
/etc/bash.bashrc abcdef1234567890

View File

@@ -0,0 +1,74 @@
[
{
"analyzerId": "apk",
"durationMilliseconds": 0,
"packageCount": 1,
"fileEvidenceCount": 4,
"warnings": [],
"packages": [
{
"packageUrl": "pkg:alpine/busybox@1.37.0-r0?arch=x86_64",
"name": "busybox",
"version": "1.37.0",
"architecture": "x86_64",
"epoch": null,
"release": "r0",
"sourcePackage": "busybox",
"license": "GPL-2.0-only",
"evidenceSource": "ApkDatabase",
"cveHints": [],
"provides": [
"/bin/sh"
],
"depends": [
"musl\u003E=1.2.5-r0",
"ssl_client"
],
"files": [
{
"path": "/bin/",
"layerDigest": null,
"sha256": null,
"sizeBytes": null,
"isConfigFile": false,
"digests": {}
},
{
"path": "/bin/busybox",
"layerDigest": null,
"sha256": null,
"sizeBytes": null,
"isConfigFile": false,
"digests": {}
},
{
"path": "/etc/",
"layerDigest": null,
"sha256": null,
"sizeBytes": null,
"isConfigFile": false,
"digests": {}
},
{
"path": "/etc/profile",
"layerDigest": null,
"sha256": "0f1e2d3c4b5a6978ffeeddccbbaa9988",
"sizeBytes": null,
"isConfigFile": false,
"digests": {
"sha256": "0f1e2d3c4b5a6978ffeeddccbbaa9988"
}
}
],
"vendorMetadata": {
"buildTime": "1729286400",
"checksum": "deadbeef12345678",
"description": "BusyBox utility set",
"homepage": "https://busybox.net/",
"maintainer": "Stella Ops \u003Cops@stella-ops.org\u003E",
"origin": "busybox"
}
}
]
}
]

View File

@@ -0,0 +1,64 @@
[
{
"analyzerId": "dpkg",
"durationMilliseconds": 0,
"packageCount": 1,
"fileEvidenceCount": 3,
"warnings": [],
"packages": [
{
"packageUrl": "pkg:deb/debian/bash@5.2.21-2?arch=amd64",
"name": "bash",
"version": "5.2.21",
"architecture": "amd64",
"epoch": null,
"release": "2",
"sourcePackage": "bash",
"license": null,
"evidenceSource": "DpkgStatus",
"cveHints": [],
"provides": [],
"depends": [],
"files": [
{
"path": "/bin/bash",
"layerDigest": null,
"sha256": null,
"sizeBytes": null,
"isConfigFile": false,
"digests": {
"md5": "0123456789abcdef0123456789abcdef"
}
},
{
"path": "/etc/bash.bashrc",
"layerDigest": null,
"sha256": null,
"sizeBytes": null,
"isConfigFile": true,
"digests": {
"md5": "abcdef1234567890abcdef1234567890"
}
},
{
"path": "/usr/share/doc/bash/changelog.Debian.gz",
"layerDigest": null,
"sha256": null,
"sizeBytes": null,
"isConfigFile": false,
"digests": {}
}
],
"vendorMetadata": {
"dpkg:Installed-Size": "1024",
"homepage": "https://www.gnu.org/software/bash/",
"maintainer": "Debian Developers \u003Cdebian-devel@lists.debian.org\u003E",
"origin": null,
"priority": "important",
"section": "shells",
"source": "bash (5.2.21-2)"
}
}
]
}
]

View File

@@ -0,0 +1,64 @@
[
{
"analyzerId": "rpm",
"durationMilliseconds": 0,
"packageCount": 1,
"fileEvidenceCount": 2,
"warnings": [],
"packages": [
{
"packageUrl": "pkg:rpm/openssl-libs@1:3.2.1-8.el9?arch=x86_64",
"name": "openssl-libs",
"version": "3.2.1",
"architecture": "x86_64",
"epoch": "1",
"release": "8.el9",
"sourcePackage": "openssl-3.2.1-8.el9.src.rpm",
"license": "OpenSSL",
"evidenceSource": "RpmDatabase",
"cveHints": [
"CVE-2025-1234"
],
"provides": [
"libcrypto.so.3()(64bit)",
"openssl-libs"
],
"depends": [
"glibc(x86-64) \u003E= 2.34"
],
"files": [
{
"path": "/etc/pki/tls/openssl.cnf",
"layerDigest": null,
"sha256": null,
"sizeBytes": null,
"isConfigFile": true,
"digests": {
"md5": "c0ffee"
}
},
{
"path": "/usr/lib64/libcrypto.so.3",
"layerDigest": null,
"sha256": "abc123",
"sizeBytes": null,
"isConfigFile": false,
"digests": {
"sha256": "abc123"
}
}
],
"vendorMetadata": {
"buildTime": null,
"description": null,
"installTime": null,
"rpm:summary": "TLS toolkit",
"sourceRpm": "openssl-3.2.1-8.el9.src.rpm",
"summary": "TLS toolkit",
"url": null,
"vendor": null
}
}
]
}
]

View File

@@ -0,0 +1,76 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using StellaOps.Scanner.Analyzers.OS.Mapping;
using StellaOps.Scanner.Core.Contracts;
using Xunit;
namespace StellaOps.Scanner.Analyzers.OS.Tests.Mapping;
public class OsComponentMapperTests
{
[Fact]
public void ToLayerFragments_ProducesDeterministicComponents()
{
var package = new OSPackageRecord(
analyzerId: "apk",
packageUrl: "pkg:alpine/busybox@1.37.0-r0?arch=x86_64",
name: "busybox",
version: "1.37.0",
architecture: "x86_64",
evidenceSource: PackageEvidenceSource.ApkDatabase,
release: "r0",
sourcePackage: "busybox",
license: "GPL-2.0-only",
depends: new[] { "musl>=1.2.5-r0", "ssl_client" },
files: new[]
{
new OSPackageFileEvidence("/bin/busybox", sha256: "abc123", isConfigFile: false),
new OSPackageFileEvidence("/etc/profile", isConfigFile: true, digests: new Dictionary<string, string> { ["md5"] = "deadbeef" }),
},
vendorMetadata: new Dictionary<string, string?>
{
["homepage"] = "https://busybox.net/",
});
var result = new OSPackageAnalyzerResult(
analyzerId: "apk",
packages: ImmutableArray.Create(package),
telemetry: new OSAnalyzerTelemetry(System.TimeSpan.Zero, 1, 2));
var fragments = OsComponentMapper.ToLayerFragments(new[] { result });
Assert.Single(fragments);
var fragment = fragments[0];
Assert.StartsWith("sha256:", fragment.LayerDigest);
Assert.Single(fragment.Components);
var component = fragment.Components[0];
Assert.Equal(fragment.LayerDigest, component.LayerDigest);
Assert.Equal("pkg:alpine/busybox@1.37.0-r0?arch=x86_64", component.Identity.Key);
Assert.Equal("busybox", component.Identity.Name);
Assert.Equal("1.37.0", component.Identity.Version);
Assert.Equal("pkg:alpine/busybox@1.37.0-r0?arch=x86_64", component.Identity.Purl);
Assert.Equal("os-package", component.Identity.ComponentType);
Assert.Equal("busybox", component.Identity.Group);
Assert.Collection(component.Evidence,
evidence =>
{
Assert.Equal("file", evidence.Kind);
Assert.Equal("/bin/busybox", evidence.Value);
Assert.Equal("abc123", evidence.Source);
},
evidence =>
{
Assert.Equal("config-file", evidence.Kind);
Assert.Equal("/etc/profile", evidence.Value);
Assert.Null(evidence.Source);
});
Assert.Equal(new[] { "musl>=1.2.5-r0", "ssl_client" }, component.Dependencies);
Assert.False(component.Usage.UsedByEntrypoint);
Assert.NotNull(component.Metadata);
Assert.Equal(new[] { "GPL-2.0-only" }, component.Metadata!.Licenses);
Assert.Contains("stellaops.os.analyzer", component.Metadata.Properties!.Keys);
Assert.Equal("apk", component.Metadata.Properties!["stellaops.os.analyzer"]);
Assert.Equal("https://busybox.net/", component.Metadata.Properties!["vendor.homepage"]);
}
}

View File

@@ -0,0 +1,137 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.Analyzers.OS;
using StellaOps.Scanner.Analyzers.OS.Apk;
using StellaOps.Scanner.Analyzers.OS.Dpkg;
using StellaOps.Scanner.Analyzers.OS.Rpm;
using StellaOps.Scanner.Analyzers.OS.Rpm.Internal;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Analyzers.OS.Tests.TestUtilities;
using Xunit;
namespace StellaOps.Scanner.Analyzers.OS.Tests;
public sealed class OsAnalyzerDeterminismTests
{
[Fact]
public async Task ApkAnalyzerMatchesGolden()
{
using var fixture = FixtureManager.UseFixture("apk", out var rootPath);
var analyzer = new ApkPackageAnalyzer(NullLogger<ApkPackageAnalyzer>.Instance);
var context = CreateContext(rootPath);
var result = await analyzer.AnalyzeAsync(context, CancellationToken.None);
var snapshot = SnapshotSerializer.Serialize(new[] { result });
GoldenAssert.MatchSnapshot(snapshot, FixtureManager.GetGoldenPath("apk.json"));
}
[Fact]
public async Task DpkgAnalyzerMatchesGolden()
{
using var fixture = FixtureManager.UseFixture("dpkg", out var rootPath);
var analyzer = new DpkgPackageAnalyzer(NullLogger<DpkgPackageAnalyzer>.Instance);
var context = CreateContext(rootPath);
var result = await analyzer.AnalyzeAsync(context, CancellationToken.None);
var snapshot = SnapshotSerializer.Serialize(new[] { result });
GoldenAssert.MatchSnapshot(snapshot, FixtureManager.GetGoldenPath("dpkg.json"));
}
[Fact]
public async Task RpmAnalyzerMatchesGolden()
{
var headers = new[]
{
CreateRpmHeader(
name: "openssl-libs",
version: "3.2.1",
architecture: "x86_64",
release: "8.el9",
epoch: "1",
license: "OpenSSL",
sourceRpm: "openssl-3.2.1-8.el9.src.rpm",
provides: new[] { "libcrypto.so.3()(64bit)", "openssl-libs" },
requires: new[] { "glibc(x86-64) >= 2.34" },
files: new[]
{
new RpmFileEntry("/usr/lib64/libcrypto.so.3", false, new Dictionary<string, string> { ["sha256"] = "abc123" }),
new RpmFileEntry("/etc/pki/tls/openssl.cnf", true, new Dictionary<string, string> { ["md5"] = "c0ffee" })
},
changeLogs: new[] { "Resolves: CVE-2025-1234" },
metadata: new Dictionary<string, string?> { ["summary"] = "TLS toolkit" })
};
var reader = new StubRpmDatabaseReader(headers);
var analyzer = new RpmPackageAnalyzer(
NullLogger<RpmPackageAnalyzer>.Instance,
reader);
var context = CreateContext("/tmp/nonexistent");
var result = await analyzer.AnalyzeAsync(context, CancellationToken.None);
var snapshot = SnapshotSerializer.Serialize(new[] { result });
GoldenAssert.MatchSnapshot(snapshot, FixtureManager.GetGoldenPath("rpm.json"));
}
private static OSPackageAnalyzerContext CreateContext(string rootPath)
{
var metadata = new Dictionary<string, string>
{
[ScanMetadataKeys.RootFilesystemPath] = rootPath
};
return new OSPackageAnalyzerContext(rootPath, workspacePath: null, TimeProvider.System, NullLoggerFactory.Instance.CreateLogger("os-analyzer-tests"), metadata);
}
private static RpmHeader CreateRpmHeader(
string name,
string version,
string architecture,
string? release,
string? epoch,
string? license,
string? sourceRpm,
IReadOnlyList<string> provides,
IReadOnlyList<string> requires,
IReadOnlyList<RpmFileEntry> files,
IReadOnlyList<string> changeLogs,
IReadOnlyDictionary<string, string?> metadata)
{
return new RpmHeader(
name,
version,
architecture,
release,
epoch,
metadata.TryGetValue("summary", out var summary) ? summary : null,
metadata.TryGetValue("description", out var description) ? description : null,
license,
sourceRpm,
metadata.TryGetValue("url", out var url) ? url : null,
metadata.TryGetValue("vendor", out var vendor) ? vendor : null,
buildTime: null,
installTime: null,
provides,
provideVersions: provides.Select(_ => string.Empty).ToArray(),
requires,
requireVersions: requires.Select(_ => string.Empty).ToArray(),
files,
changeLogs,
metadata);
}
private sealed class StubRpmDatabaseReader : IRpmDatabaseReader
{
private readonly IReadOnlyList<RpmHeader> _headers;
public StubRpmDatabaseReader(IReadOnlyList<RpmHeader> headers)
{
_headers = headers;
}
public IReadOnlyList<RpmHeader> ReadHeaders(string rootPath, CancellationToken cancellationToken)
=> _headers;
}
}

View File

@@ -0,0 +1,29 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.OS/StellaOps.Scanner.Analyzers.OS.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.OS.Apk/StellaOps.Scanner.Analyzers.OS.Apk.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.OS.Dpkg/StellaOps.Scanner.Analyzers.OS.Dpkg.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.OS.Rpm/StellaOps.Scanner.Analyzers.OS.Rpm.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,75 @@
using System;
using System.IO;
namespace StellaOps.Scanner.Analyzers.OS.Tests.TestUtilities;
internal static class FixtureManager
{
public static IDisposable UseFixture(string name, out string rootPath)
{
var basePath = Path.Combine(AppContext.BaseDirectory, "Fixtures", name);
if (!Directory.Exists(basePath))
{
throw new DirectoryNotFoundException($"Fixture '{name}' was not found at '{basePath}'.");
}
var tempRoot = Path.Combine(Path.GetTempPath(), "stellaops-os-fixture", name, Guid.NewGuid().ToString("n"));
CopyDirectory(basePath, tempRoot);
rootPath = tempRoot;
return new Disposable(() => DeleteDirectory(tempRoot));
}
public static string GetGoldenPath(string name)
=> Path.Combine(AppContext.BaseDirectory, "Fixtures", "goldens", name);
private static void CopyDirectory(string source, string destination)
{
Directory.CreateDirectory(destination);
foreach (var file in Directory.GetFiles(source, "*", SearchOption.AllDirectories))
{
var relative = Path.GetRelativePath(source, file);
var target = Path.Combine(destination, relative);
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
File.Copy(file, target);
}
}
private static void DeleteDirectory(string path)
{
if (!Directory.Exists(path))
{
return;
}
try
{
Directory.Delete(path, recursive: true);
}
catch
{
// best-effort cleanup
}
}
private sealed class Disposable : IDisposable
{
private readonly Action _dispose;
private bool _disposed;
public Disposable(Action dispose)
{
_dispose = dispose;
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_dispose();
}
}
}

View File

@@ -0,0 +1,41 @@
using System;
using System.IO;
using Xunit;
namespace StellaOps.Scanner.Analyzers.OS.Tests.TestUtilities;
internal static class GoldenAssert
{
private const string UpdateEnvironmentVariable = "UPDATE_OS_ANALYZER_FIXTURES";
public static void MatchSnapshot(string snapshot, string goldenPath)
{
var directory = Path.GetDirectoryName(goldenPath);
if (!string.IsNullOrWhiteSpace(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
snapshot = Normalize(snapshot);
if (!File.Exists(goldenPath))
{
File.WriteAllText(goldenPath, snapshot);
return;
}
if (ShouldUpdate())
{
File.WriteAllText(goldenPath, snapshot);
}
var expected = Normalize(File.ReadAllText(goldenPath));
Assert.Equal(expected.TrimEnd(), snapshot.TrimEnd());
}
private static bool ShouldUpdate()
=> string.Equals(Environment.GetEnvironmentVariable(UpdateEnvironmentVariable), "1", StringComparison.OrdinalIgnoreCase);
private static string Normalize(string value)
=> value.Replace("\r\n", "\n");
}

View File

@@ -0,0 +1,106 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Scanner.Analyzers.OS;
namespace StellaOps.Scanner.Analyzers.OS.Tests.TestUtilities;
internal static class SnapshotSerializer
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
Converters =
{
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)
}
};
public static string Serialize(IEnumerable<OSPackageAnalyzerResult> results)
{
var ordered = results
.OrderBy(r => r.AnalyzerId, StringComparer.OrdinalIgnoreCase)
.Select(result => new AnalyzerSnapshot
{
AnalyzerId = result.AnalyzerId,
PackageCount = result.Telemetry.PackageCount,
FileEvidenceCount = result.Telemetry.FileEvidenceCount,
DurationMilliseconds = 0,
Warnings = result.Warnings.Select(w => new WarningSnapshot(w.Code, w.Message)).ToArray(),
Packages = result.Packages
.OrderBy(p => p, Comparer<OSPackageRecord>.Default)
.Select(p => new PackageSnapshot
{
PackageUrl = p.PackageUrl,
Name = p.Name,
Version = p.Version,
Architecture = p.Architecture,
Epoch = p.Epoch,
Release = p.Release,
SourcePackage = p.SourcePackage,
License = p.License,
EvidenceSource = p.EvidenceSource.ToString(),
CveHints = p.CveHints,
Provides = p.Provides,
Depends = p.Depends,
Files = p.Files.Select(f => new FileSnapshot
{
Path = f.Path,
LayerDigest = f.LayerDigest,
Sha256 = f.Sha256,
SizeBytes = f.SizeBytes,
IsConfigFile = f.IsConfigFile,
Digests = f.Digests.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase).ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase)
}).ToArray(),
VendorMetadata = p.VendorMetadata.OrderBy(kv => kv.Key, StringComparer.Ordinal).ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal)
}).ToArray()
})
.ToArray();
return JsonSerializer.Serialize(ordered, Options);
}
private sealed record AnalyzerSnapshot
{
public string AnalyzerId { get; init; } = string.Empty;
public double DurationMilliseconds { get; init; }
public int PackageCount { get; init; }
public int FileEvidenceCount { get; init; }
public IReadOnlyList<WarningSnapshot> Warnings { get; init; } = Array.Empty<WarningSnapshot>();
public IReadOnlyList<PackageSnapshot> Packages { get; init; } = Array.Empty<PackageSnapshot>();
}
private sealed record WarningSnapshot(string Code, string Message);
private sealed record PackageSnapshot
{
public string PackageUrl { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string Version { get; init; } = string.Empty;
public string Architecture { get; init; } = string.Empty;
public string? Epoch { get; init; }
public string? Release { get; init; }
public string? SourcePackage { get; init; }
public string? License { get; init; }
public string EvidenceSource { get; init; } = string.Empty;
public IReadOnlyList<string> CveHints { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> Provides { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> Depends { get; init; } = Array.Empty<string>();
public IReadOnlyList<FileSnapshot> Files { get; init; } = Array.Empty<FileSnapshot>();
public IReadOnlyDictionary<string, string?> VendorMetadata { get; init; } = new Dictionary<string, string?>();
}
private sealed record FileSnapshot
{
public string Path { get; init; } = string.Empty;
public string? LayerDigest { get; init; }
public string? Sha256 { get; init; }
public long? SizeBytes { get; init; }
public bool? IsConfigFile { get; init; }
public IReadOnlyDictionary<string, string> Digests { get; init; } = new Dictionary<string, string>();
}
}