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; /// /// Service responsible for reconciling runtime-observed libraries against static SBOM inventory. /// internal interface IRuntimeInventoryReconciler { /// /// Reconciles runtime libraries from a runtime event against the SBOM for the associated image. /// Task ReconcileAsync( RuntimeReconciliationRequest request, CancellationToken cancellationToken); } /// /// Request for runtime-static reconciliation. /// internal sealed record RuntimeReconciliationRequest { /// /// Image digest to reconcile (e.g., sha256:abc123...). /// public required string ImageDigest { get; init; } /// /// Optional runtime event ID to use for library data. /// If not provided, the most recent event for the image will be used. /// public string? RuntimeEventId { get; init; } /// /// Maximum number of misses to return. /// public int MaxMisses { get; init; } = 100; } /// /// Result of runtime-static reconciliation. /// 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; } /// /// Libraries observed at runtime but not found in SBOM. /// public ImmutableArray Misses { get; init; } = []; /// /// Libraries matched between runtime and SBOM. /// public ImmutableArray 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 }; } /// /// A runtime library not found in the SBOM. /// internal sealed record RuntimeLibraryMiss { public required string Path { get; init; } public string? Sha256 { get; init; } public long? Inode { get; init; } } /// /// A runtime library matched in the SBOM. /// 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 ReconcileRequests = ReconcileMeter.CreateCounter( "scanner_runtime_reconcile_requests_total", unit: "1", description: "Total runtime-static reconciliation requests processed."); private static readonly Counter ReconcileMatches = ReconcileMeter.CreateCounter( "scanner_runtime_reconcile_matches_total", unit: "1", description: "Total library matches between runtime and SBOM."); private static readonly Counter ReconcileMisses = ReconcileMeter.CreateCounter( "scanner_runtime_reconcile_misses_total", unit: "1", description: "Total runtime libraries not found in SBOM."); private static readonly Counter ReconcileErrors = ReconcileMeter.CreateCounter( "scanner_runtime_reconcile_errors_total", unit: "1", description: "Total reconciliation errors (no SBOM, no events, etc.)."); private static readonly Histogram ReconcileLatencyMs = ReconcileMeter.CreateHistogram( "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 _storageOptions; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; public RuntimeInventoryReconciler( RuntimeEventRepository runtimeEventRepository, LinkRepository linkRepository, ArtifactRepository artifactRepository, IArtifactObjectStore objectStore, IOptionsMonitor storageOptions, TimeProvider timeProvider, ILogger 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 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(); var misses = new List(); 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 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>( loadedLibsElement.GetRawText(), JsonOptions) ?? []; } // Fallback: try loadedLibs at root level if (root.TryGetProperty("loadedLibs", out loadedLibsElement)) { return JsonSerializer.Deserialize>( loadedLibsElement.GetRawText(), JsonOptions) ?? []; } return []; } catch (JsonException ex) { _logger.LogWarning(ex, "Failed to parse loadedLibraries from runtime event payload"); return []; } } private async Task> LoadSbomComponentsAsync( ArtifactDocument artifact, CancellationToken cancellationToken) { var options = _storageOptions.CurrentValue; var primaryKey = ArtifactObjectKeyBuilder.Build( artifact.Type, artifact.Format, artifact.BytesSha256, options.ObjectStore.RootPrefix); var candidates = new List { primaryKey, ArtifactObjectKeyBuilder.Build( artifact.Type, artifact.Format, artifact.BytesSha256, rootPrefix: null) }; var legacyDigest = NormalizeLegacyDigest(artifact.BytesSha256); candidates.Add($"{MapLegacyTypeSegment(artifact.Type)}/{MapLegacyFormatSegment(artifact.Format)}/{legacyDigest}"); if (legacyDigest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)) { candidates.Add($"{MapLegacyTypeSegment(artifact.Type)}/{MapLegacyFormatSegment(artifact.Format)}/{legacyDigest["sha256:".Length..]}"); } Stream? stream = null; string? resolvedKey = null; foreach (var candidateKey in candidates.Distinct(StringComparer.Ordinal)) { var descriptor = new ArtifactObjectDescriptor( options.ObjectStore.BucketName, candidateKey, artifact.Immutable); stream = await _objectStore.GetAsync(descriptor, cancellationToken).ConfigureAwait(false); if (stream is not null) { resolvedKey = candidateKey; break; } } if (stream is null) { _logger.LogWarning("SBOM artifact content not found at {Key}", primaryKey); return []; } try { await using (stream) { 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} ({ResolvedKey})", artifact.Id, resolvedKey ?? primaryKey); return []; } } private static IReadOnlyList ExtractFilePaths(Component component) { var paths = new List(); // 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 BuildPathIndex(IReadOnlyList components) { var index = new Dictionary(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 BuildHashIndex(IReadOnlyList components) { var index = new Dictionary(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 pathIndex, Dictionary 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 string NormalizeLegacyDigest(string digest) => digest.Contains(':', StringComparison.Ordinal) ? digest.Trim() : $"sha256:{digest.Trim()}"; private static string MapLegacyTypeSegment(ArtifactDocumentType type) => type switch { ArtifactDocumentType.LayerBom => "layerbom", ArtifactDocumentType.ImageBom => "imagebom", ArtifactDocumentType.Index => "index", ArtifactDocumentType.Attestation => "attestation", ArtifactDocumentType.SurfaceManifest => "surface-manifest", ArtifactDocumentType.SurfaceEntryTrace => "surface-entrytrace", ArtifactDocumentType.SurfaceLayerFragment => "surface-layer-fragment", ArtifactDocumentType.Diff => "diff", _ => type.ToString().ToLowerInvariant() }; private static string MapLegacyFormatSegment(ArtifactDocumentFormat format) => format switch { ArtifactDocumentFormat.CycloneDxJson => "cyclonedx-json", ArtifactDocumentFormat.CycloneDxProtobuf => "cyclonedx-protobuf", ArtifactDocumentFormat.SpdxJson => "spdx-json", ArtifactDocumentFormat.BomIndex => "bom-index", ArtifactDocumentFormat.DsseJson => "dsse-json", ArtifactDocumentFormat.SurfaceManifestJson => "surface-manifest-json", ArtifactDocumentFormat.EntryTraceNdjson => "entrytrace-ndjson", ArtifactDocumentFormat.EntryTraceGraphJson => "entrytrace-graph-json", ArtifactDocumentFormat.ComponentFragmentJson => "component-fragment-json", _ => format.ToString().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 Hashes { get; init; } = []; public IReadOnlyList FilePaths { get; init; } = []; } }