up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -1,24 +1,24 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Analyzers.OS.Abstractions;
/// <summary>
/// Represents a deterministic analyzer capable of extracting operating-system package
/// evidence from a container root filesystem snapshot.
/// </summary>
public interface IOSPackageAnalyzer
{
/// <summary>
/// Gets the identifier used for logging and manifest composition (e.g. <c>apk</c>, <c>dpkg</c>).
/// </summary>
string AnalyzerId { get; }
/// <summary>
/// Executes the analyzer against the provided context, producing a deterministic set of packages.
/// </summary>
/// <param name="context">Analysis context surfaced by the worker.</param>
/// <param name="cancellationToken">Cancellation token propagated from the orchestration pipeline.</param>
/// <returns>A result describing discovered packages, metadata, and telemetry.</returns>
ValueTask<OSPackageAnalyzerResult> AnalyzeAsync(OSPackageAnalyzerContext context, CancellationToken cancellationToken);
}
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Analyzers.OS.Abstractions;
/// <summary>
/// Represents a deterministic analyzer capable of extracting operating-system package
/// evidence from a container root filesystem snapshot.
/// </summary>
public interface IOSPackageAnalyzer
{
/// <summary>
/// Gets the identifier used for logging and manifest composition (e.g. <c>apk</c>, <c>dpkg</c>).
/// </summary>
string AnalyzerId { get; }
/// <summary>
/// Executes the analyzer against the provided context, producing a deterministic set of packages.
/// </summary>
/// <param name="context">Analysis context surfaced by the worker.</param>
/// <param name="cancellationToken">Cancellation token propagated from the orchestration pipeline.</param>
/// <returns>A result describing discovered packages, metadata, and telemetry.</returns>
ValueTask<OSPackageAnalyzerResult> AnalyzeAsync(OSPackageAnalyzerContext context, CancellationToken cancellationToken);
}

View File

