save development progress
This commit is contained in:
@@ -0,0 +1,529 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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 System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Concelier.Interest;
|
||||
using StellaOps.Concelier.SbomIntegration.Events;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
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;
|
||||
|
||||
public SbomRegistryService(
|
||||
ISbomRegistryRepository repository,
|
||||
ISbomAdvisoryMatcher matcher,
|
||||
IInterestScoringService scoringService,
|
||||
ILogger<SbomRegistryService> logger,
|
||||
IEventStream<SbomLearnedEvent>? eventStream = 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;
|
||||
}
|
||||
|
||||
#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 = Guid.NewGuid(),
|
||||
Digest = input.Digest,
|
||||
Format = input.Format,
|
||||
SpecVersion = input.SpecVersion,
|
||||
PrimaryName = input.PrimaryName,
|
||||
PrimaryVersion = input.PrimaryVersion,
|
||||
ComponentCount = input.Purls.Count,
|
||||
Purls = input.Purls,
|
||||
RegisteredAt = DateTimeOffset.UtcNow,
|
||||
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, DateTimeOffset.UtcNow, 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 = DateTimeOffset.UtcNow
|
||||
},
|
||||
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, DateTimeOffset.UtcNow, 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 = DateTimeOffset.UtcNow
|
||||
},
|
||||
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, DateTimeOffset.UtcNow, 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 = DateTimeOffset.UtcNow,
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user