up
Some checks failed
Docs CI / lint-and-preview (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
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (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

This commit is contained in:
StellaOps Bot
2025-12-14 15:50:38 +02:00
parent f1a39c4ce3
commit 233873f620
249 changed files with 29746 additions and 154 deletions

View File

@@ -20,3 +20,91 @@ public sealed record RuntimeEventsIngestResponseDto
[JsonPropertyName("duplicates")]
public int Duplicates { get; init; }
}
public sealed record RuntimeReconcileRequestDto
{
[JsonPropertyName("imageDigest")]
public required string ImageDigest { get; init; }
[JsonPropertyName("runtimeEventId")]
public string? RuntimeEventId { get; init; }
[JsonPropertyName("maxMisses")]
public int MaxMisses { get; init; } = 100;
}
public sealed record RuntimeReconcileResponseDto
{
[JsonPropertyName("imageDigest")]
public required string ImageDigest { get; init; }
[JsonPropertyName("runtimeEventId")]
public string? RuntimeEventId { get; init; }
[JsonPropertyName("sbomArtifactId")]
public string? SbomArtifactId { get; init; }
[JsonPropertyName("totalRuntimeLibraries")]
public int TotalRuntimeLibraries { get; init; }
[JsonPropertyName("totalSbomComponents")]
public int TotalSbomComponents { get; init; }
[JsonPropertyName("matchCount")]
public int MatchCount { get; init; }
[JsonPropertyName("missCount")]
public int MissCount { get; init; }
[JsonPropertyName("misses")]
public IReadOnlyList<RuntimeLibraryMissDto> Misses { get; init; } = [];
[JsonPropertyName("matches")]
public IReadOnlyList<RuntimeLibraryMatchDto> Matches { get; init; } = [];
[JsonPropertyName("reconciledAt")]
public DateTimeOffset ReconciledAt { get; init; }
[JsonPropertyName("errorCode")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ErrorCode { get; init; }
[JsonPropertyName("errorMessage")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ErrorMessage { get; init; }
}
public sealed record RuntimeLibraryMissDto
{
[JsonPropertyName("path")]
public required string Path { get; init; }
[JsonPropertyName("sha256")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Sha256 { get; init; }
[JsonPropertyName("inode")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public long? Inode { get; init; }
}
public sealed record RuntimeLibraryMatchDto
{
[JsonPropertyName("runtimePath")]
public required string RuntimePath { get; init; }
[JsonPropertyName("runtimeSha256")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? RuntimeSha256 { get; init; }
[JsonPropertyName("sbomComponentKey")]
public required string SbomComponentKey { get; init; }
[JsonPropertyName("sbomComponentName")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? SbomComponentName { get; init; }
[JsonPropertyName("matchType")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? MatchType { get; init; }
}

View File

@@ -37,6 +37,15 @@ internal static class RuntimeEndpoints
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status429TooManyRequests)
.RequireAuthorization(ScannerPolicies.RuntimeIngest);
runtime.MapPost("/reconcile", HandleRuntimeReconcileAsync)
.WithName("scanner.runtime.reconcile")
.WithSummary("Reconcile runtime-observed libraries against SBOM inventory")
.WithDescription("Compares libraries observed at runtime against the static SBOM to identify discrepancies")
.Produces<RuntimeReconcileResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.RuntimeIngest);
}
private static async Task<IResult> HandleRuntimeEventsAsync(
@@ -234,6 +243,75 @@ internal static class RuntimeEndpoints
return null;
}
private static async Task<IResult> HandleRuntimeReconcileAsync(
RuntimeReconcileRequestDto request,
IRuntimeInventoryReconciler reconciler,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(reconciler);
if (string.IsNullOrWhiteSpace(request.ImageDigest))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid reconciliation request",
StatusCodes.Status400BadRequest,
detail: "imageDigest is required.");
}
var reconcileRequest = new RuntimeReconciliationRequest
{
ImageDigest = request.ImageDigest,
RuntimeEventId = request.RuntimeEventId,
MaxMisses = request.MaxMisses > 0 ? request.MaxMisses : 100
};
var result = await reconciler.ReconcileAsync(reconcileRequest, cancellationToken).ConfigureAwait(false);
var responseDto = new RuntimeReconcileResponseDto
{
ImageDigest = result.ImageDigest,
RuntimeEventId = result.RuntimeEventId,
SbomArtifactId = result.SbomArtifactId,
TotalRuntimeLibraries = result.TotalRuntimeLibraries,
TotalSbomComponents = result.TotalSbomComponents,
MatchCount = result.MatchCount,
MissCount = result.MissCount,
Misses = result.Misses
.Select(m => new RuntimeLibraryMissDto
{
Path = m.Path,
Sha256 = m.Sha256,
Inode = m.Inode
})
.ToList(),
Matches = result.Matches
.Select(m => new RuntimeLibraryMatchDto
{
RuntimePath = m.RuntimePath,
RuntimeSha256 = m.RuntimeSha256,
SbomComponentKey = m.SbomComponentKey,
SbomComponentName = m.SbomComponentName,
MatchType = m.MatchType
})
.ToList(),
ReconciledAt = result.ReconciledAt,
ErrorCode = result.ErrorCode,
ErrorMessage = result.ErrorMessage
};
if (!string.IsNullOrEmpty(result.ErrorCode) &&
result.ErrorCode is "RUNTIME_EVENT_NOT_FOUND" or "NO_RUNTIME_EVENTS")
{
return Json(responseDto, StatusCodes.Status404NotFound);
}
return Json(responseDto, StatusCodes.Status200OK);
}
private static string NormalizeSegment(string segment)
{
if (string.IsNullOrWhiteSpace(segment))

View File

@@ -367,6 +367,20 @@ public sealed class ScannerWebServiceOptions
public int PerTenantBurst { get; set; } = 1000;
public int PolicyCacheTtlSeconds { get; set; } = 300;
/// <summary>
/// Enable automatic scanning when DRIFT events are detected.
/// When true, DRIFT events will trigger a new scan of the affected image.
/// Default: false (opt-in).
/// </summary>
public bool AutoScanEnabled { get; set; } = false;
/// <summary>
/// Cooldown period in seconds before the same image can be scanned again due to DRIFT.
/// Prevents scan storms from repeated DRIFT events.
/// Default: 300 seconds (5 minutes).
/// </summary>
public int AutoScanCooldownSeconds { get; set; } = 300;
}
public sealed class DeterminismOptions

View File

@@ -202,7 +202,9 @@ builder.Services.AddScannerStorage(storageOptions =>
});
builder.Services.AddSingleton<IPostConfigureOptions<ScannerStorageOptions>, ScannerStorageOptionsPostConfigurator>();
builder.Services.AddSingleton<RuntimeEventRateLimiter>();
builder.Services.AddSingleton<IDeltaScanRequestHandler, DeltaScanRequestHandler>();
builder.Services.AddSingleton<IRuntimeEventIngestionService, RuntimeEventIngestionService>();
builder.Services.AddSingleton<IRuntimeInventoryReconciler, RuntimeInventoryReconciler>();
builder.Services.AddSingleton<IRuntimeAttestationVerifier, RuntimeAttestationVerifier>();
builder.Services.AddSingleton<ILinksetResolver, LinksetResolver>();
builder.Services.AddSingleton<IRuntimePolicyService, RuntimePolicyService>();

View File

@@ -0,0 +1,260 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.WebService.Domain;
using StellaOps.Scanner.WebService.Options;
using StellaOps.Zastava.Core.Contracts;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// Handles delta scan requests triggered by runtime DRIFT events.
/// </summary>
internal interface IDeltaScanRequestHandler
{
/// <summary>
/// Processes a batch of runtime events and triggers scans for DRIFT events when enabled.
/// </summary>
Task<DeltaScanResult> ProcessAsync(
IReadOnlyList<RuntimeEventEnvelope> envelopes,
CancellationToken cancellationToken);
}
/// <summary>
/// Result of delta scan processing.
/// </summary>
internal readonly record struct DeltaScanResult(
int DriftEventsDetected,
int ScansTriggered,
int ScansSkipped,
int ScansDeduped);
internal sealed class DeltaScanRequestHandler : IDeltaScanRequestHandler
{
private static readonly Meter DeltaScanMeter = new("StellaOps.Scanner.DeltaScan", "1.0.0");
private static readonly Counter<long> DeltaScanTriggered = DeltaScanMeter.CreateCounter<long>(
"scanner_delta_scan_triggered_total",
unit: "1",
description: "Total delta scans triggered from runtime DRIFT events.");
private static readonly Counter<long> DeltaScanSkipped = DeltaScanMeter.CreateCounter<long>(
"scanner_delta_scan_skipped_total",
unit: "1",
description: "Total delta scans skipped (feature disabled, rate limited, or missing data).");
private static readonly Counter<long> DeltaScanDeduped = DeltaScanMeter.CreateCounter<long>(
"scanner_delta_scan_deduped_total",
unit: "1",
description: "Total delta scans deduplicated within cooldown window.");
private static readonly Histogram<double> DeltaScanLatencyMs = DeltaScanMeter.CreateHistogram<double>(
"scanner_delta_scan_latency_ms",
unit: "ms",
description: "Latency for delta scan trigger processing.");
// Deduplication cache: imageDigest -> last trigger time
private readonly ConcurrentDictionary<string, DateTimeOffset> _recentTriggers = new(StringComparer.OrdinalIgnoreCase);
private readonly IScanCoordinator _scanCoordinator;
private readonly IOptionsMonitor<ScannerWebServiceOptions> _optionsMonitor;
private readonly TimeProvider _timeProvider;
private readonly ILogger<DeltaScanRequestHandler> _logger;
public DeltaScanRequestHandler(
IScanCoordinator scanCoordinator,
IOptionsMonitor<ScannerWebServiceOptions> optionsMonitor,
TimeProvider timeProvider,
ILogger<DeltaScanRequestHandler> logger)
{
_scanCoordinator = scanCoordinator ?? throw new ArgumentNullException(nameof(scanCoordinator));
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<DeltaScanResult> ProcessAsync(
IReadOnlyList<RuntimeEventEnvelope> envelopes,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(envelopes);
var stopwatch = Stopwatch.StartNew();
var options = _optionsMonitor.CurrentValue.Runtime ?? new ScannerWebServiceOptions.RuntimeOptions();
// Check if autoscan is enabled
if (!options.AutoScanEnabled)
{
var driftCount = envelopes.Count(e => e.Event.Kind == RuntimeEventKind.Drift);
if (driftCount > 0)
{
DeltaScanSkipped.Add(driftCount);
_logger.LogDebug(
"Delta scan disabled, skipping {DriftCount} DRIFT events",
driftCount);
}
return new DeltaScanResult(driftCount, 0, driftCount, 0);
}
var driftEvents = envelopes
.Where(e => e.Event.Kind == RuntimeEventKind.Drift)
.ToList();
if (driftEvents.Count == 0)
{
return new DeltaScanResult(0, 0, 0, 0);
}
var now = _timeProvider.GetUtcNow();
var cooldownWindow = TimeSpan.FromSeconds(options.AutoScanCooldownSeconds);
var triggered = 0;
var skipped = 0;
var deduped = 0;
// Cleanup old entries from dedup cache
CleanupDeduplicationCache(now, cooldownWindow);
foreach (var envelope in driftEvents)
{
var runtimeEvent = envelope.Event;
var imageDigest = ExtractImageDigest(runtimeEvent);
if (string.IsNullOrWhiteSpace(imageDigest))
{
_logger.LogWarning(
"DRIFT event {EventId} has no image digest, skipping auto-scan",
runtimeEvent.EventId);
DeltaScanSkipped.Add(1);
skipped++;
continue;
}
// Check deduplication
if (_recentTriggers.TryGetValue(imageDigest, out var lastTrigger))
{
if (now - lastTrigger < cooldownWindow)
{
_logger.LogDebug(
"DRIFT event {EventId} for image {ImageDigest} within cooldown window, deduplicating",
runtimeEvent.EventId,
imageDigest);
DeltaScanDeduped.Add(1);
deduped++;
continue;
}
}
// Trigger scan
var scanTarget = new ScanTarget(
runtimeEvent.Workload.ImageRef,
imageDigest);
var metadata = new Dictionary<string, string>
{
["stellaops:trigger"] = "drift",
["stellaops:drift.eventId"] = runtimeEvent.EventId,
["stellaops:drift.tenant"] = runtimeEvent.Tenant,
["stellaops:drift.node"] = runtimeEvent.Node
};
if (runtimeEvent.Delta?.BaselineImageDigest is { } baseline)
{
metadata["stellaops:drift.baselineDigest"] = baseline;
}
if (runtimeEvent.Delta?.ChangedFiles is { Count: > 0 } changedFiles)
{
metadata["stellaops:drift.changedFilesCount"] = changedFiles.Count.ToString();
}
if (runtimeEvent.Delta?.NewBinaries is { Count: > 0 } newBinaries)
{
metadata["stellaops:drift.newBinariesCount"] = newBinaries.Count.ToString();
}
var submission = new ScanSubmission(
scanTarget.Normalize(),
Force: false,
ClientRequestId: $"drift:{runtimeEvent.EventId}",
Metadata: metadata);
try
{
var result = await _scanCoordinator.SubmitAsync(submission, cancellationToken).ConfigureAwait(false);
_recentTriggers[imageDigest] = now;
DeltaScanTriggered.Add(1);
triggered++;
_logger.LogInformation(
"Delta scan triggered for DRIFT event {EventId}: scanId={ScanId}, created={Created}",
runtimeEvent.EventId,
result.Snapshot.Id,
result.Created);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Failed to trigger delta scan for DRIFT event {EventId}, image {ImageDigest}",
runtimeEvent.EventId,
imageDigest);
DeltaScanSkipped.Add(1);
skipped++;
}
}
stopwatch.Stop();
DeltaScanLatencyMs.Record(stopwatch.Elapsed.TotalMilliseconds);
_logger.LogInformation(
"Delta scan processing complete: {DriftCount} DRIFT events, {Triggered} triggered, {Skipped} skipped, {Deduped} deduped",
driftEvents.Count,
triggered,
skipped,
deduped);
return new DeltaScanResult(driftEvents.Count, triggered, skipped, deduped);
}
private void CleanupDeduplicationCache(DateTimeOffset now, TimeSpan cooldownWindow)
{
var expiredKeys = _recentTriggers
.Where(kvp => now - kvp.Value > cooldownWindow * 2)
.Select(kvp => kvp.Key)
.ToList();
foreach (var key in expiredKeys)
{
_recentTriggers.TryRemove(key, out _);
}
}
private static string? ExtractImageDigest(RuntimeEvent runtimeEvent)
{
// Prefer baseline digest from Delta for DRIFT events
var digest = runtimeEvent.Delta?.BaselineImageDigest?.Trim().ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(digest))
{
return digest;
}
// Fall back to extracting from ImageRef
var imageRef = runtimeEvent.Workload.ImageRef;
if (string.IsNullOrWhiteSpace(imageRef))
{
return null;
}
var trimmed = imageRef.Trim();
var atIndex = trimmed.LastIndexOf('@');
if (atIndex >= 0 && atIndex < trimmed.Length - 1)
{
var candidate = trimmed[(atIndex + 1)..].Trim().ToLowerInvariant();
if (candidate.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
return candidate;
}
}
return null;
}
}

View File

@@ -23,6 +23,7 @@ internal sealed class RuntimeEventIngestionService : IRuntimeEventIngestionServi
private readonly RuntimeEventRepository _repository;
private readonly RuntimeEventRateLimiter _rateLimiter;
private readonly IDeltaScanRequestHandler _deltaScanHandler;
private readonly IOptionsMonitor<ScannerWebServiceOptions> _optionsMonitor;
private readonly TimeProvider _timeProvider;
private readonly ILogger<RuntimeEventIngestionService> _logger;
@@ -30,12 +31,14 @@ internal sealed class RuntimeEventIngestionService : IRuntimeEventIngestionServi
public RuntimeEventIngestionService(
RuntimeEventRepository repository,
RuntimeEventRateLimiter rateLimiter,
IDeltaScanRequestHandler deltaScanHandler,
IOptionsMonitor<ScannerWebServiceOptions> optionsMonitor,
TimeProvider timeProvider,
ILogger<RuntimeEventIngestionService> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_rateLimiter = rateLimiter ?? throw new ArgumentNullException(nameof(rateLimiter));
_deltaScanHandler = deltaScanHandler ?? throw new ArgumentNullException(nameof(deltaScanHandler));
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
@@ -126,9 +129,26 @@ internal sealed class RuntimeEventIngestionService : IRuntimeEventIngestionServi
insertResult.DuplicateCount,
totalPayloadBytes);
// Process DRIFT events for auto-scan (fire and forget, don't block ingestion)
_ = ProcessDriftEventsAsync(envelopes, cancellationToken);
return RuntimeEventIngestionResult.Success(insertResult.InsertedCount, insertResult.DuplicateCount, totalPayloadBytes);
}
private async Task ProcessDriftEventsAsync(
IReadOnlyList<RuntimeEventEnvelope> envelopes,
CancellationToken cancellationToken)
{
try
{
await _deltaScanHandler.ProcessAsync(envelopes, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing DRIFT events for auto-scan");
}
}
private static string? ExtractImageDigest(RuntimeEvent runtimeEvent)
{
var digest = NormalizeDigest(runtimeEvent.Delta?.BaselineImageDigest);

View File

@@ -0,0 +1,613 @@
using System.Collections.Immutable;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Text.Json;
using CycloneDX.Json;
using CycloneDX.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Storage;
using StellaOps.Scanner.Storage.Catalog;
using StellaOps.Scanner.Storage.ObjectStore;
using StellaOps.Scanner.Storage.Repositories;
using StellaOps.Zastava.Core.Contracts;
namespace StellaOps.Scanner.WebService.Services;
/// <summary>
/// Service responsible for reconciling runtime-observed libraries against static SBOM inventory.
/// </summary>
internal interface IRuntimeInventoryReconciler
{
/// <summary>
/// Reconciles runtime libraries from a runtime event against the SBOM for the associated image.
/// </summary>
Task<RuntimeReconciliationResult> ReconcileAsync(
RuntimeReconciliationRequest request,
CancellationToken cancellationToken);
}
/// <summary>
/// Request for runtime-static reconciliation.
/// </summary>
internal sealed record RuntimeReconciliationRequest
{
/// <summary>
/// Image digest to reconcile (e.g., sha256:abc123...).
/// </summary>
public required string ImageDigest { get; init; }
/// <summary>
/// Optional runtime event ID to use for library data.
/// If not provided, the most recent event for the image will be used.
/// </summary>
public string? RuntimeEventId { get; init; }
/// <summary>
/// Maximum number of misses to return.
/// </summary>
public int MaxMisses { get; init; } = 100;
}
/// <summary>
/// Result of runtime-static reconciliation.
/// </summary>
internal sealed record RuntimeReconciliationResult
{
public required string ImageDigest { get; init; }
public string? RuntimeEventId { get; init; }
public string? SbomArtifactId { get; init; }
public int TotalRuntimeLibraries { get; init; }
public int TotalSbomComponents { get; init; }
public int MatchCount { get; init; }
public int MissCount { get; init; }
/// <summary>
/// Libraries observed at runtime but not found in SBOM.
/// </summary>
public ImmutableArray<RuntimeLibraryMiss> Misses { get; init; } = [];
/// <summary>
/// Libraries matched between runtime and SBOM.
/// </summary>
public ImmutableArray<RuntimeLibraryMatch> Matches { get; init; } = [];
public DateTimeOffset ReconciledAt { get; init; }
public string? ErrorCode { get; init; }
public string? ErrorMessage { get; init; }
public static RuntimeReconciliationResult Error(string imageDigest, string code, string message)
=> new()
{
ImageDigest = imageDigest,
ErrorCode = code,
ErrorMessage = message,
ReconciledAt = DateTimeOffset.UtcNow
};
}
/// <summary>
/// A runtime library not found in the SBOM.
/// </summary>
internal sealed record RuntimeLibraryMiss
{
public required string Path { get; init; }
public string? Sha256 { get; init; }
public long? Inode { get; init; }
}
/// <summary>
/// A runtime library matched in the SBOM.
/// </summary>
internal sealed record RuntimeLibraryMatch
{
public required string RuntimePath { get; init; }
public string? RuntimeSha256 { get; init; }
public required string SbomComponentKey { get; init; }
public string? SbomComponentName { get; init; }
public string? MatchType { get; init; }
}
internal sealed class RuntimeInventoryReconciler : IRuntimeInventoryReconciler
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private static readonly Meter ReconcileMeter = new("StellaOps.Scanner.RuntimeReconcile", "1.0.0");
private static readonly Counter<long> ReconcileRequests = ReconcileMeter.CreateCounter<long>(
"scanner_runtime_reconcile_requests_total",
unit: "1",
description: "Total runtime-static reconciliation requests processed.");
private static readonly Counter<long> ReconcileMatches = ReconcileMeter.CreateCounter<long>(
"scanner_runtime_reconcile_matches_total",
unit: "1",
description: "Total library matches between runtime and SBOM.");
private static readonly Counter<long> ReconcileMisses = ReconcileMeter.CreateCounter<long>(
"scanner_runtime_reconcile_misses_total",
unit: "1",
description: "Total runtime libraries not found in SBOM.");
private static readonly Counter<long> ReconcileErrors = ReconcileMeter.CreateCounter<long>(
"scanner_runtime_reconcile_errors_total",
unit: "1",
description: "Total reconciliation errors (no SBOM, no events, etc.).");
private static readonly Histogram<double> ReconcileLatencyMs = ReconcileMeter.CreateHistogram<double>(
"scanner_runtime_reconcile_latency_ms",
unit: "ms",
description: "Latency for runtime-static reconciliation operations.");
private readonly RuntimeEventRepository _runtimeEventRepository;
private readonly LinkRepository _linkRepository;
private readonly ArtifactRepository _artifactRepository;
private readonly IArtifactObjectStore _objectStore;
private readonly IOptionsMonitor<ScannerStorageOptions> _storageOptions;
private readonly TimeProvider _timeProvider;
private readonly ILogger<RuntimeInventoryReconciler> _logger;
public RuntimeInventoryReconciler(
RuntimeEventRepository runtimeEventRepository,
LinkRepository linkRepository,
ArtifactRepository artifactRepository,
IArtifactObjectStore objectStore,
IOptionsMonitor<ScannerStorageOptions> storageOptions,
TimeProvider timeProvider,
ILogger<RuntimeInventoryReconciler> logger)
{
_runtimeEventRepository = runtimeEventRepository ?? throw new ArgumentNullException(nameof(runtimeEventRepository));
_linkRepository = linkRepository ?? throw new ArgumentNullException(nameof(linkRepository));
_artifactRepository = artifactRepository ?? throw new ArgumentNullException(nameof(artifactRepository));
_objectStore = objectStore ?? throw new ArgumentNullException(nameof(objectStore));
_storageOptions = storageOptions ?? throw new ArgumentNullException(nameof(storageOptions));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<RuntimeReconciliationResult> ReconcileAsync(
RuntimeReconciliationRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(request.ImageDigest);
var stopwatch = Stopwatch.StartNew();
ReconcileRequests.Add(1);
var normalizedDigest = NormalizeDigest(request.ImageDigest);
var reconciledAt = _timeProvider.GetUtcNow();
// Step 1: Get runtime event
RuntimeEventDocument? runtimeEventDoc;
if (!string.IsNullOrWhiteSpace(request.RuntimeEventId))
{
runtimeEventDoc = await _runtimeEventRepository.GetByEventIdAsync(
request.RuntimeEventId,
cancellationToken).ConfigureAwait(false);
if (runtimeEventDoc is null)
{
ReconcileErrors.Add(1);
RecordLatency(stopwatch);
return RuntimeReconciliationResult.Error(
normalizedDigest,
"RUNTIME_EVENT_NOT_FOUND",
$"Runtime event '{request.RuntimeEventId}' not found.");
}
}
else
{
var recentEvents = await _runtimeEventRepository.GetByImageDigestAsync(
normalizedDigest,
1,
cancellationToken).ConfigureAwait(false);
runtimeEventDoc = recentEvents.FirstOrDefault();
if (runtimeEventDoc is null)
{
ReconcileErrors.Add(1);
RecordLatency(stopwatch);
return RuntimeReconciliationResult.Error(
normalizedDigest,
"NO_RUNTIME_EVENTS",
$"No runtime events found for image '{normalizedDigest}'.");
}
}
// Step 2: Parse runtime event payload to get LoadedLibraries
var runtimeLibraries = ParseLoadedLibraries(runtimeEventDoc.PayloadJson);
if (runtimeLibraries.Count == 0)
{
_logger.LogInformation(
"No loaded libraries in runtime event {EventId} for image {ImageDigest}",
runtimeEventDoc.EventId,
normalizedDigest);
RecordLatency(stopwatch);
return new RuntimeReconciliationResult
{
ImageDigest = normalizedDigest,
RuntimeEventId = runtimeEventDoc.EventId,
TotalRuntimeLibraries = 0,
TotalSbomComponents = 0,
MatchCount = 0,
MissCount = 0,
ReconciledAt = reconciledAt
};
}
// Step 3: Get SBOM artifact for the image
var links = await _linkRepository.ListBySourceAsync(
LinkSourceType.Image,
normalizedDigest,
cancellationToken).ConfigureAwait(false);
var sbomLink = links.FirstOrDefault(l =>
l.ArtifactId.Contains("imagebom", StringComparison.OrdinalIgnoreCase));
if (sbomLink is null)
{
_logger.LogWarning(
"No SBOM artifact linked to image {ImageDigest}",
normalizedDigest);
ReconcileMisses.Add(runtimeLibraries.Count);
ReconcileErrors.Add(1);
RecordLatency(stopwatch);
// Return all runtime libraries as misses since no SBOM exists
return new RuntimeReconciliationResult
{
ImageDigest = normalizedDigest,
RuntimeEventId = runtimeEventDoc.EventId,
TotalRuntimeLibraries = runtimeLibraries.Count,
TotalSbomComponents = 0,
MatchCount = 0,
MissCount = runtimeLibraries.Count,
Misses = runtimeLibraries
.Take(request.MaxMisses)
.Select(lib => new RuntimeLibraryMiss
{
Path = lib.Path,
Sha256 = lib.Sha256,
Inode = lib.Inode
})
.ToImmutableArray(),
ReconciledAt = reconciledAt,
ErrorCode = "NO_SBOM",
ErrorMessage = "No SBOM artifact linked to this image."
};
}
// Step 4: Get SBOM content
var sbomArtifact = await _artifactRepository.GetAsync(sbomLink.ArtifactId, cancellationToken).ConfigureAwait(false);
if (sbomArtifact is null)
{
ReconcileErrors.Add(1);
RecordLatency(stopwatch);
return RuntimeReconciliationResult.Error(
normalizedDigest,
"SBOM_ARTIFACT_NOT_FOUND",
$"SBOM artifact '{sbomLink.ArtifactId}' metadata not found.");
}
var sbomComponents = await LoadSbomComponentsAsync(sbomArtifact, cancellationToken).ConfigureAwait(false);
// Step 5: Build lookup indexes for matching
var sbomByPath = BuildPathIndex(sbomComponents);
var sbomByHash = BuildHashIndex(sbomComponents);
// Step 6: Reconcile
var matches = new List<RuntimeLibraryMatch>();
var misses = new List<RuntimeLibraryMiss>();
foreach (var runtimeLib in runtimeLibraries)
{
var matched = TryMatchLibrary(runtimeLib, sbomByPath, sbomByHash, out var match);
if (matched && match is not null)
{
matches.Add(match);
}
else
{
misses.Add(new RuntimeLibraryMiss
{
Path = runtimeLib.Path,
Sha256 = runtimeLib.Sha256,
Inode = runtimeLib.Inode
});
}
}
_logger.LogInformation(
"Reconciliation complete for image {ImageDigest}: {MatchCount} matches, {MissCount} misses out of {TotalRuntime} runtime libs",
normalizedDigest,
matches.Count,
misses.Count,
runtimeLibraries.Count);
// Record metrics
ReconcileMatches.Add(matches.Count);
ReconcileMisses.Add(misses.Count);
RecordLatency(stopwatch);
return new RuntimeReconciliationResult
{
ImageDigest = normalizedDigest,
RuntimeEventId = runtimeEventDoc.EventId,
SbomArtifactId = sbomArtifact.Id,
TotalRuntimeLibraries = runtimeLibraries.Count,
TotalSbomComponents = sbomComponents.Count,
MatchCount = matches.Count,
MissCount = misses.Count,
Matches = matches.ToImmutableArray(),
Misses = misses.Take(request.MaxMisses).ToImmutableArray(),
ReconciledAt = reconciledAt
};
}
private IReadOnlyList<RuntimeLoadedLibrary> ParseLoadedLibraries(string payloadJson)
{
try
{
using var doc = JsonDocument.Parse(payloadJson);
var root = doc.RootElement;
// Navigate to event.loadedLibs
if (root.TryGetProperty("event", out var eventElement) &&
eventElement.TryGetProperty("loadedLibs", out var loadedLibsElement))
{
return JsonSerializer.Deserialize<List<RuntimeLoadedLibrary>>(
loadedLibsElement.GetRawText(),
JsonOptions) ?? [];
}
// Fallback: try loadedLibs at root level
if (root.TryGetProperty("loadedLibs", out loadedLibsElement))
{
return JsonSerializer.Deserialize<List<RuntimeLoadedLibrary>>(
loadedLibsElement.GetRawText(),
JsonOptions) ?? [];
}
return [];
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to parse loadedLibraries from runtime event payload");
return [];
}
}
private async Task<IReadOnlyList<SbomComponent>> LoadSbomComponentsAsync(
ArtifactDocument artifact,
CancellationToken cancellationToken)
{
var options = _storageOptions.CurrentValue;
var key = ArtifactObjectKeyBuilder.Build(
artifact.Type,
artifact.Format,
artifact.BytesSha256,
options.ObjectStore.RootPrefix);
var descriptor = new ArtifactObjectDescriptor(
options.ObjectStore.BucketName,
key,
artifact.Immutable);
await using var stream = await _objectStore.GetAsync(descriptor, cancellationToken).ConfigureAwait(false);
if (stream is null)
{
_logger.LogWarning("SBOM artifact content not found at {Key}", key);
return [];
}
try
{
var bom = await Serializer.DeserializeAsync(stream).ConfigureAwait(false);
if (bom?.Components is null)
{
return [];
}
return bom.Components
.Select(c => new SbomComponent
{
BomRef = c.BomRef ?? string.Empty,
Name = c.Name ?? string.Empty,
Version = c.Version,
Purl = c.Purl,
Hashes = c.Hashes?
.Where(h => h.Alg == Hash.HashAlgorithm.SHA_256)
.Select(h => h.Content)
.Where(content => !string.IsNullOrWhiteSpace(content))
.ToList() ?? [],
FilePaths = ExtractFilePaths(c)
})
.ToList();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to deserialize SBOM from artifact {ArtifactId}", artifact.Id);
return [];
}
}
private static IReadOnlyList<string> ExtractFilePaths(Component component)
{
var paths = new List<string>();
// Extract from evidence.occurrences
if (component.Evidence?.Occurrences is { } occurrences)
{
foreach (var occurrence in occurrences)
{
if (!string.IsNullOrWhiteSpace(occurrence.Location))
{
paths.Add(occurrence.Location);
}
}
}
// Extract from properties with specific names
if (component.Properties is { } props)
{
foreach (var prop in props)
{
if (prop.Name is "stellaops:file.path" or "cdx:file:path" &&
!string.IsNullOrWhiteSpace(prop.Value))
{
paths.Add(prop.Value);
}
}
}
return paths;
}
private static Dictionary<string, SbomComponent> BuildPathIndex(IReadOnlyList<SbomComponent> components)
{
var index = new Dictionary<string, SbomComponent>(StringComparer.Ordinal);
foreach (var component in components)
{
foreach (var path in component.FilePaths)
{
var normalizedPath = NormalizePath(path);
index.TryAdd(normalizedPath, component);
}
}
return index;
}
private static Dictionary<string, SbomComponent> BuildHashIndex(IReadOnlyList<SbomComponent> components)
{
var index = new Dictionary<string, SbomComponent>(StringComparer.OrdinalIgnoreCase);
foreach (var component in components)
{
foreach (var hash in component.Hashes)
{
var normalizedHash = NormalizeHash(hash);
index.TryAdd(normalizedHash, component);
}
}
return index;
}
private static bool TryMatchLibrary(
RuntimeLoadedLibrary runtimeLib,
Dictionary<string, SbomComponent> pathIndex,
Dictionary<string, SbomComponent> hashIndex,
out RuntimeLibraryMatch? match)
{
match = null;
// Try hash match first (most reliable)
if (!string.IsNullOrWhiteSpace(runtimeLib.Sha256))
{
var normalizedHash = NormalizeHash(runtimeLib.Sha256);
if (hashIndex.TryGetValue(normalizedHash, out var componentByHash))
{
match = new RuntimeLibraryMatch
{
RuntimePath = runtimeLib.Path,
RuntimeSha256 = runtimeLib.Sha256,
SbomComponentKey = componentByHash.BomRef,
SbomComponentName = componentByHash.Name,
MatchType = "sha256"
};
return true;
}
}
// Try path match
var normalizedPath = NormalizePath(runtimeLib.Path);
if (pathIndex.TryGetValue(normalizedPath, out var componentByPath))
{
match = new RuntimeLibraryMatch
{
RuntimePath = runtimeLib.Path,
RuntimeSha256 = runtimeLib.Sha256,
SbomComponentKey = componentByPath.BomRef,
SbomComponentName = componentByPath.Name,
MatchType = "path"
};
return true;
}
// Try matching by filename only (less strict)
var fileName = Path.GetFileName(runtimeLib.Path);
if (!string.IsNullOrWhiteSpace(fileName))
{
foreach (var component in pathIndex.Values)
{
if (component.FilePaths.Any(p => Path.GetFileName(p).Equals(fileName, StringComparison.Ordinal)))
{
match = new RuntimeLibraryMatch
{
RuntimePath = runtimeLib.Path,
RuntimeSha256 = runtimeLib.Sha256,
SbomComponentKey = component.BomRef,
SbomComponentName = component.Name,
MatchType = "filename"
};
return true;
}
}
}
return false;
}
private static string NormalizeDigest(string digest)
{
var trimmed = digest.Trim();
return trimmed.ToLowerInvariant();
}
private static string NormalizePath(string path)
{
// Normalize to forward slashes and trim
return path.Trim().Replace('\\', '/');
}
private static string NormalizeHash(string hash)
{
// Remove "sha256:" prefix if present and normalize to lowercase
var trimmed = hash.Trim();
if (trimmed.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
trimmed = trimmed["sha256:".Length..];
}
return trimmed.ToLowerInvariant();
}
private static void RecordLatency(Stopwatch stopwatch)
{
stopwatch.Stop();
ReconcileLatencyMs.Record(stopwatch.Elapsed.TotalMilliseconds);
}
private sealed record SbomComponent
{
public required string BomRef { get; init; }
public required string Name { get; init; }
public string? Version { get; init; }
public string? Purl { get; init; }
public IReadOnlyList<string> Hashes { get; init; } = [];
public IReadOnlyList<string> FilePaths { get; init; } = [];
}
}

View File

@@ -9,6 +9,7 @@
<RootNamespace>StellaOps.Scanner.WebService</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CycloneDX.Core" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />