feat: Initialize Zastava Webhook service with TLS and Authority authentication

- Added Program.cs to set up the web application with Serilog for logging, health check endpoints, and a placeholder admission endpoint.
- Configured Kestrel server to use TLS 1.3 and handle client certificates appropriately.
- Created StellaOps.Zastava.Webhook.csproj with necessary dependencies including Serilog and Polly.
- Documented tasks in TASKS.md for the Zastava Webhook project, outlining current work and exit criteria for each task.
This commit is contained in:
master
2025-10-19 18:36:22 +03:00
parent 2062da7a8b
commit d099a90f9b
966 changed files with 91038 additions and 1850 deletions

View File

@@ -0,0 +1,40 @@
# AGENTS
## Role
Design and ship deterministic Linux operating-system analyzers that transform container root filesystems into canonical package evidence for SBOM emission.
## Scope
- Provide shared helpers for reading apk, dpkg, and rpm metadata and emitting normalized package identities with provenance.
- Implement analyzer plug-ins for Alpine (apk), Debian (dpkg), and RPM-based distributions that operate on extracted rootfs snapshots.
- Enrich package records with vendor-origin metadata (source packages, declared licenses, CVE hints) and evidence linking files to packages.
- Expose restart-time plug-in manifests so the Scanner.Worker can load analyzers in offline or air-gapped environments.
- Supply deterministic fixtures and a regression harness that verifies analyzer outputs remain stable across runs.
## Participants
- `StellaOps.Scanner.Core` for shared contracts, observability, and plug-in catalog guardrails.
- `StellaOps.Scanner.Worker` which executes analyzers inside the scan pipeline.
- `StellaOps.Scanner.Cache` (future) for layer cache integration; analyzers must be cache-aware via deterministic inputs/outputs.
- `StellaOps.Scanner.Emit` and `StellaOps.Scanner.Diff` rely on analyzer outputs to build SBOMs and change reports.
## Interfaces & Contracts
- Analyzers implement `IOSPackageAnalyzer` (defined in this module) and register via plug-in manifests; they must be restart-time only.
- Input rootfs paths are read-only; analyzers must never mutate files and must tolerate missing metadata gracefully.
- Package records emit canonical purls (`pkg:alpine`, `pkg:deb`, `pkg:rpm`) plus NEVRA/EVR details, source package identifiers, declared licenses, and evidence (file lists with layer attribution placeholders).
- Outputs must be deterministic: ordering is lexicographic, timestamps removed or normalized, hashes (SHA256) calculated when required.
## In/Out of Scope
In scope:
- Linux apk/dpkg/rpm analyzers, shared helpers, plug-in manifests, deterministic regression harness.
Out of scope:
- Windows MSI/SxS analyzers, native (ELF) analyzers, language analyzers, EntryTrace pipeline, or SBOM assembly logic (handled by other guilds).
## Observability & Security Expectations
- Emit structured logs with correlation/job identifiers provided by `StellaOps.Scanner.Core`.
- Surface metrics for package counts, elapsed time, and cache hits (metrics hooks stubbed until Cache module lands).
- Do not perform outbound network calls; operate entirely on provided filesystem snapshot.
- Validate plug-in manifests via `IPluginCatalogGuard` to enforce restart-only loading.
## Tests
- `StellaOps.Scanner.Analyzers.OS.Tests` hosts regression tests with canned rootfs fixtures to verify determinism.
- Fixtures store expected analyzer outputs under `Fixtures/` with golden JSON (normalized, sorted).
- Tests cover apk/dpkg/rpm analyzers, shared helper edge cases, and plug-in catalog enforcement.

View File

@@ -0,0 +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);
}

View File

@@ -0,0 +1,41 @@
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);
}

View File

@@ -0,0 +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>();
}
}

View File

@@ -0,0 +1,56 @@
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)}";
}
private static string Escape(string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
return Uri.EscapeDataString(value.Trim());
}
private static string EscapeQuery(string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
var trimmed = value.Trim();
var builder = new StringBuilder(trimmed.Length);
foreach (var ch in trimmed)
{
if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || ch == '.' || ch == '~')
{
builder.Append(ch);
}
else
{
builder.Append('%');
builder.Append(((int)ch).ToString("X2"));
}
}
return builder.ToString();
}
}