@@ -1,41 +1,77 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Analyzers.OS.Abstractions;
namespace StellaOps.Scanner.Analyzers.OS.Analyzers;
public abstract class OsPackageAnalyzerBase : IOSPackageAnalyzer
{
protected OsPackageAnalyzerBase(ILogger logger)
{
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public abstract string AnalyzerId { get; }
protected ILogger Logger { get; }
public async ValueTask<OSPackageAnalyzerResult> AnalyzeAsync(OSPackageAnalyzerContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var stopwatch = Stopwatch.StartNew();
var packages = await ExecuteCoreAsync(context, cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
var packageCount = packages.Count;
var fileEvidenceCount = 0;
foreach (var package in packages)
{
fileEvidenceCount += package.Files.Count;
}
var telemetry = new OSAnalyzerTelemetry(stopwatch.Elapsed, packageCount, fileEvidenceCount);
return new OSPackageAnalyzerResult(AnalyzerId, packages, telemetry);
}
protected abstract ValueTask<IReadOnlyList<OSPackageRecord>> ExecuteCoreAsync(OSPackageAnalyzerContext context, CancellationToken cancellationToken);
}
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Analyzers.OS.Abstractions;
namespace StellaOps.Scanner.Analyzers.OS.Analyzers;
public abstract class OsPackageAnalyzerBase : IOSPackageAnalyzer
{
private static readonly IReadOnlyList<AnalyzerWarning> EmptyWarnings =
new ReadOnlyCollection<AnalyzerWarning>(Array.Empty<AnalyzerWarning>());
private const int MaxWarningCount = 50;
protected readonly record struct ExecutionResult(IReadOnlyList<OSPackageRecord> Packages, IReadOnlyList<AnalyzerWarning> Warnings)
{
public static ExecutionResult FromPackages(IReadOnlyList<OSPackageRecord> packages)
=> new(packages, EmptyWarnings);
public static ExecutionResult From(IReadOnlyList<OSPackageRecord> packages, IReadOnlyList<AnalyzerWarning> warnings)
=> new(packages, warnings);
}
protected OsPackageAnalyzerBase(ILogger logger)
{
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public abstract string AnalyzerId { get; }
protected ILogger Logger { get; }
public async ValueTask<OSPackageAnalyzerResult> AnalyzeAsync(OSPackageAnalyzerContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var stopwatch = Stopwatch.StartNew();
var core = await ExecuteCoreAsync(context, cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
var packages = core.Packages ?? Array.Empty<OSPackageRecord>();
var packageCount = packages.Count;
var fileEvidenceCount = 0;
foreach (var package in packages)
{
fileEvidenceCount += package.Files.Count;
}
var telemetry = new OSAnalyzerTelemetry(stopwatch.Elapsed, packageCount, fileEvidenceCount);
var warnings = NormalizeWarnings(core.Warnings);
return new OSPackageAnalyzerResult(AnalyzerId, packages, telemetry, warnings);
}
protected abstract ValueTask<ExecutionResult> ExecuteCoreAsync(OSPackageAnalyzerContext context, CancellationToken cancellationToken);
private static IReadOnlyList<AnalyzerWarning> NormalizeWarnings(IReadOnlyList<AnalyzerWarning>? warnings)
{
if (warnings is null || warnings.Count == 0)
{
return EmptyWarnings;
}
var buffer = warnings
.Where(static warning => warning is not null && !string.IsNullOrWhiteSpace(warning.Code) && !string.IsNullOrWhiteSpace(warning.Message))
.DistinctBy(static warning => (warning.Code, warning.Message))
.OrderBy(static warning => warning.Code, StringComparer.Ordinal)
.ThenBy(static warning => warning.Message, StringComparer.Ordinal)
.Take(MaxWarningCount)
.ToArray();
return buffer.Length == 0 ? EmptyWarnings : new ReadOnlyCollection<AnalyzerWarning>(buffer);
}
}

View File

@@ -1,39 +1,39 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.OS.Helpers;
public static class CveHintExtractor
{
private static readonly Regex CveRegex = new(@"CVE-\d{4}-\d{4,7}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
public static IReadOnlyList<string> Extract(params string?[] inputs)
{
if (inputs is { Length: > 0 })
{
var set = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var input in inputs)
{
if (string.IsNullOrWhiteSpace(input))
{
continue;
}
foreach (Match match in CveRegex.Matches(input))
{
set.Add(match.Value.ToUpperInvariant());
}
}
if (set.Count > 0)
{
return new ReadOnlyCollection<string>(set.ToArray());
}
}
return Array.Empty<string>();
}
}
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.OS.Helpers;
public static class CveHintExtractor
{
private static readonly Regex CveRegex = new(@"CVE-\d{4}-\d{4,7}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
public static IReadOnlyList<string> Extract(params string?[] inputs)
{
if (inputs is { Length: > 0 })
{
var set = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var input in inputs)
{
if (string.IsNullOrWhiteSpace(input))
{
continue;
}
foreach (Match match in CveRegex.Matches(input))
{
set.Add(match.Value.ToUpperInvariant());
}
}
if (set.Count > 0)
{
return new ReadOnlyCollection<string>(set.ToArray());
}
}
return Array.Empty<string>();
}
}

View File

@@ -15,6 +15,8 @@ namespace StellaOps.Scanner.Analyzers.OS.Helpers;
/// </summary>
public sealed class OsFileEvidenceFactory
{
private const long MaxComputedSha256Bytes = 16L * 1024L * 1024L;
private readonly string _rootPath;
private readonly ImmutableArray<(string? Digest, string Path)> _layerDirectories;
private readonly string? _defaultLayerDigest;
@@ -55,7 +57,12 @@ public sealed class OsFileEvidenceFactory
var info = new FileInfo(fullPath);
size = info.Length;
if (info.Length > 0 && !digestMap.TryGetValue("sha256", out sha256))
digestMap.TryGetValue("sha256", out sha256);
if (info.Length > 0 &&
sha256 is null &&
digestMap.Count == 0 &&
info.Length <= MaxComputedSha256Bytes)
{
sha256 = ComputeSha256(fullPath);
digestMap["sha256"] = sha256;

View File

@@ -0,0 +1,45 @@
namespace StellaOps.Scanner.Analyzers.OS.Helpers;
public static class OsPath
{
public static string? NormalizeRelative(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
var trimmed = path.Trim().TrimStart('/', '\\');
return trimmed.Replace('\\', '/');
}
public static string? TryGetRootfsRelative(string rootPath, string fullPath)
{
if (string.IsNullOrWhiteSpace(rootPath) || string.IsNullOrWhiteSpace(fullPath))
{
return null;
}
try
{
var fullRoot = Path.GetFullPath(rootPath);
var full = Path.GetFullPath(fullPath);
var relative = Path.GetRelativePath(fullRoot, full);
relative = NormalizeRelative(relative);
if (relative is null ||
relative == "." ||
relative.StartsWith("..", StringComparison.Ordinal))
{
return null;
}
return relative;
}
catch (Exception ex) when (ex is ArgumentException or PathTooLongException or NotSupportedException)
{
return null;
}
}
}

View File

@@ -1,171 +1,171 @@
using System;
using System.Text;
namespace StellaOps.Scanner.Analyzers.OS.Helpers;
public static class PackageUrlBuilder
{
public static string BuildAlpine(string name, string version, string architecture)
=> $"pkg:alpine/{Escape(name)}@{Escape(version)}?arch={EscapeQuery(architecture)}";
public static string BuildDebian(string distribution, string name, string version, string architecture)
{
var distro = string.IsNullOrWhiteSpace(distribution) ? "debian" : distribution.Trim().ToLowerInvariant();
return $"pkg:deb/{Escape(distro)}/{Escape(name)}@{Escape(version)}?arch={EscapeQuery(architecture)}";
}
public static string BuildRpm(string name, string? epoch, string version, string? release, string architecture)
{
var versionComponent = string.IsNullOrWhiteSpace(epoch)
? Escape(version)
: $"{Escape(epoch)}:{Escape(version)}";
var releaseComponent = string.IsNullOrWhiteSpace(release)
? string.Empty
: $"-{Escape(release!)}";
return $"pkg:rpm/{Escape(name)}@{versionComponent}{releaseComponent}?arch={EscapeQuery(architecture)}";
}
/// <summary>
/// Builds a PURL for a Homebrew formula.
/// Format: pkg:brew/{tap}/{formula}@{version}?revision={revision}
/// </summary>
public static string BuildHomebrew(string tap, string formula, string version, int revision)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tap);
ArgumentException.ThrowIfNullOrWhiteSpace(formula);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
var normalizedTap = tap.Trim().ToLowerInvariant();
var builder = new StringBuilder();
builder.Append("pkg:brew/");
builder.Append(Escape(normalizedTap));
builder.Append('/');
builder.Append(Escape(formula));
builder.Append('@');
builder.Append(Escape(version));
if (revision > 0)
{
builder.Append("?revision=");
builder.Append(revision);
}
return builder.ToString();
}
/// <summary>
/// Builds a PURL for a macOS pkgutil receipt.
/// Format: pkg:generic/apple/{identifier}@{version}
/// </summary>
public static string BuildPkgutil(string identifier, string version)
{
ArgumentException.ThrowIfNullOrWhiteSpace(identifier);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
return $"pkg:generic/apple/{Escape(identifier)}@{Escape(version)}";
}
/// <summary>
/// Builds a PURL for a macOS application bundle.
/// Format: pkg:generic/macos-app/{bundleId}@{version}
/// </summary>
public static string BuildMacOsBundle(string bundleId, string version)
{
ArgumentException.ThrowIfNullOrWhiteSpace(bundleId);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
return $"pkg:generic/macos-app/{Escape(bundleId)}@{Escape(version)}";
}
/// <summary>
/// Builds a PURL for a Windows MSI package.
/// Format: pkg:generic/windows-msi/{productName}@{version}?upgrade_code={upgradeCode}
/// </summary>
public static string BuildWindowsMsi(string productName, string version, string? upgradeCode = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(productName);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
var normalizedName = productName.Trim().ToLowerInvariant().Replace(' ', '-');
var builder = new StringBuilder();
builder.Append("pkg:generic/windows-msi/");
builder.Append(Escape(normalizedName));
builder.Append('@');
builder.Append(Escape(version));
if (!string.IsNullOrWhiteSpace(upgradeCode))
{
builder.Append("?upgrade_code=");
builder.Append(EscapeQuery(upgradeCode));
}
return builder.ToString();
}
/// <summary>
/// Builds a PURL for a Windows WinSxS assembly.
/// Format: pkg:generic/windows-winsxs/{assemblyName}@{version}?arch={arch}
/// </summary>
public static string BuildWindowsWinSxS(string assemblyName, string version, string? architecture = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(assemblyName);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
var normalizedName = assemblyName.Trim().ToLowerInvariant();
var builder = new StringBuilder();
builder.Append("pkg:generic/windows-winsxs/");
builder.Append(Escape(normalizedName));
builder.Append('@');
builder.Append(Escape(version));
if (!string.IsNullOrWhiteSpace(architecture))
{
builder.Append("?arch=");
builder.Append(EscapeQuery(architecture));
}
return builder.ToString();
}
/// <summary>
/// Builds a PURL for a Windows Chocolatey package.
/// Format: pkg:chocolatey/{packageId}@{version}
/// </summary>
public static string BuildChocolatey(string packageId, string version)
{
ArgumentException.ThrowIfNullOrWhiteSpace(packageId);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
var normalizedId = packageId.Trim().ToLowerInvariant();
return $"pkg:chocolatey/{Escape(normalizedId)}@{Escape(version)}";
}
private static string Escape(string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
return Uri.EscapeDataString(value.Trim());
}
private static string EscapeQuery(string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
var trimmed = value.Trim();
var builder = new StringBuilder(trimmed.Length);
foreach (var ch in trimmed)
{
if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || ch == '.' || ch == '~')
{
builder.Append(ch);
}
else
{
builder.Append('%');
builder.Append(((int)ch).ToString("X2"));
}
}
return builder.ToString();
}
}
using System;
using System.Text;
namespace StellaOps.Scanner.Analyzers.OS.Helpers;
public static class PackageUrlBuilder
{
public static string BuildAlpine(string name, string version, string architecture)
=> $"pkg:alpine/{Escape(name)}@{Escape(version)}?arch={EscapeQuery(architecture)}";
public static string BuildDebian(string distribution, string name, string version, string architecture)
{
var distro = string.IsNullOrWhiteSpace(distribution) ? "debian" : distribution.Trim().ToLowerInvariant();
return $"pkg:deb/{Escape(distro)}/{Escape(name)}@{Escape(version)}?arch={EscapeQuery(architecture)}";
}
public static string BuildRpm(string name, string? epoch, string version, string? release, string architecture)
{
var versionComponent = string.IsNullOrWhiteSpace(epoch)
? Escape(version)
: $"{Escape(epoch)}:{Escape(version)}";
var releaseComponent = string.IsNullOrWhiteSpace(release)
? string.Empty
: $"-{Escape(release!)}";
return $"pkg:rpm/{Escape(name)}@{versionComponent}{releaseComponent}?arch={EscapeQuery(architecture)}";
}
/// <summary>
/// Builds a PURL for a Homebrew formula.
/// Format: pkg:brew/{tap}/{formula}@{version}?revision={revision}
/// </summary>
public static string BuildHomebrew(string tap, string formula, string version, int revision)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tap);
ArgumentException.ThrowIfNullOrWhiteSpace(formula);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
var normalizedTap = tap.Trim().ToLowerInvariant();
var builder = new StringBuilder();
builder.Append("pkg:brew/");
builder.Append(Escape(normalizedTap));
builder.Append('/');
builder.Append(Escape(formula));
builder.Append('@');
builder.Append(Escape(version));
if (revision > 0)
{
builder.Append("?revision=");
builder.Append(revision);
}
return builder.ToString();
}
/// <summary>
/// Builds a PURL for a macOS pkgutil receipt.
/// Format: pkg:generic/apple/{identifier}@{version}
/// </summary>
public static string BuildPkgutil(string identifier, string version)
{
ArgumentException.ThrowIfNullOrWhiteSpace(identifier);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
return $"pkg:generic/apple/{Escape(identifier)}@{Escape(version)}";
}
/// <summary>
/// Builds a PURL for a macOS application bundle.
/// Format: pkg:generic/macos-app/{bundleId}@{version}
/// </summary>
public static string BuildMacOsBundle(string bundleId, string version)
{
ArgumentException.ThrowIfNullOrWhiteSpace(bundleId);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
return $"pkg:generic/macos-app/{Escape(bundleId)}@{Escape(version)}";
}
/// <summary>
/// Builds a PURL for a Windows MSI package.
/// Format: pkg:generic/windows-msi/{productName}@{version}?upgrade_code={upgradeCode}
/// </summary>
public static string BuildWindowsMsi(string productName, string version, string? upgradeCode = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(productName);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
var normalizedName = productName.Trim().ToLowerInvariant().Replace(' ', '-');
var builder = new StringBuilder();
builder.Append("pkg:generic/windows-msi/");
builder.Append(Escape(normalizedName));
builder.Append('@');
builder.Append(Escape(version));
if (!string.IsNullOrWhiteSpace(upgradeCode))
{
builder.Append("?upgrade_code=");
builder.Append(EscapeQuery(upgradeCode));
}
return builder.ToString();
}
/// <summary>
/// Builds a PURL for a Windows WinSxS assembly.
/// Format: pkg:generic/windows-winsxs/{assemblyName}@{version}?arch={arch}
/// </summary>
public static string BuildWindowsWinSxS(string assemblyName, string version, string? architecture = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(assemblyName);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
var normalizedName = assemblyName.Trim().ToLowerInvariant();
var builder = new StringBuilder();
builder.Append("pkg:generic/windows-winsxs/");
builder.Append(Escape(normalizedName));
builder.Append('@');
builder.Append(Escape(version));
if (!string.IsNullOrWhiteSpace(architecture))
{
builder.Append("?arch=");
builder.Append(EscapeQuery(architecture));
}
return builder.ToString();
}
/// <summary>
/// Builds a PURL for a Windows Chocolatey package.
/// Format: pkg:chocolatey/{packageId}@{version}
/// </summary>
public static string BuildChocolatey(string packageId, string version)
{
ArgumentException.ThrowIfNullOrWhiteSpace(packageId);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
var normalizedId = packageId.Trim().ToLowerInvariant();
return $"pkg:chocolatey/{Escape(normalizedId)}@{Escape(version)}";
}
private static string Escape(string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
return Uri.EscapeDataString(value.Trim());
}
private static string EscapeQuery(string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
var trimmed = value.Trim();
var builder = new StringBuilder(trimmed.Length);
foreach (var ch in trimmed)
{
if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || ch == '.' || ch == '~')
{
builder.Append(ch);
}
else
{
builder.Append('%');
builder.Append(((int)ch).ToString("X2"));
}
}
return builder.ToString();
}
}

View File

