Files
git.stella-ops.org/src/Concelier/__Libraries/StellaOps.Concelier.SbomIntegration/SbomRegistryService.cs
2026-02-01 21:37:40 +02:00

546 lines
19 KiB
C#

// -----------------------------------------------------------------------------
// 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;
/// <summary>
/// Service for registering SBOMs and matching them against canonical advisories.
/// </summary>
public sealed class SbomRegistryService : ISbomRegistryService
{
private readonly ISbomRegistryRepository _repository;
private readonly ISbomAdvisoryMatcher _matcher;
private readonly IInterestScoringService _scoringService;
private readonly IEventStream<SbomLearnedEvent>? _eventStream;
private readonly ILogger<SbomRegistryService> _logger;
private readonly TimeProvider _timeProvider;
public SbomRegistryService(
ISbomRegistryRepository repository,
ISbomAdvisoryMatcher matcher,
IInterestScoringService scoringService,
ILogger<SbomRegistryService> logger,
IEventStream<SbomLearnedEvent>? 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
/// <inheritdoc />
public async Task<SbomRegistration> 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;
}
/// <inheritdoc />
public Task<SbomRegistration?> GetByDigestAsync(
string digest,
CancellationToken cancellationToken = default)
{
return _repository.GetByDigestAsync(digest, cancellationToken);
}
/// <inheritdoc />
public Task<SbomRegistration?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken = default)
{
return _repository.GetByIdAsync(id, cancellationToken);
}
/// <inheritdoc />
public Task<IReadOnlyList<SbomRegistration>> ListAsync(
int offset = 0,
int limit = 50,
string? tenantId = null,
CancellationToken cancellationToken = default)
{
return _repository.ListAsync(offset, limit, tenantId, cancellationToken);
}
/// <inheritdoc />
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)
/// <inheritdoc />
public async Task<SbomLearnResult> 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;
}
/// <inheritdoc />
public async Task<SbomLearnResult> 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;
}
/// <inheritdoc />
public async Task<SbomLearnResult> 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<string>(registration.Purls);
var removedPurls = new HashSet<string>(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
/// <inheritdoc />
public Task<IReadOnlyList<SbomAdvisoryMatch>> GetMatchesAsync(
string digest,
CancellationToken cancellationToken = default)
{
return _repository.GetMatchesAsync(digest, cancellationToken);
}
/// <inheritdoc />
public Task<IReadOnlyList<SbomAdvisoryMatch>> GetSbomsForAdvisoryAsync(
Guid canonicalId,
CancellationToken cancellationToken = default)
{
return _repository.GetMatchesByCanonicalAsync(canonicalId, cancellationToken);
}
#endregion
#region Statistics
/// <inheritdoc />
public Task<long> CountAsync(
string? tenantId = null,
CancellationToken cancellationToken = default)
{
return _repository.CountAsync(tenantId, cancellationToken);
}
/// <inheritdoc />
public Task<SbomRegistryStats> GetStatsAsync(
string? tenantId = null,
CancellationToken cancellationToken = default)
{
return _repository.GetStatsAsync(tenantId, cancellationToken);
}
#endregion
#region Private Helpers
private async Task EmitLearnedEventAsync(
SbomLearnResult result,
IReadOnlyList<Guid> 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
/// <summary>
/// Computes a deterministic registration ID from SBOM digest and tenant.
/// </summary>
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);
}
}