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