@@ -1,57 +1,57 @@
using System;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.OS.Helpers;
public static class PackageVersionParser
{
private static readonly Regex DebianVersionRegex = new(@"^(?<epoch>\d+):(?<version>.+)$", RegexOptions.Compiled);
private static readonly Regex DebianRevisionRegex = new(@"^(?<base>.+?)(?<revision>-[^-]+)?$", RegexOptions.Compiled);
private static readonly Regex ApkVersionRegex = new(@"^(?<version>.+?)(?:-(?<release>r\d+))?$", RegexOptions.Compiled);
public static DebianVersionParts ParseDebianVersion(string version)
{
ArgumentException.ThrowIfNullOrWhiteSpace(version);
var trimmed = version.Trim();
string? epoch = null;
string baseVersion = trimmed;
var epochMatch = DebianVersionRegex.Match(trimmed);
if (epochMatch.Success)
{
epoch = epochMatch.Groups["epoch"].Value;
baseVersion = epochMatch.Groups["version"].Value;
}
string? revision = null;
var revisionMatch = DebianRevisionRegex.Match(baseVersion);
if (revisionMatch.Success && revisionMatch.Groups["revision"].Success)
{
revision = revisionMatch.Groups["revision"].Value.TrimStart('-');
baseVersion = revisionMatch.Groups["base"].Value;
}
return new DebianVersionParts(epoch, baseVersion, revision, trimmed);
}
public static ApkVersionParts ParseApkVersion(string version)
{
ArgumentException.ThrowIfNullOrWhiteSpace(version);
var match = ApkVersionRegex.Match(version.Trim());
if (!match.Success)
{
return new ApkVersionParts(null, version.Trim());
}
var release = match.Groups["release"].Success ? match.Groups["release"].Value : null;
return new ApkVersionParts(release, match.Groups["version"].Value);
}
}
public sealed record DebianVersionParts(string? Epoch, string UpstreamVersion, string? Revision, string Original)
{
public string ForPackageUrl => Epoch is null ? Original : $"{Epoch}:{UpstreamVersion}{(Revision is null ? string.Empty : "-" + Revision)}";
}
public sealed record ApkVersionParts(string? Release, string BaseVersion);
using System;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.OS.Helpers;
public static class PackageVersionParser
{
private static readonly Regex DebianVersionRegex = new(@"^(?<epoch>\d+):(?<version>.+)$", RegexOptions.Compiled);
private static readonly Regex DebianRevisionRegex = new(@"^(?<base>.+?)(?<revision>-[^-]+)?$", RegexOptions.Compiled);
private static readonly Regex ApkVersionRegex = new(@"^(?<version>.+?)(?:-(?<release>r\d+))?$", RegexOptions.Compiled);
public static DebianVersionParts ParseDebianVersion(string version)
{
ArgumentException.ThrowIfNullOrWhiteSpace(version);
var trimmed = version.Trim();
string? epoch = null;
string baseVersion = trimmed;
var epochMatch = DebianVersionRegex.Match(trimmed);
if (epochMatch.Success)
{
epoch = epochMatch.Groups["epoch"].Value;
baseVersion = epochMatch.Groups["version"].Value;
}
string? revision = null;
var revisionMatch = DebianRevisionRegex.Match(baseVersion);
if (revisionMatch.Success && revisionMatch.Groups["revision"].Success)
{
revision = revisionMatch.Groups["revision"].Value.TrimStart('-');
baseVersion = revisionMatch.Groups["base"].Value;
}
return new DebianVersionParts(epoch, baseVersion, revision, trimmed);
}
public static ApkVersionParts ParseApkVersion(string version)
{
ArgumentException.ThrowIfNullOrWhiteSpace(version);
var match = ApkVersionRegex.Match(version.Trim());
if (!match.Success)
{
return new ApkVersionParts(null, version.Trim());
}
var release = match.Groups["release"].Success ? match.Groups["release"].Value : null;
return new ApkVersionParts(release, match.Groups["version"].Value);
}
}
public sealed record DebianVersionParts(string? Epoch, string UpstreamVersion, string? Revision, string Original)
{
public string ForPackageUrl => Epoch is null ? Original : $"{Epoch}:{UpstreamVersion}{(Revision is null ? string.Empty : "-" + Revision)}";
}
public sealed record ApkVersionParts(string? Release, string BaseVersion);

View File

@@ -0,0 +1,280 @@
namespace StellaOps.Scanner.Analyzers.OS.Internal;
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Surface.FS;
public readonly record struct OsAnalyzerSurfaceCacheEntry(OSPackageAnalyzerResult Result, bool IsHit);
public sealed class OsAnalyzerSurfaceCache
{
private const string CacheNamespace = "scanner/os/analyzers";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
WriteIndented = false,
};
private readonly ISurfaceCache _cache;
private readonly string _tenant;
public OsAnalyzerSurfaceCache(ISurfaceCache cache, string tenant)
{
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_tenant = string.IsNullOrWhiteSpace(tenant) ? "default" : tenant.Trim();
}
public async ValueTask<OsAnalyzerSurfaceCacheEntry> GetOrCreateEntryAsync(
ILogger logger,
string analyzerId,
string fingerprint,
Func<CancellationToken, ValueTask<OSPackageAnalyzerResult>> factory,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(factory);
if (string.IsNullOrWhiteSpace(analyzerId))
{
throw new ArgumentException("Analyzer identifier is required.", nameof(analyzerId));
}
if (string.IsNullOrWhiteSpace(fingerprint))
{
throw new ArgumentException("Fingerprint is required.", nameof(fingerprint));
}
analyzerId = analyzerId.Trim();
fingerprint = fingerprint.Trim();
var contentKey = $"{fingerprint}:{analyzerId}";
var key = new SurfaceCacheKey(CacheNamespace, _tenant, contentKey);
var cacheHit = true;
var stopwatch = Stopwatch.StartNew();
CachePayload payload;
try
{
payload = await _cache.GetOrCreateAsync(
key,
async token =>
{
cacheHit = false;
var result = await factory(token).ConfigureAwait(false);
return ToPayload(result);
},
Serialize,
Deserialize,
cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
{
cacheHit = false;
stopwatch.Stop();
logger.LogWarning(
ex,
"Surface cache lookup failed for OS analyzer {AnalyzerId} (tenant {Tenant}, fingerprint {Fingerprint}); running analyzer without cache.",
analyzerId,
_tenant,
fingerprint);
var result = await factory(cancellationToken).ConfigureAwait(false);
return new OsAnalyzerSurfaceCacheEntry(result, false);
}
stopwatch.Stop();
if (cacheHit)
{
logger.LogDebug(
"Surface cache hit for OS analyzer {AnalyzerId} (tenant {Tenant}, fingerprint {Fingerprint}).",
analyzerId,
_tenant,
fingerprint);
}
else
{
logger.LogDebug(
"Surface cache miss for OS analyzer {AnalyzerId} (tenant {Tenant}, fingerprint {Fingerprint}); stored result.",
analyzerId,
_tenant,
fingerprint);
}
var packages = payload.Packages
.Select(snapshot => snapshot.ToRecord(analyzerId))
.ToArray();
var warnings = payload.Warnings
.Select(snapshot => AnalyzerWarning.From(snapshot.Code, snapshot.Message))
.ToArray();
var fileEvidenceCount = 0;
foreach (var package in packages)
{
fileEvidenceCount += package.Files.Count;
}
var telemetry = new OSAnalyzerTelemetry(stopwatch.Elapsed, packages.Length, fileEvidenceCount);
var mappedResult = new OSPackageAnalyzerResult(analyzerId, packages, telemetry, warnings);
return new OsAnalyzerSurfaceCacheEntry(mappedResult, cacheHit);
}
private static ReadOnlyMemory<byte> Serialize(CachePayload payload)
=> JsonSerializer.SerializeToUtf8Bytes(payload, JsonOptions);
private static CachePayload Deserialize(ReadOnlyMemory<byte> payload)
{
if (payload.IsEmpty)
{
return CachePayload.Empty;
}
return JsonSerializer.Deserialize<CachePayload>(payload.Span, JsonOptions) ?? CachePayload.Empty;
}
private static CachePayload ToPayload(OSPackageAnalyzerResult result)
{
var warnings = result.Warnings
.OrderBy(static warning => warning.Code, StringComparer.Ordinal)
.ThenBy(static warning => warning.Message, StringComparer.Ordinal)
.Select(static warning => new WarningSnapshot(warning.Code, warning.Message))
.ToArray();
var packages = result.Packages
.OrderBy(static package => package, Comparer<OSPackageRecord>.Default)
.Select(static package => PackageSnapshot.FromRecord(package))
.ToArray();
return new CachePayload
{
Packages = packages,
Warnings = warnings
};
}
private sealed record CachePayload
{
public static CachePayload Empty { get; } = new()
{
Packages = Array.Empty<PackageSnapshot>(),
Warnings = Array.Empty<WarningSnapshot>()
};
public IReadOnlyList<PackageSnapshot> Packages { get; init; } = Array.Empty<PackageSnapshot>();
public IReadOnlyList<WarningSnapshot> Warnings { get; init; } = Array.Empty<WarningSnapshot>();
}
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 EvidenceSource { 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 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<FileEvidenceSnapshot> Files { get; init; } = Array.Empty<FileEvidenceSnapshot>();
public IReadOnlyDictionary<string, string?> VendorMetadata { get; init; } = new Dictionary<string, string?>(StringComparer.Ordinal);
public static PackageSnapshot FromRecord(OSPackageRecord package)
{
var files = package.Files
.OrderBy(static file => file, Comparer<OSPackageFileEvidence>.Default)
.Select(static file => FileEvidenceSnapshot.FromRecord(file))
.ToArray();
return new PackageSnapshot
{
PackageUrl = package.PackageUrl,
Name = package.Name,
Version = package.Version,
Architecture = package.Architecture,
EvidenceSource = package.EvidenceSource.ToString(),
Epoch = package.Epoch,
Release = package.Release,
SourcePackage = package.SourcePackage,
License = package.License,
CveHints = package.CveHints.ToArray(),
Provides = package.Provides.ToArray(),
Depends = package.Depends.ToArray(),
Files = files,
VendorMetadata = package.VendorMetadata.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal),
};
}
public OSPackageRecord ToRecord(string analyzerId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(analyzerId);
var evidenceSource = Enum.TryParse<PackageEvidenceSource>(EvidenceSource, ignoreCase: true, out var parsed)
? parsed
: PackageEvidenceSource.Unknown;
var files = Files.Select(static file => file.ToRecord()).ToArray();
return new OSPackageRecord(
analyzerId: analyzerId.Trim(),
packageUrl: PackageUrl,
name: Name,
version: Version,
architecture: Architecture,
evidenceSource: evidenceSource,
epoch: Epoch,
release: Release,
sourcePackage: SourcePackage,
license: License,
cveHints: CveHints,
provides: Provides,
depends: Depends,
files: files,
vendorMetadata: new Dictionary<string, string?>(VendorMetadata, StringComparer.Ordinal));
}
}
private sealed record FileEvidenceSnapshot
{
public string Path { get; init; } = string.Empty;
public string? LayerDigest { get; init; }
public long? SizeBytes { get; init; }
public bool? IsConfigFile { get; init; }
public IReadOnlyDictionary<string, string> Digests { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public static FileEvidenceSnapshot FromRecord(OSPackageFileEvidence file)
{
return new FileEvidenceSnapshot
{
Path = file.Path,
LayerDigest = file.LayerDigest,
SizeBytes = file.SizeBytes,
IsConfigFile = file.IsConfigFile,
Digests = file.Digests.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase),
};
}
public OSPackageFileEvidence ToRecord()
{
var digests = Digests.Count == 0
? null
: new Dictionary<string, string>(Digests, StringComparer.OrdinalIgnoreCase);
return new OSPackageFileEvidence(
Path,
layerDigest: LayerDigest,
sha256: null,
sizeBytes: SizeBytes,
isConfigFile: IsConfigFile,
digests: digests);
}
}
}

