546 lines
19 KiB
C#
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);
|
|
}
|
|
}
|