View File

@@ -0,0 +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);

View File

@@ -0,0 +1,173 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
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 builder = ImmutableArray.CreateBuilder<LayerComponentFragment>();
foreach (var result in results)
{
if (result is null || string.IsNullOrWhiteSpace(result.AnalyzerId))
{
continue;
}
var layerDigest = ComputeLayerDigest(result.AnalyzerId);
var components = BuildComponentRecords(result.AnalyzerId, layerDigest, result.Packages);
if (components.IsEmpty)
{
continue;
}
builder.Add(LayerComponentFragment.Create(layerDigest, components));
}
return builder.ToImmutable();
}
private static ImmutableArray<ComponentRecord> BuildComponentRecords(
string analyzerId,
string layerDigest,
IEnumerable<OSPackageRecord> packages)
{
var records = ImmutableArray.CreateBuilder<ComponentRecord>();
foreach (var package in packages ?? Enumerable.Empty<OSPackageRecord>())
{
records.Add(ToComponentRecord(analyzerId, layerDigest, package));
}
return records.ToImmutable();
}
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();
}
}
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()}";
}
}

View File

@@ -0,0 +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());
}
}

View File

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

View File

@@ -0,0 +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; }
}

View File

@@ -0,0 +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; }
}

View File

@@ -0,0 +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();
}
}

View File

@@ -0,0 +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);
}
}

View File

@@ -0,0 +1,9 @@
namespace StellaOps.Scanner.Analyzers.OS;
public enum PackageEvidenceSource
{
Unknown = 0,
ApkDatabase,
DpkgStatus,
RpmDatabase,
}

View File

@@ -0,0 +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);
}

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Plugin\StellaOps.Plugin.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,11 @@
# OS Analyzer Task Board
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SCANNER-ANALYZERS-OS-10-201 | DONE (2025-10-19) | OS Analyzer Guild | Scanner Core contracts | Alpine/apk analyzer emitting deterministic package components with provenance evidence. | Analyzer reads `/lib/apk/db/installed`, emits deterministic `pkg:alpine` components with provenance, license, and file evidence; snapshot tests cover fixture. |
| SCANNER-ANALYZERS-OS-10-202 | DONE (2025-10-19) | OS Analyzer Guild | Shared helpers (204) | Debian/dpkg analyzer mapping packages to canonical `pkg:deb` identities with evidence and normalized metadata. | Analyzer parses `status` + `info/*.list`/`md5sums`, outputs normalized packages with config flags and provenance evidence. |
| SCANNER-ANALYZERS-OS-10-203 | DONE (2025-10-19) | OS Analyzer Guild | Shared helpers (204) | RPM analyzer capturing EVR/NEVRA, declared file lists, provenance metadata. | SQLite rpmdb reader parses headers, reconstructs NEVRA, provides/requires, file evidence, and vendor metadata for fixtures. |
| SCANNER-ANALYZERS-OS-10-204 | DONE (2025-10-19) | OS Analyzer Guild | — | Build shared OS evidence helpers for package identity normalization, file attribution, and metadata enrichment used by analyzers. | Shared helpers deliver analyzer base context, PURL builders, CVE hint extraction, and file evidence model reused across plugins. |
| SCANNER-ANALYZERS-OS-10-205 | DONE (2025-10-19) | OS Analyzer Guild | Shared helpers (204) | Vendor metadata enrichment (source packages, declared licenses, CVE hints). | Apk/dpkg/rpm analyzers populate source, license, maintainer, URLs, and CVE hints; metadata stored deterministically. |
| SCANNER-ANALYZERS-OS-10-206 | DONE (2025-10-19) | QA + OS Analyzer Guild | 201205 | Determinism harness + fixtures for OS analyzers (warm/cold runs). | xUnit snapshot harness with fixtures + goldens ensures byte-stable JSON; helper normalizes newlines and supports env-based regen. |
| SCANNER-ANALYZERS-OS-10-207 | DONE (2025-10-19) | OS Analyzer Guild + DevOps | 201206 | Package OS analyzers as restart-time plug-ins (manifest + host registration). | Build targets copy analyzer DLLs/manifests to `plugins/scanner/analyzers/os/`; Worker dispatcher loads via restart-only plugin guard. |