View File

@@ -0,0 +1,155 @@
namespace StellaOps.Scanner.Analyzers.OS.Internal;
using System.Buffers;
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
public static class OsRootfsFingerprint
{
private const long ContentHashThresholdBytes = 8L * 1024L * 1024L;
public static string? TryCompute(string analyzerId, string rootPath, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(analyzerId))
{
throw new ArgumentException("Analyzer identifier is required.", nameof(analyzerId));
}
if (string.IsNullOrWhiteSpace(rootPath))
{
throw new ArgumentException("Root filesystem path is required.", nameof(rootPath));
}
analyzerId = analyzerId.Trim().ToLowerInvariant();
var fullRoot = Path.GetFullPath(rootPath);
if (!Directory.Exists(fullRoot))
{
return HashPrimitive($"{analyzerId}|{fullRoot}");
}
var fingerprintFile = ResolveFingerprintFile(analyzerId, fullRoot);
if (fingerprintFile is null || !File.Exists(fingerprintFile))
{
return null;
}
using var aggregate = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
Append(aggregate, $"ROOT|{NormalizePath(fullRoot)}");
Append(aggregate, $"ANALYZER|{analyzerId}");
var relative = NormalizeRelative(fullRoot, fingerprintFile);
FileInfo info;
try
{
info = new FileInfo(fingerprintFile);
}
catch
{
return null;
}
var timestamp = new DateTimeOffset(info.LastWriteTimeUtc).ToUnixTimeMilliseconds();
Append(aggregate, $"F|{relative}|{info.Length}|{timestamp}");
if (info.Length > 0 && info.Length <= ContentHashThresholdBytes)
{
try
{
Append(aggregate, $"H|{ComputeFileHash(fingerprintFile, cancellationToken)}");
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Append(aggregate, $"HERR|{ex.GetType().Name}");
}
}
return Convert.ToHexString(aggregate.GetHashAndReset()).ToLowerInvariant();
}
private static string? ResolveFingerprintFile(string analyzerId, string rootPath)
{
Debug.Assert(Path.IsPathFullyQualified(rootPath), "Expected root path to be full.");
return analyzerId switch
{
"apk" => Path.Combine(rootPath, "lib", "apk", "db", "installed"),
"dpkg" => Path.Combine(rootPath, "var", "lib", "dpkg", "status"),
"rpm" => ResolveRpmFingerprintFile(rootPath),
_ => null,
};
}
private static string? ResolveRpmFingerprintFile(string rootPath)
{
var candidates = new[]
{
Path.Combine(rootPath, "var", "lib", "rpm", "rpmdb.sqlite"),
Path.Combine(rootPath, "usr", "lib", "sysimage", "rpm", "rpmdb.sqlite"),
Path.Combine(rootPath, "var", "lib", "rpm", "Packages"),
Path.Combine(rootPath, "usr", "lib", "sysimage", "rpm", "Packages"),
};
foreach (var candidate in candidates)
{
if (File.Exists(candidate))
{
return candidate;
}
}
return null;
}
private static string ComputeFileHash(string path, CancellationToken cancellationToken)
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
var buffer = ArrayPool<byte>.Shared.Rent(64 * 1024);
try
{
int read;
while ((read = stream.Read(buffer, 0, buffer.Length)) > 0)
{
cancellationToken.ThrowIfCancellationRequested();
hash.AppendData(buffer, 0, read);
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
}
private static void Append(IncrementalHash hash, string value)
{
var bytes = Encoding.UTF8.GetBytes(value + "\n");
hash.AppendData(bytes);
}
private static string NormalizeRelative(string root, string path)
{
if (string.Equals(root, path, StringComparison.Ordinal))
{
return ".";
}
var relative = Path.GetRelativePath(root, path);
return NormalizePath(relative);
}
private static string NormalizePath(string value)
=> value.Replace('\\', '/');
private static string HashPrimitive(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
}
}

View File

