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:
40
src/StellaOps.Scanner.Analyzers.OS/AGENTS.md
Normal file
40
src/StellaOps.Scanner.Analyzers.OS/AGENTS.md
Normal 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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
173
src/StellaOps.Scanner.Analyzers.OS/Mapping/OsComponentMapper.cs
Normal file
173
src/StellaOps.Scanner.Analyzers.OS/Mapping/OsComponentMapper.cs
Normal 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()}";
|
||||
}
|
||||
}
|
||||
13
src/StellaOps.Scanner.Analyzers.OS/Model/AnalyzerWarning.cs
Normal file
13
src/StellaOps.Scanner.Analyzers.OS/Model/AnalyzerWarning.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.OS;
|
||||
|
||||
public sealed record OSAnalyzerTelemetry(TimeSpan Duration, int PackageCount, int FileEvidenceCount);
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
138
src/StellaOps.Scanner.Analyzers.OS/Model/OSPackageRecord.cs
Normal file
138
src/StellaOps.Scanner.Analyzers.OS/Model/OSPackageRecord.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace StellaOps.Scanner.Analyzers.OS;
|
||||
|
||||
public enum PackageEvidenceSource
|
||||
{
|
||||
Unknown = 0,
|
||||
ApkDatabase,
|
||||
DpkgStatus,
|
||||
RpmDatabase,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Scanner.Analyzers.OS.Tests")]
|
||||
@@ -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>
|
||||
11
src/StellaOps.Scanner.Analyzers.OS/TASKS.md
Normal file
11
src/StellaOps.Scanner.Analyzers.OS/TASKS.md
Normal 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 | 201–205 | 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 | 201–206 | 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. |
|
||||
Reference in New Issue
Block a user