// ----------------------------------------------------------------------------- // SbomRegistryService.cs // Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring // Tasks: SBOM-8200-004, SBOM-8200-013 // Description: Service implementation for SBOM registration and advisory matching // ----------------------------------------------------------------------------- using Microsoft.Extensions.Logging; using StellaOps.Concelier.Interest; using StellaOps.Concelier.SbomIntegration.Events; using StellaOps.Concelier.SbomIntegration.Models; using StellaOps.Messaging.Abstractions; using System.Diagnostics; using System.Security.Cryptography; using System.Text; namespace StellaOps.Concelier.SbomIntegration; /// /// Service for registering SBOMs and matching them against canonical advisories. /// public sealed class SbomRegistryService : ISbomRegistryService { private readonly ISbomRegistryRepository _repository; private readonly ISbomAdvisoryMatcher _matcher; private readonly IInterestScoringService _scoringService; private readonly IEventStream? _eventStream; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; public SbomRegistryService( ISbomRegistryRepository repository, ISbomAdvisoryMatcher matcher, IInterestScoringService scoringService, ILogger logger, IEventStream? eventStream = null, TimeProvider? timeProvider = null) { _repository = repository ?? throw new ArgumentNullException(nameof(repository)); _matcher = matcher ?? throw new ArgumentNullException(nameof(matcher)); _scoringService = scoringService ?? throw new ArgumentNullException(nameof(scoringService)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _eventStream = eventStream; _timeProvider = timeProvider ?? TimeProvider.System; } #region Registration /// public async Task RegisterSbomAsync( SbomRegistrationInput input, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(input); // Check for existing registration var existing = await _repository.GetByDigestAsync(input.Digest, cancellationToken) .ConfigureAwait(false); if (existing is not null) { _logger.LogDebug( "SBOM {Digest} already registered, returning existing registration", input.Digest); return existing; } var registration = new SbomRegistration { Id = ComputeDeterministicRegistrationId(input.Digest, input.TenantId ?? "default"), Digest = input.Digest, Format = input.Format, SpecVersion = input.SpecVersion, PrimaryName = input.PrimaryName, PrimaryVersion = input.PrimaryVersion, ComponentCount = input.Purls.Count, Purls = input.Purls, RegisteredAt = _timeProvider.GetUtcNow(), Source = input.Source, TenantId = input.TenantId }; await _repository.SaveAsync(registration, cancellationToken).ConfigureAwait(false); _logger.LogInformation( "Registered SBOM {Digest} with {ComponentCount} components from source {Source}", input.Digest, registration.ComponentCount, input.Source); return registration; } /// public Task GetByDigestAsync( string digest, CancellationToken cancellationToken = default) { return _repository.GetByDigestAsync(digest, cancellationToken); } /// public Task GetByIdAsync( Guid id, CancellationToken cancellationToken = default) { return _repository.GetByIdAsync(id, cancellationToken); } /// public Task> ListAsync( int offset = 0, int limit = 50, string? tenantId = null, CancellationToken cancellationToken = default) { return _repository.ListAsync(offset, limit, tenantId, cancellationToken); } /// public async Task UnregisterAsync( string digest, CancellationToken cancellationToken = default) { var registration = await _repository.GetByDigestAsync(digest, cancellationToken) .ConfigureAwait(false); if (registration is not null) { await _repository.DeleteMatchesAsync(registration.Id, cancellationToken) .ConfigureAwait(false); } await _repository.DeleteAsync(digest, cancellationToken).ConfigureAwait(false); _logger.LogInformation("Unregistered SBOM {Digest}", digest); } #endregion #region Learning (Full Flow) /// public async Task LearnSbomAsync( SbomRegistrationInput input, CancellationToken cancellationToken = default) { var sw = Stopwatch.StartNew(); // Step 1: Register SBOM var registration = await RegisterSbomAsync(input, cancellationToken).ConfigureAwait(false); // Step 2: Match against advisories var matches = await _matcher.MatchAsync( registration.Id, registration.Digest, input.Purls, input.ReachabilityMap, input.DeploymentMap, cancellationToken).ConfigureAwait(false); // Step 3: Save matches await _repository.SaveMatchesAsync(registration.Id, matches, cancellationToken) .ConfigureAwait(false); // Step 4: Update registration metadata await _repository.UpdateAffectedCountAsync(registration.Digest, matches.Count, cancellationToken) .ConfigureAwait(false); await _repository.UpdateLastMatchedAsync(registration.Digest, _timeProvider.GetUtcNow(), cancellationToken) .ConfigureAwait(false); // Step 5: Update interest scores for affected canonicals var affectedCanonicalIds = matches .Select(m => m.CanonicalId) .Distinct() .ToList(); var scoresUpdated = 0; foreach (var canonicalId in affectedCanonicalIds) { try { var matchesForCanonical = matches.Where(m => m.CanonicalId == canonicalId).ToList(); var isReachable = matchesForCanonical.Any(m => m.IsReachable); var isDeployed = matchesForCanonical.Any(m => m.IsDeployed); var purl = matchesForCanonical.First().Purl; await _scoringService.RecordSbomMatchAsync( canonicalId, registration.Digest, purl, isReachable, isDeployed, cancellationToken).ConfigureAwait(false); scoresUpdated++; } catch (Exception ex) { _logger.LogWarning( ex, "Failed to update interest score for canonical {CanonicalId}", canonicalId); } } sw.Stop(); _logger.LogInformation( "Learned SBOM {Digest}: {MatchCount} matches, {ScoresUpdated} scores updated in {ElapsedMs}ms", registration.Digest, matches.Count, scoresUpdated, sw.ElapsedMilliseconds); var result = new SbomLearnResult { Registration = registration with { AffectedCount = matches.Count, LastMatchedAt = _timeProvider.GetUtcNow() }, Matches = matches, ScoresUpdated = scoresUpdated, ProcessingTimeMs = sw.Elapsed.TotalMilliseconds }; // Emit SbomLearned event await EmitLearnedEventAsync(result, affectedCanonicalIds, isRematch: false, cancellationToken) .ConfigureAwait(false); return result; } /// public async Task RematchSbomAsync( string digest, CancellationToken cancellationToken = default) { var registration = await _repository.GetByDigestAsync(digest, cancellationToken) .ConfigureAwait(false); if (registration is null) { throw new InvalidOperationException($"SBOM with digest {digest} not found"); } // Create input from existing registration var input = new SbomRegistrationInput { Digest = registration.Digest, Format = registration.Format, SpecVersion = registration.SpecVersion, PrimaryName = registration.PrimaryName, PrimaryVersion = registration.PrimaryVersion, Purls = registration.Purls, Source = registration.Source, TenantId = registration.TenantId }; // Clear existing matches await _repository.DeleteMatchesAsync(registration.Id, cancellationToken) .ConfigureAwait(false); // Re-run matching (skip registration since already exists) var sw = Stopwatch.StartNew(); var matches = await _matcher.MatchAsync( registration.Id, registration.Digest, registration.Purls, null, // No reachability data on rematch null, // No deployment data on rematch cancellationToken).ConfigureAwait(false); await _repository.SaveMatchesAsync(registration.Id, matches, cancellationToken) .ConfigureAwait(false); await _repository.UpdateAffectedCountAsync(digest, matches.Count, cancellationToken) .ConfigureAwait(false); await _repository.UpdateLastMatchedAsync(digest, _timeProvider.GetUtcNow(), cancellationToken) .ConfigureAwait(false); sw.Stop(); _logger.LogInformation( "Rematched SBOM {Digest}: {MatchCount} matches in {ElapsedMs}ms", digest, matches.Count, sw.ElapsedMilliseconds); var affectedCanonicalIds = matches .Select(m => m.CanonicalId) .Distinct() .ToList(); var result = new SbomLearnResult { Registration = registration with { AffectedCount = matches.Count, LastMatchedAt = _timeProvider.GetUtcNow() }, Matches = matches, ScoresUpdated = 0, // Rematch doesn't update scores ProcessingTimeMs = sw.Elapsed.TotalMilliseconds }; // Emit SbomLearned event await EmitLearnedEventAsync(result, affectedCanonicalIds, isRematch: true, cancellationToken) .ConfigureAwait(false); return result; } /// public async Task UpdateSbomDeltaAsync( string digest, SbomDeltaInput delta, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(delta); var registration = await _repository.GetByDigestAsync(digest, cancellationToken) .ConfigureAwait(false); if (registration is null) { throw new InvalidOperationException($"SBOM with digest {digest} not found"); } var sw = Stopwatch.StartNew(); // Calculate new PURL list var currentPurls = new HashSet(registration.Purls); var removedPurls = new HashSet(delta.RemovedPurls); var addedPurls = delta.AddedPurls.Where(p => !currentPurls.Contains(p)).ToList(); // Remove specified PURLs foreach (var purl in removedPurls) { currentPurls.Remove(purl); } // Add new PURLs foreach (var purl in addedPurls) { currentPurls.Add(purl); } var newPurls = currentPurls.ToList(); // Only match for added PURLs (optimization) var matchesForAdded = addedPurls.Count > 0 ? await _matcher.MatchAsync( registration.Id, registration.Digest, addedPurls, delta.ReachabilityMap, delta.DeploymentMap, cancellationToken).ConfigureAwait(false) : []; // Get existing matches and remove those for removed PURLs var existingMatches = await _repository.GetMatchesAsync(digest, cancellationToken) .ConfigureAwait(false); var filteredMatches = existingMatches .Where(m => !removedPurls.Contains(m.Purl)) .ToList(); // Combine existing (minus removed) with new matches var allMatches = filteredMatches.Concat(matchesForAdded).ToList(); // Update registration with new PURL list await _repository.UpdatePurlsAsync(digest, newPurls, cancellationToken) .ConfigureAwait(false); // Save updated matches await _repository.DeleteMatchesAsync(registration.Id, cancellationToken) .ConfigureAwait(false); await _repository.SaveMatchesAsync(registration.Id, allMatches, cancellationToken) .ConfigureAwait(false); await _repository.UpdateAffectedCountAsync(digest, allMatches.Count, cancellationToken) .ConfigureAwait(false); await _repository.UpdateLastMatchedAsync(digest, _timeProvider.GetUtcNow(), cancellationToken) .ConfigureAwait(false); // Update interest scores only for newly added matches var affectedCanonicalIds = matchesForAdded .Select(m => m.CanonicalId) .Distinct() .ToList(); var scoresUpdated = 0; foreach (var canonicalId in affectedCanonicalIds) { try { var matchesForCanonical = matchesForAdded.Where(m => m.CanonicalId == canonicalId).ToList(); var isReachable = matchesForCanonical.Any(m => m.IsReachable); var isDeployed = matchesForCanonical.Any(m => m.IsDeployed); var purl = matchesForCanonical.First().Purl; await _scoringService.RecordSbomMatchAsync( canonicalId, registration.Digest, purl, isReachable, isDeployed, cancellationToken).ConfigureAwait(false); scoresUpdated++; } catch (Exception ex) { _logger.LogWarning( ex, "Failed to update interest score for canonical {CanonicalId}", canonicalId); } } sw.Stop(); _logger.LogInformation( "Updated SBOM {Digest} delta: +{Added}/-{Removed} PURLs, {NewMatches} new matches, {ScoresUpdated} scores in {ElapsedMs}ms", digest, addedPurls.Count, removedPurls.Count, matchesForAdded.Count, scoresUpdated, sw.ElapsedMilliseconds); var result = new SbomLearnResult { Registration = registration with { ComponentCount = newPurls.Count, AffectedCount = allMatches.Count, LastMatchedAt = _timeProvider.GetUtcNow(), Purls = newPurls }, Matches = allMatches, ScoresUpdated = scoresUpdated, ProcessingTimeMs = sw.Elapsed.TotalMilliseconds }; // Emit SbomLearned event await EmitLearnedEventAsync(result, affectedCanonicalIds, isRematch: false, cancellationToken) .ConfigureAwait(false); return result; } #endregion #region Matching /// public Task> GetMatchesAsync( string digest, CancellationToken cancellationToken = default) { return _repository.GetMatchesAsync(digest, cancellationToken); } /// public Task> GetSbomsForAdvisoryAsync( Guid canonicalId, CancellationToken cancellationToken = default) { return _repository.GetMatchesByCanonicalAsync(canonicalId, cancellationToken); } #endregion #region Statistics /// public Task CountAsync( string? tenantId = null, CancellationToken cancellationToken = default) { return _repository.CountAsync(tenantId, cancellationToken); } /// public Task GetStatsAsync( string? tenantId = null, CancellationToken cancellationToken = default) { return _repository.GetStatsAsync(tenantId, cancellationToken); } #endregion #region Private Helpers private async Task EmitLearnedEventAsync( SbomLearnResult result, IReadOnlyList affectedCanonicalIds, bool isRematch, CancellationToken cancellationToken) { if (_eventStream is null) { return; } try { var @event = new SbomLearnedEvent { SbomId = result.Registration.Id, SbomDigest = result.Registration.Digest, TenantId = result.Registration.TenantId, PrimaryName = result.Registration.PrimaryName, PrimaryVersion = result.Registration.PrimaryVersion, ComponentCount = result.Registration.ComponentCount, AdvisoriesMatched = result.Matches.Count, ScoresUpdated = result.ScoresUpdated, AffectedCanonicalIds = affectedCanonicalIds, ProcessingTimeMs = result.ProcessingTimeMs, IsRematch = isRematch }; await _eventStream.PublishAsync(@event, cancellationToken: cancellationToken) .ConfigureAwait(false); _logger.LogDebug( "Emitted SbomLearned event for SBOM {SbomDigest}", result.Registration.Digest); } catch (Exception ex) { _logger.LogWarning( ex, "Failed to emit SbomLearned event for SBOM {SbomDigest}", result.Registration.Digest); } } #endregion /// /// Computes a deterministic registration ID from SBOM digest and tenant. /// private static Guid ComputeDeterministicRegistrationId(string digest, string tenantId) { var input = $"SBOM_REG:{tenantId}:{digest}"; var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(input))[..16]; return new Guid(hashBytes); } }