@@ -1,192 +1,199 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Globalization;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Analyzers.OS.Mapping;
public static class OsComponentMapper
{
private const string ComponentType = "os-package";
public static ImmutableArray<LayerComponentFragment> ToLayerFragments(IEnumerable<OSPackageAnalyzerResult> results)
{
ArgumentNullException.ThrowIfNull(results);
var fragmentsByLayer = new Dictionary<string, List<ComponentRecord>>(StringComparer.OrdinalIgnoreCase);
foreach (var result in results)
{
if (result is null || string.IsNullOrWhiteSpace(result.AnalyzerId))
{
continue;
}
var syntheticDigest = ComputeLayerDigest(result.AnalyzerId);
foreach (var package in result.Packages ?? Enumerable.Empty<OSPackageRecord>())
{
var actualLayerDigest = ResolveLayerDigest(package) ?? syntheticDigest;
var record = ToComponentRecord(result.AnalyzerId, actualLayerDigest, package);
if (!fragmentsByLayer.TryGetValue(actualLayerDigest, out var records))
{
records = new List<ComponentRecord>();
fragmentsByLayer[actualLayerDigest] = records;
}
records.Add(record);
}
}
var builder = ImmutableArray.CreateBuilder<LayerComponentFragment>(fragmentsByLayer.Count);
foreach (var (layerDigest, records) in fragmentsByLayer)
{
builder.Add(LayerComponentFragment.Create(layerDigest, ImmutableArray.CreateRange(records)));
}
return builder.ToImmutable();
}
private static string? ResolveLayerDigest(OSPackageRecord package)
{
foreach (var file in package.Files)
{
if (!string.IsNullOrWhiteSpace(file.LayerDigest))
{
return file.LayerDigest;
}
}
return null;
}
private static ComponentRecord ToComponentRecord(string analyzerId, string layerDigest, OSPackageRecord package)
{
var identity = ComponentIdentity.Create(
key: package.PackageUrl,
name: package.Name,
version: package.Version,
purl: package.PackageUrl,
componentType: ComponentType,
group: package.SourcePackage);
var evidence = package.Files.Select(file =>
new ComponentEvidence
{
Kind = file.IsConfigFile is true ? "config-file" : "file",
Value = file.Path,
Source = ResolvePrimaryDigest(file),
}).ToImmutableArray();
var dependencies = package.Depends.Count == 0
? ImmutableArray<string>.Empty
: ImmutableArray.CreateRange(package.Depends);
var metadata = BuildMetadata(analyzerId, package);
return new ComponentRecord
{
Identity = identity,
LayerDigest = layerDigest,
Evidence = evidence,
Dependencies = dependencies,
Metadata = metadata,
Usage = ComponentUsage.Unused,
};
}
private static ComponentMetadata? BuildMetadata(string analyzerId, OSPackageRecord package)
{
var properties = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["stellaops.os.analyzer"] = analyzerId,
["stellaops.os.architecture"] = package.Architecture,
["stellaops.os.evidenceSource"] = package.EvidenceSource.ToString(),
};
if (!string.IsNullOrWhiteSpace(package.SourcePackage))
{
properties["stellaops.os.sourcePackage"] = package.SourcePackage!;
}
if (package.CveHints.Count > 0)
{
properties["stellaops.os.cveHints"] = string.Join(",", package.CveHints);
}
if (package.Provides.Count > 0)
{
properties["stellaops.os.provides"] = string.Join(",", package.Provides);
}
foreach (var pair in package.VendorMetadata)
{
if (string.IsNullOrWhiteSpace(pair.Key) || string.IsNullOrWhiteSpace(pair.Value))
{
continue;
}
properties[$"vendor.{pair.Key}"] = pair.Value!.Trim();
}
foreach (var file in package.Files)
{
foreach (var digest in file.Digests)
{
if (string.IsNullOrWhiteSpace(digest.Value))
{
continue;
}
properties[$"digest.{digest.Key}.{NormalizePathKey(file.Path)}"] = digest.Value.Trim();
}
if (file.SizeBytes.HasValue)
{
properties[$"size.{NormalizePathKey(file.Path)}"] = file.SizeBytes.Value.ToString(CultureInfo.InvariantCulture);
}
}
IReadOnlyList<string>? licenses = null;
if (!string.IsNullOrWhiteSpace(package.License))
{
licenses = new[] { package.License!.Trim() };
}
return new ComponentMetadata
{
Licenses = licenses,
Properties = properties.Count == 0 ? null : properties,
};
}
private static string NormalizePathKey(string path)
=> path.Replace('/', '_').Replace('\\', '_').Trim('_');
private static string? ResolvePrimaryDigest(OSPackageFileEvidence file)
{
if (!string.IsNullOrWhiteSpace(file.Sha256))
{
return file.Sha256;
}
if (file.Digests.TryGetValue("sha256", out var sha256) && !string.IsNullOrWhiteSpace(sha256))
{
return sha256;
}
return null;
}
private static string ComputeLayerDigest(string analyzerId)
{
var normalized = $"stellaops:os:{analyzerId.Trim().ToLowerInvariant()}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(normalized));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Globalization;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Analyzers.OS.Mapping;
public static class OsComponentMapper
{
private const string ComponentType = "os-package";
public static ImmutableArray<LayerComponentFragment> ToLayerFragments(IEnumerable<OSPackageAnalyzerResult> results)
{
ArgumentNullException.ThrowIfNull(results);
var fragmentsByLayer = new Dictionary<string, List<ComponentRecord>>(StringComparer.OrdinalIgnoreCase);
foreach (var result in results)
{
if (result is null || string.IsNullOrWhiteSpace(result.AnalyzerId))
{
continue;
}
var syntheticDigest = ComputeLayerDigest(result.AnalyzerId);
foreach (var package in result.Packages ?? Enumerable.Empty<OSPackageRecord>())
{
var actualLayerDigest = ResolveLayerDigest(package) ?? syntheticDigest;
var record = ToComponentRecord(result.AnalyzerId, actualLayerDigest, package);
if (!fragmentsByLayer.TryGetValue(actualLayerDigest, out var records))
{
records = new List<ComponentRecord>();
fragmentsByLayer[actualLayerDigest] = records;
}
records.Add(record);
}
}
var builder = ImmutableArray.CreateBuilder<LayerComponentFragment>(fragmentsByLayer.Count);
foreach (var (layerDigest, records) in fragmentsByLayer)
{
builder.Add(LayerComponentFragment.Create(layerDigest, ImmutableArray.CreateRange(records)));
}
return builder.ToImmutable();
}
private static string? ResolveLayerDigest(OSPackageRecord package)
{
foreach (var file in package.Files)
{
if (!string.IsNullOrWhiteSpace(file.LayerDigest))
{
return file.LayerDigest;
}
}
return null;
}
private static ComponentRecord ToComponentRecord(string analyzerId, string layerDigest, OSPackageRecord package)
{
var identity = ComponentIdentity.Create(
key: package.PackageUrl,
name: package.Name,
version: package.Version,
purl: package.PackageUrl,
componentType: ComponentType,
group: package.SourcePackage);
var evidence = package.Files.Select(file =>
new ComponentEvidence
{
Kind = file.IsConfigFile is true ? "config-file" : "file",
Value = file.Path,
Source = ResolvePrimaryDigest(file),
}).ToImmutableArray();
var dependencies = package.Depends.Count == 0
? ImmutableArray<string>.Empty
: ImmutableArray.CreateRange(package.Depends);
var metadata = BuildMetadata(analyzerId, package);
return new ComponentRecord
{
Identity = identity,
LayerDigest = layerDigest,
Evidence = evidence,
Dependencies = dependencies,
Metadata = metadata,
Usage = ComponentUsage.Unused,
};
}
private static ComponentMetadata? BuildMetadata(string analyzerId, OSPackageRecord package)
{
var properties = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["stellaops.os.analyzer"] = analyzerId,
["stellaops.os.architecture"] = package.Architecture,
["stellaops.os.evidenceSource"] = package.EvidenceSource.ToString(),
};
if (!string.IsNullOrWhiteSpace(package.SourcePackage))
{
properties["stellaops.os.sourcePackage"] = package.SourcePackage!;
}
if (package.CveHints.Count > 0)
{
properties["stellaops.os.cveHints"] = string.Join(",", package.CveHints);
}
if (package.Provides.Count > 0)
{
properties["stellaops.os.provides"] = string.Join(",", package.Provides);
}
foreach (var pair in package.VendorMetadata)
{
if (string.IsNullOrWhiteSpace(pair.Key) || string.IsNullOrWhiteSpace(pair.Value))
{
continue;
}
properties[$"vendor.{pair.Key}"] = pair.Value!.Trim();
}
foreach (var file in package.Files)
{
foreach (var digest in file.Digests)
{
if (string.IsNullOrWhiteSpace(digest.Value))
{
continue;
}
properties[$"digest.{digest.Key}.{NormalizePathKey(file.Path)}"] = digest.Value.Trim();
}
if (file.SizeBytes.HasValue)
{
properties[$"size.{NormalizePathKey(file.Path)}"] = file.SizeBytes.Value.ToString(CultureInfo.InvariantCulture);
}
}
IReadOnlyList<string>? licenses = null;
if (!string.IsNullOrWhiteSpace(package.License))
{
licenses = new[] { package.License!.Trim() };
}
return new ComponentMetadata
{
Licenses = licenses,
Properties = properties.Count == 0 ? null : properties,
};
}
private static string NormalizePathKey(string path)
=> path.Replace('/', '_').Replace('\\', '_').Trim('_');
private static string? ResolvePrimaryDigest(OSPackageFileEvidence file)
{
if (file is null)
{
return null;
}
if (!string.IsNullOrWhiteSpace(file.Sha256))
{
return file.Sha256;
}
static string? SelectDigest(OSPackageFileEvidence file, string key)
=> file.Digests.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) ? value : null;
return SelectDigest(file, "sha512")
?? SelectDigest(file, "sha384")
?? SelectDigest(file, "sha256")
?? SelectDigest(file, "sha1")
?? SelectDigest(file, "md5");
}
private static string ComputeLayerDigest(string analyzerId)
{
var normalized = $"stellaops:os:{analyzerId.Trim().ToLowerInvariant()}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(normalized));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}

View File

@@ -1,13 +1,13 @@
using System;
namespace StellaOps.Scanner.Analyzers.OS;
public sealed record AnalyzerWarning(string Code, string Message)
{
public static AnalyzerWarning From(string code, string message)
{
ArgumentException.ThrowIfNullOrWhiteSpace(code);
ArgumentException.ThrowIfNullOrWhiteSpace(message);
return new AnalyzerWarning(code.Trim(), message.Trim());
}
}
using System;
namespace StellaOps.Scanner.Analyzers.OS;
public sealed record AnalyzerWarning(string Code, string Message)
{
public static AnalyzerWarning From(string code, string message)
{
ArgumentException.ThrowIfNullOrWhiteSpace(code);
ArgumentException.ThrowIfNullOrWhiteSpace(message);
return new AnalyzerWarning(code.Trim(), message.Trim());
}
}

View File

@@ -1,5 +1,5 @@
using System;
namespace StellaOps.Scanner.Analyzers.OS;
public sealed record OSAnalyzerTelemetry(TimeSpan Duration, int PackageCount, int FileEvidenceCount);
using System;
namespace StellaOps.Scanner.Analyzers.OS;
public sealed record OSAnalyzerTelemetry(TimeSpan Duration, int PackageCount, int FileEvidenceCount);

View File

@@ -1,59 +1,59 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.Analyzers.OS;
/// <summary>
/// Carries the immutable context shared across analyzer executions for a given scan job.
/// </summary>
public sealed class OSPackageAnalyzerContext
{
private static readonly IReadOnlyDictionary<string, string> EmptyMetadata =
new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(0, StringComparer.Ordinal));
public OSPackageAnalyzerContext(
string rootPath,
string? workspacePath,
TimeProvider timeProvider,
ILogger logger,
IReadOnlyDictionary<string, string>? metadata = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
RootPath = Path.GetFullPath(rootPath);
WorkspacePath = string.IsNullOrWhiteSpace(workspacePath) ? null : Path.GetFullPath(workspacePath!);
TimeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
Metadata = metadata is null or { Count: 0 }
? EmptyMetadata
: new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(metadata, StringComparer.Ordinal));
}
/// <summary>
/// Gets the absolute path to the reconstructed root filesystem of the scanned image/layer set.
/// </summary>
public string RootPath { get; }
/// <summary>
/// Gets the absolute path to a writable workspace root the analyzer may use for transient state (optional).
/// The sandbox guarantees cleanup post-run.
/// </summary>
public string? WorkspacePath { get; }
/// <summary>
/// Gets the time provider aligned with the scanner's deterministic clock.
/// </summary>
public TimeProvider TimeProvider { get; }
/// <summary>
/// Gets the structured logger scoped to the analyzer execution.
/// </summary>
public ILogger Logger { get; }
/// <summary>
/// Gets metadata forwarded by prior pipeline stages (image digest, layer digests, tenant, etc.).
/// </summary>
public IReadOnlyDictionary<string, string> Metadata { get; }
}
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.Analyzers.OS;
/// <summary>
/// Carries the immutable context shared across analyzer executions for a given scan job.
/// </summary>
public sealed class OSPackageAnalyzerContext
{
private static readonly IReadOnlyDictionary<string, string> EmptyMetadata =
new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(0, StringComparer.Ordinal));
public OSPackageAnalyzerContext(
string rootPath,
string? workspacePath,
TimeProvider timeProvider,
ILogger logger,
IReadOnlyDictionary<string, string>? metadata = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
RootPath = Path.GetFullPath(rootPath);
WorkspacePath = string.IsNullOrWhiteSpace(workspacePath) ? null : Path.GetFullPath(workspacePath!);
TimeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
Metadata = metadata is null or { Count: 0 }
? EmptyMetadata
: new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(metadata, StringComparer.Ordinal));
}
/// <summary>
/// Gets the absolute path to the reconstructed root filesystem of the scanned image/layer set.
/// </summary>
public string RootPath { get; }
/// <summary>
/// Gets the absolute path to a writable workspace root the analyzer may use for transient state (optional).
/// The sandbox guarantees cleanup post-run.
/// </summary>
public string? WorkspacePath { get; }
/// <summary>
/// Gets the time provider aligned with the scanner's deterministic clock.
/// </summary>
public TimeProvider TimeProvider { get; }
/// <summary>
/// Gets the structured logger scoped to the analyzer execution.
/// </summary>
public ILogger Logger { get; }
/// <summary>
/// Gets metadata forwarded by prior pipeline stages (image digest, layer digests, tenant, etc.).
/// </summary>
public IReadOnlyDictionary<string, string> Metadata { get; }
}

View File

@@ -1,40 +1,40 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
namespace StellaOps.Scanner.Analyzers.OS;
public sealed class OSPackageAnalyzerResult
{
private static readonly IReadOnlyList<OSPackageRecord> EmptyPackages =
new ReadOnlyCollection<OSPackageRecord>(Array.Empty<OSPackageRecord>());
private static readonly IReadOnlyList<AnalyzerWarning> EmptyWarnings =
new ReadOnlyCollection<AnalyzerWarning>(Array.Empty<AnalyzerWarning>());
public OSPackageAnalyzerResult(
string analyzerId,
IEnumerable<OSPackageRecord>? packages,
OSAnalyzerTelemetry telemetry,
IEnumerable<AnalyzerWarning>? warnings = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(analyzerId);
AnalyzerId = analyzerId.Trim();
Packages = packages is null
? EmptyPackages
: new ReadOnlyCollection<OSPackageRecord>(packages.ToArray());
Telemetry = telemetry;
Warnings = warnings is null
? EmptyWarnings
: new ReadOnlyCollection<AnalyzerWarning>(warnings.ToArray());
}
public string AnalyzerId { get; }
public IReadOnlyList<OSPackageRecord> Packages { get; }
public OSAnalyzerTelemetry Telemetry { get; }
public IReadOnlyList<AnalyzerWarning> Warnings { get; }
}
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
namespace StellaOps.Scanner.Analyzers.OS;
public sealed class OSPackageAnalyzerResult
{
private static readonly IReadOnlyList<OSPackageRecord> EmptyPackages =
new ReadOnlyCollection<OSPackageRecord>(Array.Empty<OSPackageRecord>());
private static readonly IReadOnlyList<AnalyzerWarning> EmptyWarnings =
new ReadOnlyCollection<AnalyzerWarning>(Array.Empty<AnalyzerWarning>());
public OSPackageAnalyzerResult(
string analyzerId,
IEnumerable<OSPackageRecord>? packages,
OSAnalyzerTelemetry telemetry,
IEnumerable<AnalyzerWarning>? warnings = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(analyzerId);
AnalyzerId = analyzerId.Trim();
Packages = packages is null
? EmptyPackages
: new ReadOnlyCollection<OSPackageRecord>(packages.ToArray());
Telemetry = telemetry;
Warnings = warnings is null
? EmptyWarnings
: new ReadOnlyCollection<AnalyzerWarning>(warnings.ToArray());
}
public string AnalyzerId { get; }
public IReadOnlyList<OSPackageRecord> Packages { get; }
public OSAnalyzerTelemetry Telemetry { get; }
public IReadOnlyList<AnalyzerWarning> Warnings { get; }
}

View File

@@ -1,100 +1,100 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
namespace StellaOps.Scanner.Analyzers.OS;
public sealed class OSPackageFileEvidence : IComparable<OSPackageFileEvidence>
{
public OSPackageFileEvidence(
string path,
string? layerDigest = null,
string? sha256 = null,
long? sizeBytes = null,
bool? isConfigFile = null,
IDictionary<string, string>? digests = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
Path = Normalize(path);
LayerDigest = NormalizeDigest(layerDigest);
var digestMap = digests is null
? new SortedDictionary<string, string>(StringComparer.OrdinalIgnoreCase)
: new SortedDictionary<string, string>(digests, StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(sha256))
{
digestMap["sha256"] = NormalizeHash(sha256)!;
}
Digests = new ReadOnlyDictionary<string, string>(digestMap);
Sha256 = Digests.TryGetValue("sha256", out var normalizedSha256) ? normalizedSha256 : null;
SizeBytes = sizeBytes;
IsConfigFile = isConfigFile;
}
public string Path { get; }
public string? LayerDigest { get; }
public string? Sha256 { get; }
public IReadOnlyDictionary<string, string> Digests { get; }
public long? SizeBytes { get; }
public bool? IsConfigFile { get; }
public int CompareTo(OSPackageFileEvidence? other)
{
if (other is null)
{
return 1;
}
return string.CompareOrdinal(Path, other.Path);
}
public override string ToString()
=> $"{Path} ({SizeBytes?.ToString("N0", CultureInfo.InvariantCulture) ?? "?"} bytes)";
private static string Normalize(string path)
{
var trimmed = path.Trim();
if (!trimmed.StartsWith('/'))
{
trimmed = "/" + trimmed;
}
return trimmed.Replace('\\', '/');
}
private static string? NormalizeDigest(string? digest)
{
if (string.IsNullOrWhiteSpace(digest))
{
return null;
}
var trimmed = digest.Trim();
if (!trimmed.Contains(':', StringComparison.Ordinal))
{
return trimmed.ToLowerInvariant();
}
var parts = trimmed.Split(':', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return parts.Length == 2
? $"{parts[0].ToLowerInvariant()}:{parts[1].ToLowerInvariant()}"
: trimmed.ToLowerInvariant();
}
private static string? NormalizeHash(string? hash)
{
if (string.IsNullOrWhiteSpace(hash))
{
return null;
}
return hash.Trim().ToLowerInvariant();
}
}
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
namespace StellaOps.Scanner.Analyzers.OS;
public sealed class OSPackageFileEvidence : IComparable<OSPackageFileEvidence>
{
public OSPackageFileEvidence(
string path,
string? layerDigest = null,
string? sha256 = null,
long? sizeBytes = null,
bool? isConfigFile = null,
IDictionary<string, string>? digests = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
Path = Normalize(path);
LayerDigest = NormalizeDigest(layerDigest);
var digestMap = digests is null
? new SortedDictionary<string, string>(StringComparer.OrdinalIgnoreCase)
: new SortedDictionary<string, string>(digests, StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(sha256))
{
digestMap["sha256"] = NormalizeHash(sha256)!;
}
Digests = new ReadOnlyDictionary<string, string>(digestMap);
Sha256 = Digests.TryGetValue("sha256", out var normalizedSha256) ? normalizedSha256 : null;
SizeBytes = sizeBytes;
IsConfigFile = isConfigFile;
}
public string Path { get; }
public string? LayerDigest { get; }
public string? Sha256 { get; }
public IReadOnlyDictionary<string, string> Digests { get; }
public long? SizeBytes { get; }
public bool? IsConfigFile { get; }
public int CompareTo(OSPackageFileEvidence? other)
{
if (other is null)
{
return 1;
}
return string.CompareOrdinal(Path, other.Path);
}
public override string ToString()
=> $"{Path} ({SizeBytes?.ToString("N0", CultureInfo.InvariantCulture) ?? "?"} bytes)";
private static string Normalize(string path)
{
var trimmed = path.Trim();
if (!trimmed.StartsWith('/'))
{
trimmed = "/" + trimmed;
}
return trimmed.Replace('\\', '/');
}
private static string? NormalizeDigest(string? digest)
{
if (string.IsNullOrWhiteSpace(digest))
{
return null;
}
var trimmed = digest.Trim();
if (!trimmed.Contains(':', StringComparison.Ordinal))
{
return trimmed.ToLowerInvariant();
}
var parts = trimmed.Split(':', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return parts.Length == 2
? $"{parts[0].ToLowerInvariant()}:{parts[1].ToLowerInvariant()}"
: trimmed.ToLowerInvariant();
}
private static string? NormalizeHash(string? hash)
{
if (string.IsNullOrWhiteSpace(hash))
{
return null;
}
return hash.Trim().ToLowerInvariant();
}
}

View File

@@ -1,138 +1,138 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
namespace StellaOps.Scanner.Analyzers.OS;
public sealed class OSPackageRecord : IComparable<OSPackageRecord>
{
private static readonly IReadOnlyList<string> EmptyList =
new ReadOnlyCollection<string>(Array.Empty<string>());
private static readonly IReadOnlyList<OSPackageFileEvidence> EmptyFiles =
new ReadOnlyCollection<OSPackageFileEvidence>(Array.Empty<OSPackageFileEvidence>());
private static readonly IReadOnlyDictionary<string, string?> EmptyMetadata =
new ReadOnlyDictionary<string, string?>(new Dictionary<string, string?>(0, StringComparer.Ordinal));
public OSPackageRecord(
string analyzerId,
string packageUrl,
string name,
string version,
string architecture,
PackageEvidenceSource evidenceSource,
string? epoch = null,
string? release = null,
string? sourcePackage = null,
string? license = null,
IEnumerable<string>? cveHints = null,
IEnumerable<string>? provides = null,
IEnumerable<string>? depends = null,
IEnumerable<OSPackageFileEvidence>? files = null,
IDictionary<string, string?>? vendorMetadata = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(analyzerId);
ArgumentException.ThrowIfNullOrWhiteSpace(packageUrl);
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
ArgumentException.ThrowIfNullOrWhiteSpace(architecture);
AnalyzerId = analyzerId.Trim();
PackageUrl = packageUrl.Trim();
Name = name.Trim();
Version = version.Trim();
Architecture = architecture.Trim();
EvidenceSource = evidenceSource;
Epoch = string.IsNullOrWhiteSpace(epoch) ? null : epoch.Trim();
Release = string.IsNullOrWhiteSpace(release) ? null : release.Trim();
SourcePackage = string.IsNullOrWhiteSpace(sourcePackage) ? null : sourcePackage.Trim();
License = string.IsNullOrWhiteSpace(license) ? null : license.Trim();
CveHints = AsReadOnlyList(cveHints);
Provides = AsReadOnlyList(provides);
Depends = AsReadOnlyList(depends);
Files = files is null
? EmptyFiles
: new ReadOnlyCollection<OSPackageFileEvidence>(files.OrderBy(f => f).ToArray());
VendorMetadata = vendorMetadata is null or { Count: 0 }
? EmptyMetadata
: new ReadOnlyDictionary<string, string?>(
new SortedDictionary<string, string?>(vendorMetadata, StringComparer.Ordinal));
}
public string AnalyzerId { get; }
public string PackageUrl { get; }
public string Name { get; }
public string Version { get; }
public string Architecture { get; }
public string? Epoch { get; }
public string? Release { get; }
public string? SourcePackage { get; }
public string? License { get; }
public IReadOnlyList<string> CveHints { get; }
public IReadOnlyList<string> Provides { get; }
public IReadOnlyList<string> Depends { get; }
public IReadOnlyList<OSPackageFileEvidence> Files { get; }
public IReadOnlyDictionary<string, string?> VendorMetadata { get; }
public PackageEvidenceSource EvidenceSource { get; }
public int CompareTo(OSPackageRecord? other)
{
if (other is null)
{
return 1;
}
var cmp = string.CompareOrdinal(PackageUrl, other.PackageUrl);
if (cmp != 0)
{
return cmp;
}
cmp = string.CompareOrdinal(Name, other.Name);
if (cmp != 0)
{
return cmp;
}
cmp = string.CompareOrdinal(Version, other.Version);
if (cmp != 0)
{
return cmp;
}
return string.CompareOrdinal(Architecture, other.Architecture);
}
private static IReadOnlyList<string> AsReadOnlyList(IEnumerable<string>? values)
{
if (values is null)
{
return EmptyList;
}
var buffer = values
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(static value => value, StringComparer.Ordinal)
.ToArray();
return buffer.Length == 0 ? EmptyList : new ReadOnlyCollection<string>(buffer);
}
}
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
namespace StellaOps.Scanner.Analyzers.OS;
public sealed class OSPackageRecord : IComparable<OSPackageRecord>
{
private static readonly IReadOnlyList<string> EmptyList =
new ReadOnlyCollection<string>(Array.Empty<string>());
private static readonly IReadOnlyList<OSPackageFileEvidence> EmptyFiles =
new ReadOnlyCollection<OSPackageFileEvidence>(Array.Empty<OSPackageFileEvidence>());
private static readonly IReadOnlyDictionary<string, string?> EmptyMetadata =
new ReadOnlyDictionary<string, string?>(new Dictionary<string, string?>(0, StringComparer.Ordinal));
public OSPackageRecord(
string analyzerId,
string packageUrl,
string name,
string version,
string architecture,
PackageEvidenceSource evidenceSource,
string? epoch = null,
string? release = null,
string? sourcePackage = null,
string? license = null,
IEnumerable<string>? cveHints = null,
IEnumerable<string>? provides = null,
IEnumerable<string>? depends = null,
IEnumerable<OSPackageFileEvidence>? files = null,
IDictionary<string, string?>? vendorMetadata = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(analyzerId);
ArgumentException.ThrowIfNullOrWhiteSpace(packageUrl);
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
ArgumentException.ThrowIfNullOrWhiteSpace(architecture);
AnalyzerId = analyzerId.Trim();
PackageUrl = packageUrl.Trim();
Name = name.Trim();
Version = version.Trim();
Architecture = architecture.Trim();
EvidenceSource = evidenceSource;
Epoch = string.IsNullOrWhiteSpace(epoch) ? null : epoch.Trim();
Release = string.IsNullOrWhiteSpace(release) ? null : release.Trim();
SourcePackage = string.IsNullOrWhiteSpace(sourcePackage) ? null : sourcePackage.Trim();
License = string.IsNullOrWhiteSpace(license) ? null : license.Trim();
CveHints = AsReadOnlyList(cveHints);
Provides = AsReadOnlyList(provides);
Depends = AsReadOnlyList(depends);
Files = files is null
? EmptyFiles
: new ReadOnlyCollection<OSPackageFileEvidence>(files.OrderBy(f => f).ToArray());
VendorMetadata = vendorMetadata is null or { Count: 0 }
? EmptyMetadata
: new ReadOnlyDictionary<string, string?>(
new SortedDictionary<string, string?>(vendorMetadata, StringComparer.Ordinal));
}
public string AnalyzerId { get; }
public string PackageUrl { get; }
public string Name { get; }
public string Version { get; }
public string Architecture { get; }
public string? Epoch { get; }
public string? Release { get; }
public string? SourcePackage { get; }
public string? License { get; }
public IReadOnlyList<string> CveHints { get; }
public IReadOnlyList<string> Provides { get; }
public IReadOnlyList<string> Depends { get; }
public IReadOnlyList<OSPackageFileEvidence> Files { get; }
public IReadOnlyDictionary<string, string?> VendorMetadata { get; }
public PackageEvidenceSource EvidenceSource { get; }
public int CompareTo(OSPackageRecord? other)
{
if (other is null)
{
return 1;
}
var cmp = string.CompareOrdinal(PackageUrl, other.PackageUrl);
if (cmp != 0)
{
return cmp;
}
cmp = string.CompareOrdinal(Name, other.Name);
if (cmp != 0)
{
return cmp;
}
cmp = string.CompareOrdinal(Version, other.Version);
if (cmp != 0)
{
return cmp;
}
return string.CompareOrdinal(Architecture, other.Architecture);
}
private static IReadOnlyList<string> AsReadOnlyList(IEnumerable<string>? values)
{
if (values is null)
{
return EmptyList;
}
var buffer = values
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(static value => value, StringComparer.Ordinal)
.ToArray();
return buffer.Length == 0 ? EmptyList : new ReadOnlyCollection<string>(buffer);
}
}

View File

@@ -1,15 +1,15 @@
namespace StellaOps.Scanner.Analyzers.OS;
public enum PackageEvidenceSource
{
Unknown = 0,
ApkDatabase,
DpkgStatus,
RpmDatabase,
HomebrewCellar,
PkgutilReceipt,
MacOsBundle,
WindowsMsi,
WindowsWinSxS,
WindowsChocolatey,
}
namespace StellaOps.Scanner.Analyzers.OS;
public enum PackageEvidenceSource
{
Unknown = 0,
ApkDatabase,
DpkgStatus,
RpmDatabase,
HomebrewCellar,
PkgutilReceipt,
MacOsBundle,
WindowsMsi,
WindowsWinSxS,
WindowsChocolatey,
}

View File

@@ -1,16 +1,16 @@
using System;
using StellaOps.Plugin;
using StellaOps.Scanner.Analyzers.OS.Abstractions;
namespace StellaOps.Scanner.Analyzers.OS.Plugin;
/// <summary>
/// Represents a restart-time plug-in that publishes a single <see cref="IOSPackageAnalyzer"/>.
/// </summary>
public interface IOSAnalyzerPlugin : IAvailabilityPlugin
{
/// <summary>
/// Creates the analyzer instance bound to the host service provider.
/// </summary>
IOSPackageAnalyzer CreateAnalyzer(IServiceProvider services);
}
using System;
using StellaOps.Plugin;
using StellaOps.Scanner.Analyzers.OS.Abstractions;
namespace StellaOps.Scanner.Analyzers.OS.Plugin;
/// <summary>
/// Represents a restart-time plug-in that publishes a single <see cref="IOSPackageAnalyzer"/>.
/// </summary>
public interface IOSAnalyzerPlugin : IAvailabilityPlugin
{
/// <summary>
/// Creates the analyzer instance bound to the host service provider.
/// </summary>
IOSPackageAnalyzer CreateAnalyzer(IServiceProvider services);
}

View File

@@ -1,16 +1,16 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Reflection;
using Microsoft.Extensions.Logging;
using StellaOps.Plugin;
using StellaOps.Plugin.Hosting;
using StellaOps.Scanner.Analyzers.OS.Abstractions;
using StellaOps.Scanner.Core.Security;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Reflection;
using Microsoft.Extensions.Logging;
using StellaOps.Plugin;
using StellaOps.Plugin.Hosting;
using StellaOps.Scanner.Analyzers.OS.Abstractions;
using StellaOps.Scanner.Core.Security;
namespace StellaOps.Scanner.Analyzers.OS.Plugin;
public interface IOSAnalyzerPluginCatalog
@@ -24,124 +24,124 @@ public interface IOSAnalyzerPluginCatalog
public sealed class OsAnalyzerPluginCatalog : IOSAnalyzerPluginCatalog
{
private readonly ILogger<OsAnalyzerPluginCatalog> _logger;
private readonly IPluginCatalogGuard _guard;
private readonly ConcurrentDictionary<string, Assembly> _assemblies = new(StringComparer.OrdinalIgnoreCase);
private IReadOnlyList<IOSAnalyzerPlugin> _plugins = Array.Empty<IOSAnalyzerPlugin>();
public OsAnalyzerPluginCatalog(IPluginCatalogGuard guard, ILogger<OsAnalyzerPluginCatalog> logger)
{
_guard = guard ?? throw new ArgumentNullException(nameof(guard));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public IReadOnlyCollection<IOSAnalyzerPlugin> Plugins => _plugins;
public void LoadFromDirectory(string directory, bool seal = true)
{
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
var fullDirectory = Path.GetFullPath(directory);
var options = new PluginHostOptions
{
PluginsDirectory = fullDirectory,
EnsureDirectoryExists = false,
RecursiveSearch = false,
};
options.SearchPatterns.Add("StellaOps.Scanner.Analyzers.*.dll");
var result = PluginHost.LoadPlugins(options, _logger);
if (result.Plugins.Count == 0)
{
_logger.LogWarning("No OS analyzer plug-ins discovered under '{Directory}'.", fullDirectory);
}
foreach (var descriptor in result.Plugins)
{
try
{
_guard.EnsureRegistrationAllowed(descriptor.AssemblyPath);
_assemblies[descriptor.AssemblyPath] = descriptor.Assembly;
_logger.LogInformation("Registered OS analyzer plug-in assembly '{Assembly}' from '{Path}'.",
descriptor.Assembly.FullName,
descriptor.AssemblyPath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to register analyzer plug-in '{Path}'.", descriptor.AssemblyPath);
}
}
RefreshPluginList();
if (seal)
{
_guard.Seal();
}
}
public IReadOnlyList<IOSPackageAnalyzer> CreateAnalyzers(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
if (_plugins.Count == 0)
{
_logger.LogWarning("No OS analyzer plug-ins available; scanning will skip OS package extraction.");
return Array.Empty<IOSPackageAnalyzer>();
}
var analyzers = new List<IOSPackageAnalyzer>(_plugins.Count);
foreach (var plugin in _plugins)
{
if (!IsPluginAvailable(plugin, services))
{
continue;
}
try
{
var analyzer = plugin.CreateAnalyzer(services);
if (analyzer is null)
{
continue;
}
analyzers.Add(analyzer);
}
catch (Exception ex)
{
_logger.LogError(ex, "Analyzer plug-in '{Plugin}' failed to create analyzer instance.", plugin.Name);
}
}
if (analyzers.Count == 0)
{
_logger.LogWarning("All OS analyzer plug-ins were unavailable.");
return Array.Empty<IOSPackageAnalyzer>();
}
analyzers.Sort(static (a, b) => string.CompareOrdinal(a.AnalyzerId, b.AnalyzerId));
return new ReadOnlyCollection<IOSPackageAnalyzer>(analyzers);
}
private void RefreshPluginList()
{
var assemblies = _assemblies.Values.ToArray();
var plugins = PluginLoader.LoadPlugins<IOSAnalyzerPlugin>(assemblies);
_plugins = plugins is IReadOnlyList<IOSAnalyzerPlugin> list
? list
: new ReadOnlyCollection<IOSAnalyzerPlugin>(plugins.ToArray());
}
private static bool IsPluginAvailable(IOSAnalyzerPlugin plugin, IServiceProvider services)
{
try
{
return plugin.IsAvailable(services);
}
catch
{
return false;
}
}
}
private readonly ILogger<OsAnalyzerPluginCatalog> _logger;
private readonly IPluginCatalogGuard _guard;
private readonly ConcurrentDictionary<string, Assembly> _assemblies = new(StringComparer.OrdinalIgnoreCase);
private IReadOnlyList<IOSAnalyzerPlugin> _plugins = Array.Empty<IOSAnalyzerPlugin>();
public OsAnalyzerPluginCatalog(IPluginCatalogGuard guard, ILogger<OsAnalyzerPluginCatalog> logger)
{
_guard = guard ?? throw new ArgumentNullException(nameof(guard));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public IReadOnlyCollection<IOSAnalyzerPlugin> Plugins => _plugins;
public void LoadFromDirectory(string directory, bool seal = true)
{
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
var fullDirectory = Path.GetFullPath(directory);
var options = new PluginHostOptions
{
PluginsDirectory = fullDirectory,
EnsureDirectoryExists = false,
RecursiveSearch = false,
};
options.SearchPatterns.Add("StellaOps.Scanner.Analyzers.*.dll");
var result = PluginHost.LoadPlugins(options, _logger);
if (result.Plugins.Count == 0)
{
_logger.LogWarning("No OS analyzer plug-ins discovered under '{Directory}'.", fullDirectory);
}
foreach (var descriptor in result.Plugins)
{
try
{
_guard.EnsureRegistrationAllowed(descriptor.AssemblyPath);
_assemblies[descriptor.AssemblyPath] = descriptor.Assembly;
_logger.LogInformation("Registered OS analyzer plug-in assembly '{Assembly}' from '{Path}'.",
descriptor.Assembly.FullName,
descriptor.AssemblyPath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to register analyzer plug-in '{Path}'.", descriptor.AssemblyPath);
}
}
RefreshPluginList();
if (seal)
{
_guard.Seal();
}
}
public IReadOnlyList<IOSPackageAnalyzer> CreateAnalyzers(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
if (_plugins.Count == 0)
{
_logger.LogWarning("No OS analyzer plug-ins available; scanning will skip OS package extraction.");
return Array.Empty<IOSPackageAnalyzer>();
}
var analyzers = new List<IOSPackageAnalyzer>(_plugins.Count);
foreach (var plugin in _plugins)
{
if (!IsPluginAvailable(plugin, services))
{
continue;
}
try
{
var analyzer = plugin.CreateAnalyzer(services);
if (analyzer is null)
{
continue;
}
analyzers.Add(analyzer);
}
catch (Exception ex)
{
_logger.LogError(ex, "Analyzer plug-in '{Plugin}' failed to create analyzer instance.", plugin.Name);
}
}
if (analyzers.Count == 0)
{
_logger.LogWarning("All OS analyzer plug-ins were unavailable.");
return Array.Empty<IOSPackageAnalyzer>();
}
analyzers.Sort(static (a, b) => string.CompareOrdinal(a.AnalyzerId, b.AnalyzerId));
return new ReadOnlyCollection<IOSPackageAnalyzer>(analyzers);
}
private void RefreshPluginList()
{
var assemblies = _assemblies.Values.ToArray();
var plugins = PluginLoader.LoadPlugins<IOSAnalyzerPlugin>(assemblies);
_plugins = plugins is IReadOnlyList<IOSAnalyzerPlugin> list
? list
: new ReadOnlyCollection<IOSAnalyzerPlugin>(plugins.ToArray());
}
private static bool IsPluginAvailable(IOSAnalyzerPlugin plugin, IServiceProvider services)
{
try
{
return plugin.IsAvailable(services);
}
catch
{
return false;
}
}
}

View File

@@ -1,3 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Scanner.Analyzers.OS.Tests")]
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Scanner.Analyzers.OS.Tests")]

View File

@@ -13,5 +13,6 @@
<ItemGroup>
<ProjectReference Include="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Surface.FS\StellaOps.Scanner.Surface.FS.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -0,0 +1,10 @@
# OS Analyzer Tasks (Sprint 0409.0001.0001)
| Task ID | Status | Notes | Updated (UTC) |
| --- | --- | --- | --- |
| SCAN-NL-0409-001 | DONE | Added deterministic rootfs fingerprint + surface-cache adapter for OS analyzer results. | 2025-12-12 |
| SCAN-NL-0409-003 | DONE | Structured warnings: dedupe/sort/cap and analyzer updates. | 2025-12-12 |
| SCAN-NL-0409-004 | DONE | Evidence-path semantics: rootfs-relative normalization + layer attribution helper. | 2025-12-12 |
| SCAN-NL-0409-005 | DONE | Digest strategy: bounded hashing + primary digest selection. | 2025-12-12 |
| SCAN-NL-0409-006 | DONE | rpmdb.sqlite query shape optimized; schema-aware blob selection. | 2025-12-12 |