save development progress

This commit is contained in:
StellaOps Bot
2025-12-25 23:09:58 +02:00
parent d71853ad7e
commit aa70af062e
351 changed files with 37683 additions and 150156 deletions

View File

@@ -0,0 +1,51 @@
// -----------------------------------------------------------------------------
// SbomLearnedEvent.cs
// Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring
// Task: SBOM-8200-024
// Description: Event emitted when an SBOM is learned/registered
// -----------------------------------------------------------------------------
namespace StellaOps.Concelier.SbomIntegration.Events;
/// <summary>
/// Event emitted when an SBOM has been registered and matched against advisories.
/// Downstream consumers can use this to trigger additional processing.
/// </summary>
public sealed record SbomLearnedEvent
{
/// <summary>Event timestamp.</summary>
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
/// <summary>SBOM registration ID.</summary>
public required Guid SbomId { get; init; }
/// <summary>SBOM content digest.</summary>
public required string SbomDigest { get; init; }
/// <summary>Optional tenant ID.</summary>
public string? TenantId { get; init; }
/// <summary>Primary component name (e.g., image name).</summary>
public string? PrimaryName { get; init; }
/// <summary>Primary component version.</summary>
public string? PrimaryVersion { get; init; }
/// <summary>Total components in the SBOM.</summary>
public int ComponentCount { get; init; }
/// <summary>Number of advisories matched.</summary>
public int AdvisoriesMatched { get; init; }
/// <summary>Number of interest scores updated.</summary>
public int ScoresUpdated { get; init; }
/// <summary>Canonical advisory IDs that were matched.</summary>
public required IReadOnlyList<Guid> AffectedCanonicalIds { get; init; }
/// <summary>Processing duration in milliseconds.</summary>
public double ProcessingTimeMs { get; init; }
/// <summary>Whether this was a re-match of an existing SBOM.</summary>
public bool IsRematch { get; init; }
}

View File

@@ -0,0 +1,56 @@
// -----------------------------------------------------------------------------
// ISbomAdvisoryMatcher.cs
// Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring
// Task: SBOM-8200-008
// Description: Interface for matching SBOM components against advisories
// -----------------------------------------------------------------------------
using StellaOps.Concelier.SbomIntegration.Models;
namespace StellaOps.Concelier.SbomIntegration;
/// <summary>
/// Service for matching SBOM components against canonical advisories.
/// </summary>
public interface ISbomAdvisoryMatcher
{
/// <summary>
/// Matches a set of PURLs against canonical advisories.
/// </summary>
/// <param name="sbomId">SBOM registration ID.</param>
/// <param name="sbomDigest">SBOM content digest.</param>
/// <param name="purls">PURLs to match.</param>
/// <param name="reachabilityMap">Optional reachability data per PURL.</param>
/// <param name="deploymentMap">Optional deployment status per PURL.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of matches found.</returns>
Task<IReadOnlyList<SbomAdvisoryMatch>> MatchAsync(
Guid sbomId,
string sbomDigest,
IEnumerable<string> purls,
IReadOnlyDictionary<string, bool>? reachabilityMap = null,
IReadOnlyDictionary<string, bool>? deploymentMap = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all canonical IDs that could affect a PURL.
/// </summary>
/// <param name="purl">Package URL.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of canonical advisory IDs.</returns>
Task<IReadOnlyList<Guid>> FindAffectingCanonicalIdsAsync(
string purl,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if a specific PURL is affected by a specific advisory.
/// </summary>
/// <param name="purl">Package URL.</param>
/// <param name="canonicalId">Canonical advisory ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Match result, or null if not affected.</returns>
Task<SbomAdvisoryMatch?> CheckMatchAsync(
string purl,
Guid canonicalId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,112 @@
// -----------------------------------------------------------------------------
// ISbomRegistryRepository.cs
// Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring
// Task: SBOM-8200-001
// Description: Repository interface for SBOM registry persistence
// -----------------------------------------------------------------------------
using StellaOps.Concelier.SbomIntegration.Models;
namespace StellaOps.Concelier.SbomIntegration;
/// <summary>
/// Repository for SBOM registration persistence.
/// </summary>
public interface ISbomRegistryRepository
{
#region Registration CRUD
/// <summary>
/// Saves or updates an SBOM registration.
/// </summary>
Task SaveAsync(SbomRegistration registration, CancellationToken cancellationToken = default);
/// <summary>
/// Gets an SBOM registration by digest.
/// </summary>
Task<SbomRegistration?> GetByDigestAsync(string digest, CancellationToken cancellationToken = default);
/// <summary>
/// Gets an SBOM registration by ID.
/// </summary>
Task<SbomRegistration?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Lists registrations with pagination.
/// </summary>
Task<IReadOnlyList<SbomRegistration>> ListAsync(
int offset,
int limit,
string? tenantId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes an SBOM registration by digest.
/// </summary>
Task DeleteAsync(string digest, CancellationToken cancellationToken = default);
/// <summary>
/// Counts total registrations.
/// </summary>
Task<long> CountAsync(string? tenantId = null, CancellationToken cancellationToken = default);
#endregion
#region Match CRUD
/// <summary>
/// Saves SBOM matches (replaces existing).
/// </summary>
Task SaveMatchesAsync(
Guid sbomId,
IEnumerable<SbomAdvisoryMatch> matches,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets matches for an SBOM.
/// </summary>
Task<IReadOnlyList<SbomAdvisoryMatch>> GetMatchesAsync(
string digest,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets matches for a canonical advisory.
/// </summary>
Task<IReadOnlyList<SbomAdvisoryMatch>> GetMatchesByCanonicalAsync(
Guid canonicalId,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes all matches for an SBOM.
/// </summary>
Task DeleteMatchesAsync(Guid sbomId, CancellationToken cancellationToken = default);
#endregion
#region Statistics
/// <summary>
/// Gets registry statistics.
/// </summary>
Task<SbomRegistryStats> GetStatsAsync(
string? tenantId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates affected count for an SBOM.
/// </summary>
Task UpdateAffectedCountAsync(
string digest,
int affectedCount,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates last matched timestamp.
/// </summary>
Task UpdateLastMatchedAsync(
string digest,
DateTimeOffset lastMatched,
CancellationToken cancellationToken = default);
#endregion
}

View File

@@ -0,0 +1,179 @@
// -----------------------------------------------------------------------------
// ISbomRegistryService.cs
// Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring
// Task: SBOM-8200-001
// Description: Service interface for SBOM registration and advisory matching
// -----------------------------------------------------------------------------
using StellaOps.Concelier.SbomIntegration.Models;
namespace StellaOps.Concelier.SbomIntegration;
/// <summary>
/// Service for registering SBOMs and matching them against canonical advisories.
/// </summary>
public interface ISbomRegistryService
{
#region Registration
/// <summary>
/// Registers an SBOM for advisory matching.
/// </summary>
/// <param name="input">SBOM registration input.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>SBOM registration record.</returns>
Task<SbomRegistration> RegisterSbomAsync(
SbomRegistrationInput input,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets an SBOM registration by digest.
/// </summary>
/// <param name="digest">SBOM content digest.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Registration, or null if not found.</returns>
Task<SbomRegistration?> GetByDigestAsync(
string digest,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets an SBOM registration by ID.
/// </summary>
/// <param name="id">Registration ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Registration, or null if not found.</returns>
Task<SbomRegistration?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists registered SBOMs with pagination.
/// </summary>
/// <param name="offset">Pagination offset.</param>
/// <param name="limit">Maximum results.</param>
/// <param name="tenantId">Optional tenant filter.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of registrations.</returns>
Task<IReadOnlyList<SbomRegistration>> ListAsync(
int offset = 0,
int limit = 50,
string? tenantId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Removes an SBOM registration.
/// </summary>
/// <param name="digest">SBOM content digest.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UnregisterAsync(
string digest,
CancellationToken cancellationToken = default);
#endregion
#region Learning (Full Flow)
/// <summary>
/// Learns from an SBOM: registers, matches advisories, updates scores.
/// </summary>
/// <param name="input">SBOM registration input.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Learn result with matches and score updates.</returns>
Task<SbomLearnResult> LearnSbomAsync(
SbomRegistrationInput input,
CancellationToken cancellationToken = default);
/// <summary>
/// Re-matches an existing SBOM against current advisories.
/// </summary>
/// <param name="digest">SBOM content digest.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Learn result with updated matches.</returns>
Task<SbomLearnResult> RematchSbomAsync(
string digest,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates an SBOM with delta changes (added/removed PURLs).
/// Performs incremental matching only for changed components.
/// </summary>
/// <param name="digest">SBOM content digest.</param>
/// <param name="delta">Delta changes.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Learn result with incremental matches.</returns>
Task<SbomLearnResult> UpdateSbomDeltaAsync(
string digest,
SbomDeltaInput delta,
CancellationToken cancellationToken = default);
#endregion
#region Matching
/// <summary>
/// Gets advisories affecting an SBOM.
/// </summary>
/// <param name="digest">SBOM content digest.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of matches.</returns>
Task<IReadOnlyList<SbomAdvisoryMatch>> GetMatchesAsync(
string digest,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets SBOMs affected by a canonical advisory.
/// </summary>
/// <param name="canonicalId">Canonical advisory ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of matches.</returns>
Task<IReadOnlyList<SbomAdvisoryMatch>> GetSbomsForAdvisoryAsync(
Guid canonicalId,
CancellationToken cancellationToken = default);
#endregion
#region Statistics
/// <summary>
/// Counts total registered SBOMs.
/// </summary>
/// <param name="tenantId">Optional tenant filter.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Total count.</returns>
Task<long> CountAsync(
string? tenantId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets SBOM registry statistics.
/// </summary>
/// <param name="tenantId">Optional tenant filter.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Statistics.</returns>
Task<SbomRegistryStats> GetStatsAsync(
string? tenantId = null,
CancellationToken cancellationToken = default);
#endregion
}
/// <summary>
/// SBOM registry statistics.
/// </summary>
public sealed record SbomRegistryStats
{
/// <summary>Total registered SBOMs.</summary>
public long TotalSboms { get; init; }
/// <summary>Total unique PURLs across all SBOMs.</summary>
public long TotalPurls { get; init; }
/// <summary>Total advisory matches.</summary>
public long TotalMatches { get; init; }
/// <summary>SBOMs with at least one advisory match.</summary>
public long AffectedSboms { get; init; }
/// <summary>Average advisories per SBOM.</summary>
public double AverageMatchesPerSbom { get; init; }
}

View File

@@ -0,0 +1,155 @@
// -----------------------------------------------------------------------------
// IPurlCanonicalIndex.cs
// Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring
// Task: SBOM-8200-006
// Description: Interface for PURL to canonical advisory index
// -----------------------------------------------------------------------------
using StellaOps.Concelier.SbomIntegration.Models;
namespace StellaOps.Concelier.SbomIntegration.Index;
/// <summary>
/// Index for fast PURL to canonical advisory lookups.
/// </summary>
public interface IPurlCanonicalIndex
{
/// <summary>
/// Gets all canonical advisory IDs that affect a PURL.
/// </summary>
/// <param name="purl">Package URL.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of canonical advisory IDs.</returns>
Task<IReadOnlyList<Guid>> GetCanonicalIdsForPurlAsync(
string purl,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets canonical IDs for multiple PURLs in batch.
/// </summary>
/// <param name="purls">Package URLs.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Dictionary mapping PURLs to canonical IDs.</returns>
Task<IReadOnlyDictionary<string, IReadOnlyList<Guid>>> GetCanonicalIdsForPurlsBatchAsync(
IEnumerable<string> purls,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if a PURL is affected by a specific canonical advisory.
/// </summary>
/// <param name="purl">Package URL.</param>
/// <param name="canonicalId">Canonical advisory ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if affected.</returns>
Task<bool> IsAffectedAsync(
string purl,
Guid canonicalId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets match information for a PURL and canonical advisory pair.
/// </summary>
/// <param name="purl">Package URL.</param>
/// <param name="canonicalId">Canonical advisory ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Match information, or null if not affected.</returns>
Task<PurlMatchInfo?> GetMatchInfoAsync(
string purl,
Guid canonicalId,
CancellationToken cancellationToken = default);
/// <summary>
/// Indexes a canonical advisory for PURL lookups.
/// </summary>
/// <param name="canonicalId">Canonical advisory ID.</param>
/// <param name="affectsKey">Affected PURL or CPE.</param>
/// <param name="versionConstraint">Optional version constraint.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task IndexCanonicalAsync(
Guid canonicalId,
string affectsKey,
string? versionConstraint = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Indexes multiple canonical advisories in batch.
/// </summary>
/// <param name="entries">Entries to index.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task IndexCanonicalBatchAsync(
IEnumerable<PurlIndexEntry> entries,
CancellationToken cancellationToken = default);
/// <summary>
/// Removes a canonical advisory from the index.
/// </summary>
/// <param name="canonicalId">Canonical advisory ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UnindexCanonicalAsync(
Guid canonicalId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets index statistics.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Index statistics.</returns>
Task<PurlIndexStats> GetStatsAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// Match information between a PURL and a canonical advisory.
/// </summary>
public sealed record PurlMatchInfo
{
/// <summary>Matched PURL.</summary>
public required string Purl { get; init; }
/// <summary>Canonical advisory ID.</summary>
public required Guid CanonicalId { get; init; }
/// <summary>Match method used.</summary>
public MatchMethod Method { get; init; } = MatchMethod.ExactPurl;
/// <summary>Match confidence (0-1).</summary>
public double Confidence { get; init; } = 1.0;
/// <summary>Version constraint from advisory.</summary>
public string? VersionConstraint { get; init; }
}
/// <summary>
/// Entry for batch indexing.
/// </summary>
public sealed record PurlIndexEntry
{
/// <summary>Canonical advisory ID.</summary>
public required Guid CanonicalId { get; init; }
/// <summary>Affected PURL or CPE.</summary>
public required string AffectsKey { get; init; }
/// <summary>Optional version constraint.</summary>
public string? VersionConstraint { get; init; }
}
/// <summary>
/// PURL index statistics.
/// </summary>
public sealed record PurlIndexStats
{
/// <summary>Total indexed PURLs.</summary>
public long TotalPurls { get; init; }
/// <summary>Total indexed canonicals.</summary>
public long TotalCanonicals { get; init; }
/// <summary>Total PURL→canonical mappings.</summary>
public long TotalMappings { get; init; }
/// <summary>Cache hit rate (0-1).</summary>
public double CacheHitRate { get; init; }
/// <summary>Last index update time.</summary>
public DateTimeOffset? LastUpdatedAt { get; init; }
}

View File

@@ -0,0 +1,396 @@
// -----------------------------------------------------------------------------
// ValkeyPurlCanonicalIndex.cs
// Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring
// Tasks: SBOM-8200-006, SBOM-8200-011
// Description: Valkey-backed PURL to canonical advisory index
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Concelier.Core.Canonical;
using StellaOps.Concelier.SbomIntegration.Models;
using StackExchange.Redis;
namespace StellaOps.Concelier.SbomIntegration.Index;
/// <summary>
/// Valkey-backed implementation of PURL to canonical advisory index.
/// Provides fast O(1) lookups from PURL to canonical IDs.
/// </summary>
public sealed class ValkeyPurlCanonicalIndex : IPurlCanonicalIndex
{
private const string PurlKeyPrefix = "purl:";
private const string CanonicalKeyPrefix = "canonical:purls:";
private const string StatsKey = "purl_index:stats";
private static readonly TimeSpan DefaultTtl = TimeSpan.FromHours(24);
private readonly IConnectionMultiplexer _redis;
private readonly ICanonicalAdvisoryService _canonicalService;
private readonly ILogger<ValkeyPurlCanonicalIndex> _logger;
private long _cacheHits;
private long _cacheMisses;
public ValkeyPurlCanonicalIndex(
IConnectionMultiplexer redis,
ICanonicalAdvisoryService canonicalService,
ILogger<ValkeyPurlCanonicalIndex> logger)
{
_redis = redis ?? throw new ArgumentNullException(nameof(redis));
_canonicalService = canonicalService ?? throw new ArgumentNullException(nameof(canonicalService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<IReadOnlyList<Guid>> GetCanonicalIdsForPurlAsync(
string purl,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(purl))
{
return [];
}
var db = _redis.GetDatabase();
var key = GetPurlKey(purl);
// Try cache first
var cached = await db.StringGetAsync(key).ConfigureAwait(false);
if (cached.HasValue)
{
Interlocked.Increment(ref _cacheHits);
return DeserializeGuids(cached!);
}
Interlocked.Increment(ref _cacheMisses);
// Fall back to database
var advisories = await _canonicalService.GetByArtifactAsync(purl, cancellationToken)
.ConfigureAwait(false);
var ids = advisories.Select(a => a.Id).ToList();
// Cache the result
if (ids.Count > 0)
{
await db.StringSetAsync(key, SerializeGuids(ids), DefaultTtl).ConfigureAwait(false);
}
return ids;
}
/// <inheritdoc />
public async Task<IReadOnlyDictionary<string, IReadOnlyList<Guid>>> GetCanonicalIdsForPurlsBatchAsync(
IEnumerable<string> purls,
CancellationToken cancellationToken = default)
{
var purlList = purls.Where(p => !string.IsNullOrWhiteSpace(p)).Distinct().ToList();
if (purlList.Count == 0)
{
return new Dictionary<string, IReadOnlyList<Guid>>();
}
var db = _redis.GetDatabase();
var results = new ConcurrentDictionary<string, IReadOnlyList<Guid>>();
var uncachedPurls = new List<string>();
// Batch lookup in cache
var keys = purlList.Select(p => (RedisKey)GetPurlKey(p)).ToArray();
var values = await db.StringGetAsync(keys).ConfigureAwait(false);
for (int i = 0; i < purlList.Count; i++)
{
if (values[i].HasValue)
{
Interlocked.Increment(ref _cacheHits);
results[purlList[i]] = DeserializeGuids(values[i]!);
}
else
{
Interlocked.Increment(ref _cacheMisses);
uncachedPurls.Add(purlList[i]);
}
}
// Fetch uncached PURLs from database in parallel
if (uncachedPurls.Count > 0)
{
var semaphore = new SemaphoreSlim(16);
var tasks = uncachedPurls.Select(async purl =>
{
await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var advisories = await _canonicalService.GetByArtifactAsync(purl, cancellationToken)
.ConfigureAwait(false);
var ids = advisories.Select(a => a.Id).ToList();
results[purl] = ids;
// Cache the result
if (ids.Count > 0)
{
await db.StringSetAsync(GetPurlKey(purl), SerializeGuids(ids), DefaultTtl)
.ConfigureAwait(false);
}
}
finally
{
semaphore.Release();
}
});
await Task.WhenAll(tasks).ConfigureAwait(false);
}
return results;
}
/// <inheritdoc />
public async Task<bool> IsAffectedAsync(
string purl,
Guid canonicalId,
CancellationToken cancellationToken = default)
{
var ids = await GetCanonicalIdsForPurlAsync(purl, cancellationToken).ConfigureAwait(false);
return ids.Contains(canonicalId);
}
/// <inheritdoc />
public async Task<PurlMatchInfo?> GetMatchInfoAsync(
string purl,
Guid canonicalId,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(purl))
{
return null;
}
var isAffected = await IsAffectedAsync(purl, canonicalId, cancellationToken).ConfigureAwait(false);
if (!isAffected)
{
return null;
}
// Determine match method based on PURL format
var method = DetermineMatchMethod(purl);
return new PurlMatchInfo
{
Purl = purl,
CanonicalId = canonicalId,
Method = method,
Confidence = 1.0
};
}
/// <inheritdoc />
public async Task IndexCanonicalAsync(
Guid canonicalId,
string affectsKey,
string? versionConstraint = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(affectsKey))
{
return;
}
var db = _redis.GetDatabase();
var purlKey = GetPurlKey(affectsKey);
// Add canonical ID to PURL's set
var existing = await db.StringGetAsync(purlKey).ConfigureAwait(false);
var ids = existing.HasValue ? DeserializeGuids(existing!).ToList() : new List<Guid>();
if (!ids.Contains(canonicalId))
{
ids.Add(canonicalId);
await db.StringSetAsync(purlKey, SerializeGuids(ids), DefaultTtl).ConfigureAwait(false);
}
// Track which PURLs are indexed for this canonical (for unindexing)
var canonicalKey = GetCanonicalKey(canonicalId);
await db.SetAddAsync(canonicalKey, affectsKey).ConfigureAwait(false);
await db.KeyExpireAsync(canonicalKey, DefaultTtl).ConfigureAwait(false);
_logger.LogDebug("Indexed PURL {Purl} for canonical {CanonicalId}", affectsKey, canonicalId);
}
/// <inheritdoc />
public async Task IndexCanonicalBatchAsync(
IEnumerable<PurlIndexEntry> entries,
CancellationToken cancellationToken = default)
{
var entryList = entries.ToList();
if (entryList.Count == 0)
{
return;
}
var db = _redis.GetDatabase();
var batch = db.CreateBatch();
var tasks = new List<Task>();
// Group by PURL
var byPurl = entryList.GroupBy(e => e.AffectsKey);
foreach (var group in byPurl)
{
var purlKey = GetPurlKey(group.Key);
var canonicalIds = group.Select(e => e.CanonicalId).Distinct().ToList();
// Get existing and merge
var existingTask = db.StringGetAsync(purlKey);
tasks.Add(existingTask.ContinueWith(async t =>
{
var existing = t.Result;
var ids = existing.HasValue ? DeserializeGuids(existing!).ToList() : new List<Guid>();
ids.AddRange(canonicalIds.Where(id => !ids.Contains(id)));
await db.StringSetAsync(purlKey, SerializeGuids(ids), DefaultTtl).ConfigureAwait(false);
}, cancellationToken).Unwrap());
}
// Track canonical → PURLs mappings
var byCanonical = entryList.GroupBy(e => e.CanonicalId);
foreach (var group in byCanonical)
{
var canonicalKey = GetCanonicalKey(group.Key);
var purls = group.Select(e => (RedisValue)e.AffectsKey).ToArray();
tasks.Add(db.SetAddAsync(canonicalKey, purls));
tasks.Add(db.KeyExpireAsync(canonicalKey, DefaultTtl));
}
batch.Execute();
await Task.WhenAll(tasks).ConfigureAwait(false);
_logger.LogInformation("Indexed {EntryCount} PURL→canonical mappings", entryList.Count);
}
/// <inheritdoc />
public async Task UnindexCanonicalAsync(
Guid canonicalId,
CancellationToken cancellationToken = default)
{
var db = _redis.GetDatabase();
var canonicalKey = GetCanonicalKey(canonicalId);
// Get all PURLs indexed for this canonical
var purls = await db.SetMembersAsync(canonicalKey).ConfigureAwait(false);
foreach (var purl in purls)
{
var purlKey = GetPurlKey(purl!);
var existing = await db.StringGetAsync(purlKey).ConfigureAwait(false);
if (existing.HasValue)
{
var ids = DeserializeGuids(existing!).Where(id => id != canonicalId).ToList();
if (ids.Count > 0)
{
await db.StringSetAsync(purlKey, SerializeGuids(ids), DefaultTtl).ConfigureAwait(false);
}
else
{
await db.KeyDeleteAsync(purlKey).ConfigureAwait(false);
}
}
}
// Remove the canonical's PURL set
await db.KeyDeleteAsync(canonicalKey).ConfigureAwait(false);
_logger.LogDebug("Unindexed canonical {CanonicalId} from {PurlCount} PURLs", canonicalId, purls.Length);
}
/// <inheritdoc />
public async Task<PurlIndexStats> GetStatsAsync(CancellationToken cancellationToken = default)
{
var db = _redis.GetDatabase();
var server = _redis.GetServers().FirstOrDefault();
if (server is null)
{
return new PurlIndexStats();
}
// Count keys by pattern
var purlCount = 0L;
var canonicalCount = 0L;
await foreach (var key in server.KeysAsync(pattern: $"{PurlKeyPrefix}*"))
{
purlCount++;
}
await foreach (var key in server.KeysAsync(pattern: $"{CanonicalKeyPrefix}*"))
{
canonicalCount++;
}
var totalRequests = _cacheHits + _cacheMisses;
var hitRate = totalRequests > 0 ? (double)_cacheHits / totalRequests : 0;
return new PurlIndexStats
{
TotalPurls = purlCount,
TotalCanonicals = canonicalCount,
TotalMappings = purlCount, // Approximation
CacheHitRate = hitRate,
LastUpdatedAt = DateTimeOffset.UtcNow
};
}
private static string GetPurlKey(string purl) => $"{PurlKeyPrefix}{NormalizePurl(purl)}";
private static string GetCanonicalKey(Guid canonicalId) => $"{CanonicalKeyPrefix}{canonicalId}";
private static string NormalizePurl(string purl)
{
// Normalize PURL for consistent caching
var normalized = purl.Trim().ToLowerInvariant();
// Remove qualifiers for base key
var qualifierIndex = normalized.IndexOf('?');
if (qualifierIndex > 0)
{
normalized = normalized[..qualifierIndex];
}
return normalized;
}
private static MatchMethod DetermineMatchMethod(string purl)
{
if (purl.StartsWith("cpe:", StringComparison.OrdinalIgnoreCase))
{
return MatchMethod.Cpe;
}
if (purl.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
{
return purl.Contains('@') ? MatchMethod.ExactPurl : MatchMethod.NameVersion;
}
return MatchMethod.NameVersion;
}
private static string SerializeGuids(IEnumerable<Guid> guids)
{
return JsonSerializer.Serialize(guids.Select(g => g.ToString()));
}
private static IReadOnlyList<Guid> DeserializeGuids(string json)
{
try
{
var strings = JsonSerializer.Deserialize<List<string>>(json);
return strings?.Select(Guid.Parse).ToList() ?? [];
}
catch
{
return [];
}
}
}

View File

@@ -0,0 +1,270 @@
// -----------------------------------------------------------------------------
// SbomAdvisoryMatcher.cs
// Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring
// Tasks: SBOM-8200-008, SBOM-8200-009
// Description: Implementation for matching SBOM components against advisories
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using StellaOps.Concelier.Core.Canonical;
using StellaOps.Concelier.SbomIntegration.Models;
namespace StellaOps.Concelier.SbomIntegration.Matching;
/// <summary>
/// Service for matching SBOM components against canonical advisories.
/// </summary>
public sealed class SbomAdvisoryMatcher : ISbomAdvisoryMatcher
{
private readonly ICanonicalAdvisoryService _canonicalService;
private readonly ILogger<SbomAdvisoryMatcher> _logger;
public SbomAdvisoryMatcher(
ICanonicalAdvisoryService canonicalService,
ILogger<SbomAdvisoryMatcher> logger)
{
_canonicalService = canonicalService ?? throw new ArgumentNullException(nameof(canonicalService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<IReadOnlyList<SbomAdvisoryMatch>> MatchAsync(
Guid sbomId,
string sbomDigest,
IEnumerable<string> purls,
IReadOnlyDictionary<string, bool>? reachabilityMap = null,
IReadOnlyDictionary<string, bool>? deploymentMap = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(purls);
var purlList = purls.ToList();
if (purlList.Count == 0)
{
return [];
}
_logger.LogDebug("Matching {PurlCount} PURLs against canonical advisories", purlList.Count);
var matches = new ConcurrentBag<SbomAdvisoryMatch>();
var matchedCount = 0;
// Process PURLs in parallel with bounded concurrency
var semaphore = new SemaphoreSlim(16); // Max 16 concurrent lookups
var tasks = purlList.Select(async purl =>
{
await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var purlMatches = await MatchPurlAsync(
sbomId,
sbomDigest,
purl,
reachabilityMap,
deploymentMap,
cancellationToken).ConfigureAwait(false);
foreach (var match in purlMatches)
{
matches.Add(match);
Interlocked.Increment(ref matchedCount);
}
}
finally
{
semaphore.Release();
}
});
await Task.WhenAll(tasks).ConfigureAwait(false);
_logger.LogInformation(
"Found {MatchCount} advisory matches for SBOM {SbomDigest} across {PurlCount} PURLs",
matches.Count, sbomDigest, purlList.Count);
return matches.ToList();
}
/// <inheritdoc />
public async Task<IReadOnlyList<Guid>> FindAffectingCanonicalIdsAsync(
string purl,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(purl))
{
return [];
}
var advisories = await _canonicalService.GetByArtifactAsync(purl, cancellationToken)
.ConfigureAwait(false);
return advisories.Select(a => a.Id).ToList();
}
/// <inheritdoc />
public async Task<SbomAdvisoryMatch?> CheckMatchAsync(
string purl,
Guid canonicalId,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(purl))
{
return null;
}
var advisory = await _canonicalService.GetByIdAsync(canonicalId, cancellationToken)
.ConfigureAwait(false);
if (advisory is null)
{
return null;
}
// Check if this advisory affects the given PURL
var affectsThisPurl = IsArtifactMatch(purl, advisory.AffectsKey);
if (!affectsThisPurl)
{
return null;
}
return new SbomAdvisoryMatch
{
Id = Guid.NewGuid(),
SbomId = Guid.Empty, // Not applicable for single check
SbomDigest = string.Empty,
CanonicalId = canonicalId,
Purl = purl,
Method = DetermineMatchMethod(purl),
IsReachable = false,
IsDeployed = false,
MatchedAt = DateTimeOffset.UtcNow
};
}
private async Task<IReadOnlyList<SbomAdvisoryMatch>> MatchPurlAsync(
Guid sbomId,
string sbomDigest,
string purl,
IReadOnlyDictionary<string, bool>? reachabilityMap,
IReadOnlyDictionary<string, bool>? deploymentMap,
CancellationToken cancellationToken)
{
try
{
var advisories = await _canonicalService.GetByArtifactAsync(purl, cancellationToken)
.ConfigureAwait(false);
if (advisories.Count == 0)
{
return [];
}
var isReachable = reachabilityMap?.TryGetValue(purl, out var reachable) == true && reachable;
var isDeployed = deploymentMap?.TryGetValue(purl, out var deployed) == true && deployed;
var matchMethod = DetermineMatchMethod(purl);
return advisories.Select(advisory => new SbomAdvisoryMatch
{
Id = Guid.NewGuid(),
SbomId = sbomId,
SbomDigest = sbomDigest,
CanonicalId = advisory.Id,
Purl = purl,
Method = matchMethod,
IsReachable = isReachable,
IsDeployed = isDeployed,
MatchedAt = DateTimeOffset.UtcNow
}).ToList();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to match PURL {Purl} against advisories", purl);
return [];
}
}
private static MatchMethod DetermineMatchMethod(string purl)
{
// CPE-based matching for OS packages
if (purl.StartsWith("cpe:", StringComparison.OrdinalIgnoreCase))
{
return MatchMethod.Cpe;
}
// PURL-based matching
if (purl.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
{
// If contains version, it's an exact match
if (purl.Contains('@'))
{
return MatchMethod.ExactPurl;
}
return MatchMethod.NameVersion;
}
return MatchMethod.NameVersion;
}
private static bool IsArtifactMatch(string purl, string affectedArtifact)
{
// Exact match
if (string.Equals(purl, affectedArtifact, StringComparison.OrdinalIgnoreCase))
{
return true;
}
// Normalize and compare
var normalizedPurl = NormalizePurl(purl);
var normalizedAffected = NormalizePurl(affectedArtifact);
if (string.Equals(normalizedPurl, normalizedAffected, StringComparison.OrdinalIgnoreCase))
{
return true;
}
// Check if affected artifact is a prefix match (package without version)
if (normalizedPurl.StartsWith(normalizedAffected, StringComparison.OrdinalIgnoreCase))
{
// Ensure it's actually a version boundary (@ or end of string)
var remaining = normalizedPurl[normalizedAffected.Length..];
if (remaining.Length == 0 || remaining[0] == '@')
{
return true;
}
}
return false;
}
private static string NormalizePurl(string purl)
{
// Remove trailing slashes, normalize case for scheme
var normalized = purl.Trim();
// Normalize pkg: prefix
if (normalized.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
{
normalized = "pkg:" + normalized[4..];
}
// Remove qualifiers for comparison (everything after ?)
var qualifierIndex = normalized.IndexOf('?');
if (qualifierIndex > 0)
{
normalized = normalized[..qualifierIndex];
}
// Remove subpath for comparison (everything after #)
var subpathIndex = normalized.IndexOf('#');
if (subpathIndex > 0)
{
normalized = normalized[..subpathIndex];
}
return normalized;
}
}

View File

@@ -0,0 +1,195 @@
// -----------------------------------------------------------------------------
// SbomRegistration.cs
// Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring
// Task: SBOM-8200-000
// Description: Domain model for SBOM registration
// -----------------------------------------------------------------------------
namespace StellaOps.Concelier.SbomIntegration.Models;
/// <summary>
/// Registered SBOM for advisory matching.
/// </summary>
public sealed record SbomRegistration
{
/// <summary>Registration identifier.</summary>
public Guid Id { get; init; }
/// <summary>SBOM content digest (SHA-256).</summary>
public required string Digest { get; init; }
/// <summary>SBOM format: CycloneDX or SPDX.</summary>
public required SbomFormat Format { get; init; }
/// <summary>SBOM specification version.</summary>
public required string SpecVersion { get; init; }
/// <summary>Primary component name (e.g., container image name).</summary>
public string? PrimaryName { get; init; }
/// <summary>Primary component version.</summary>
public string? PrimaryVersion { get; init; }
/// <summary>Total component count in SBOM.</summary>
public int ComponentCount { get; init; }
/// <summary>Extracted PURLs from SBOM components.</summary>
public IReadOnlyList<string> Purls { get; init; } = [];
/// <summary>When the SBOM was registered.</summary>
public DateTimeOffset RegisteredAt { get; init; }
/// <summary>When the SBOM was last matched against advisories.</summary>
public DateTimeOffset? LastMatchedAt { get; init; }
/// <summary>Number of advisories affecting this SBOM.</summary>
public int AffectedCount { get; init; }
/// <summary>Source of the SBOM (scanner, upload, etc.).</summary>
public required string Source { get; init; }
/// <summary>Optional tenant ID for multi-tenant deployments.</summary>
public string? TenantId { get; init; }
}
/// <summary>
/// SBOM format type.
/// </summary>
public enum SbomFormat
{
/// <summary>CycloneDX format.</summary>
CycloneDX,
/// <summary>SPDX format.</summary>
SPDX
}
/// <summary>
/// Result of matching an SBOM against advisories.
/// Distinct from <see cref="StellaOps.Concelier.Interest.Models.SbomMatch"/> which is simpler for score calculation.
/// </summary>
public sealed record SbomAdvisoryMatch
{
/// <summary>Match identifier.</summary>
public Guid Id { get; init; }
/// <summary>SBOM registration ID.</summary>
public required Guid SbomId { get; init; }
/// <summary>SBOM digest.</summary>
public required string SbomDigest { get; init; }
/// <summary>Canonical advisory ID.</summary>
public required Guid CanonicalId { get; init; }
/// <summary>Matched PURL from SBOM.</summary>
public required string Purl { get; init; }
/// <summary>Whether the code path is reachable.</summary>
public bool IsReachable { get; init; }
/// <summary>Whether deployed in production environment.</summary>
public bool IsDeployed { get; init; }
/// <summary>Match confidence (0-1).</summary>
public double Confidence { get; init; } = 1.0;
/// <summary>How the match was determined.</summary>
public required MatchMethod Method { get; init; }
/// <summary>When the match was recorded.</summary>
public DateTimeOffset MatchedAt { get; init; }
}
/// <summary>
/// Method used to match SBOM component to advisory.
/// </summary>
public enum MatchMethod
{
/// <summary>Exact PURL match.</summary>
ExactPurl,
/// <summary>PURL with version range match.</summary>
PurlVersionRange,
/// <summary>CPE-based match for OS packages.</summary>
Cpe,
/// <summary>Component name and version heuristic.</summary>
NameVersion
}
/// <summary>
/// Input for registering an SBOM.
/// </summary>
public sealed record SbomRegistrationInput
{
/// <summary>SBOM content digest.</summary>
public required string Digest { get; init; }
/// <summary>SBOM format.</summary>
public required SbomFormat Format { get; init; }
/// <summary>SBOM specification version.</summary>
public required string SpecVersion { get; init; }
/// <summary>Primary component name.</summary>
public string? PrimaryName { get; init; }
/// <summary>Primary component version.</summary>
public string? PrimaryVersion { get; init; }
/// <summary>List of PURLs extracted from SBOM.</summary>
public required IReadOnlyList<string> Purls { get; init; }
/// <summary>Source of registration.</summary>
public required string Source { get; init; }
/// <summary>Optional tenant ID.</summary>
public string? TenantId { get; init; }
/// <summary>Optional reachability data per PURL.</summary>
public IReadOnlyDictionary<string, bool>? ReachabilityMap { get; init; }
/// <summary>Optional deployment status per PURL.</summary>
public IReadOnlyDictionary<string, bool>? DeploymentMap { get; init; }
}
/// <summary>
/// Result of SBOM learning (registration + matching + scoring).
/// </summary>
public sealed record SbomLearnResult
{
/// <summary>SBOM registration.</summary>
public required SbomRegistration Registration { get; init; }
/// <summary>Matches found against advisories.</summary>
public required IReadOnlyList<SbomAdvisoryMatch> Matches { get; init; }
/// <summary>Number of interest scores updated.</summary>
public int ScoresUpdated { get; init; }
/// <summary>Processing time in milliseconds.</summary>
public double ProcessingTimeMs { get; init; }
}
/// <summary>
/// Delta changes to an existing SBOM.
/// </summary>
public sealed record SbomDeltaInput
{
/// <summary>PURLs to add to the SBOM.</summary>
public IReadOnlyList<string> AddedPurls { get; init; } = [];
/// <summary>PURLs to remove from the SBOM.</summary>
public IReadOnlyList<string> RemovedPurls { get; init; } = [];
/// <summary>Optional updated reachability data.</summary>
public IReadOnlyDictionary<string, bool>? ReachabilityMap { get; init; }
/// <summary>Optional updated deployment status.</summary>
public IReadOnlyDictionary<string, bool>? DeploymentMap { get; init; }
/// <summary>Whether this delta represents a complete replacement.</summary>
public bool IsFullReplacement { get; init; }
}

View File

@@ -0,0 +1,90 @@
// -----------------------------------------------------------------------------
// ISbomParser.cs
// Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring
// Task: SBOM-8200-005
// Description: Interface for SBOM parsing and PURL extraction
// -----------------------------------------------------------------------------
using StellaOps.Concelier.SbomIntegration.Models;
namespace StellaOps.Concelier.SbomIntegration.Parsing;
/// <summary>
/// Service for parsing SBOM content and extracting package identifiers.
/// </summary>
public interface ISbomParser
{
/// <summary>
/// Extracts PURLs from SBOM content.
/// </summary>
/// <param name="content">SBOM content stream.</param>
/// <param name="format">SBOM format (CycloneDX or SPDX).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Parsing result with extracted PURLs.</returns>
Task<SbomParseResult> ParseAsync(
Stream content,
SbomFormat format,
CancellationToken cancellationToken = default);
/// <summary>
/// Detects the SBOM format from content.
/// </summary>
/// <param name="content">SBOM content stream.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Detected format and spec version.</returns>
Task<SbomFormatInfo> DetectFormatAsync(
Stream content,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of SBOM parsing.
/// </summary>
public sealed record SbomParseResult
{
/// <summary>List of extracted PURLs.</summary>
public required IReadOnlyList<string> Purls { get; init; }
/// <summary>List of extracted CPEs (for OS packages).</summary>
public IReadOnlyList<string> Cpes { get; init; } = [];
/// <summary>Primary component name (e.g., image name).</summary>
public string? PrimaryName { get; init; }
/// <summary>Primary component version.</summary>
public string? PrimaryVersion { get; init; }
/// <summary>Total component count in SBOM.</summary>
public int TotalComponents { get; init; }
/// <summary>Components without PURL (name/version only).</summary>
public IReadOnlyList<ComponentInfo> UnresolvedComponents { get; init; } = [];
/// <summary>Parsing warnings (non-fatal issues).</summary>
public IReadOnlyList<string> Warnings { get; init; } = [];
}
/// <summary>
/// Information about a component without PURL.
/// </summary>
public sealed record ComponentInfo
{
public required string Name { get; init; }
public string? Version { get; init; }
public string? Type { get; init; }
}
/// <summary>
/// Detected SBOM format information.
/// </summary>
public sealed record SbomFormatInfo
{
/// <summary>SBOM format.</summary>
public SbomFormat Format { get; init; }
/// <summary>Specification version (e.g., "1.5" for CycloneDX).</summary>
public string? SpecVersion { get; init; }
/// <summary>Whether format was successfully detected.</summary>
public bool IsDetected { get; init; }
}

View File

@@ -0,0 +1,517 @@
// -----------------------------------------------------------------------------
// SbomParser.cs
// Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring
// Task: SBOM-8200-005
// Description: SBOM parser for CycloneDX and SPDX formats
// -----------------------------------------------------------------------------
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Concelier.SbomIntegration.Models;
namespace StellaOps.Concelier.SbomIntegration.Parsing;
/// <summary>
/// Parser for extracting PURLs and metadata from SBOM documents.
/// Supports CycloneDX (1.4-1.6) and SPDX (2.2-2.3, 3.0).
/// </summary>
public sealed class SbomParser : ISbomParser
{
private readonly ILogger<SbomParser> _logger;
public SbomParser(ILogger<SbomParser> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<SbomParseResult> ParseAsync(
Stream content,
SbomFormat format,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(content);
// Ensure stream is at beginning
if (content.CanSeek)
{
content.Position = 0;
}
return format switch
{
SbomFormat.CycloneDX => await ParseCycloneDxAsync(content, cancellationToken).ConfigureAwait(false),
SbomFormat.SPDX => await ParseSpdxAsync(content, cancellationToken).ConfigureAwait(false),
_ => throw new ArgumentException($"Unsupported SBOM format: {format}", nameof(format))
};
}
/// <inheritdoc />
public async Task<SbomFormatInfo> DetectFormatAsync(
Stream content,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(content);
if (content.CanSeek)
{
content.Position = 0;
}
try
{
using var doc = await JsonDocument.ParseAsync(content, cancellationToken: cancellationToken)
.ConfigureAwait(false);
var root = doc.RootElement;
// Check for CycloneDX
if (root.TryGetProperty("bomFormat", out var bomFormat) &&
bomFormat.GetString()?.Equals("CycloneDX", StringComparison.OrdinalIgnoreCase) == true)
{
var specVersion = root.TryGetProperty("specVersion", out var sv) ? sv.GetString() : null;
return new SbomFormatInfo
{
Format = SbomFormat.CycloneDX,
SpecVersion = specVersion,
IsDetected = true
};
}
// Check for SPDX 2.x
if (root.TryGetProperty("spdxVersion", out var spdxVersion))
{
return new SbomFormatInfo
{
Format = SbomFormat.SPDX,
SpecVersion = spdxVersion.GetString(),
IsDetected = true
};
}
// Check for SPDX 3.0 (@context indicates JSON-LD)
if (root.TryGetProperty("@context", out var context))
{
var contextStr = context.ValueKind == JsonValueKind.String
? context.GetString()
: context.ToString();
if (contextStr?.Contains("spdx", StringComparison.OrdinalIgnoreCase) == true)
{
return new SbomFormatInfo
{
Format = SbomFormat.SPDX,
SpecVersion = "3.0",
IsDetected = true
};
}
}
return new SbomFormatInfo { IsDetected = false };
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to parse SBOM content as JSON");
return new SbomFormatInfo { IsDetected = false };
}
}
private async Task<SbomParseResult> ParseCycloneDxAsync(
Stream content,
CancellationToken cancellationToken)
{
using var doc = await JsonDocument.ParseAsync(content, cancellationToken: cancellationToken)
.ConfigureAwait(false);
var root = doc.RootElement;
var purls = new List<string>();
var cpes = new List<string>();
var unresolvedComponents = new List<ComponentInfo>();
var warnings = new List<string>();
string? primaryName = null;
string? primaryVersion = null;
int totalComponents = 0;
// Get primary component from metadata
if (root.TryGetProperty("metadata", out var metadata) &&
metadata.TryGetProperty("component", out var primaryComponent))
{
primaryName = primaryComponent.TryGetProperty("name", out var name) ? name.GetString() : null;
primaryVersion = primaryComponent.TryGetProperty("version", out var version) ? version.GetString() : null;
// Primary component may also have a PURL
if (primaryComponent.TryGetProperty("purl", out var primaryPurl))
{
var purlStr = primaryPurl.GetString();
if (!string.IsNullOrWhiteSpace(purlStr))
{
purls.Add(purlStr);
}
}
}
// Parse components array
if (root.TryGetProperty("components", out var components))
{
foreach (var component in components.EnumerateArray())
{
totalComponents++;
ParseCycloneDxComponent(component, purls, cpes, unresolvedComponents, warnings);
}
}
// Parse nested components (CycloneDX supports component hierarchy)
ParseNestedComponents(root, purls, cpes, unresolvedComponents, warnings, ref totalComponents);
_logger.LogDebug(
"Parsed CycloneDX SBOM: {PurlCount} PURLs, {CpeCount} CPEs, {UnresolvedCount} unresolved from {TotalCount} components",
purls.Count, cpes.Count, unresolvedComponents.Count, totalComponents);
return new SbomParseResult
{
Purls = purls.Distinct().ToList(),
Cpes = cpes.Distinct().ToList(),
PrimaryName = primaryName,
PrimaryVersion = primaryVersion,
TotalComponents = totalComponents,
UnresolvedComponents = unresolvedComponents,
Warnings = warnings
};
}
private void ParseCycloneDxComponent(
JsonElement component,
List<string> purls,
List<string> cpes,
List<ComponentInfo> unresolved,
List<string> warnings)
{
var hasPurl = false;
// Extract PURL
if (component.TryGetProperty("purl", out var purl))
{
var purlStr = purl.GetString();
if (!string.IsNullOrWhiteSpace(purlStr))
{
purls.Add(purlStr);
hasPurl = true;
}
}
// Extract CPE (from cpe property or externalReferences)
if (component.TryGetProperty("cpe", out var cpe))
{
var cpeStr = cpe.GetString();
if (!string.IsNullOrWhiteSpace(cpeStr))
{
cpes.Add(cpeStr);
}
}
// Check externalReferences for additional CPEs
if (component.TryGetProperty("externalReferences", out var extRefs))
{
foreach (var extRef in extRefs.EnumerateArray())
{
if (extRef.TryGetProperty("type", out var type) &&
type.GetString()?.Equals("cpe", StringComparison.OrdinalIgnoreCase) == true &&
extRef.TryGetProperty("url", out var url))
{
var cpeStr = url.GetString();
if (!string.IsNullOrWhiteSpace(cpeStr))
{
cpes.Add(cpeStr);
}
}
}
}
// Track unresolved components (no PURL)
if (!hasPurl)
{
var name = component.TryGetProperty("name", out var n) ? n.GetString() : null;
var version = component.TryGetProperty("version", out var v) ? v.GetString() : null;
var componentType = component.TryGetProperty("type", out var t) ? t.GetString() : null;
if (!string.IsNullOrWhiteSpace(name))
{
unresolved.Add(new ComponentInfo
{
Name = name,
Version = version,
Type = componentType
});
}
}
// Recursively parse nested components
if (component.TryGetProperty("components", out var nestedComponents))
{
foreach (var nested in nestedComponents.EnumerateArray())
{
ParseCycloneDxComponent(nested, purls, cpes, unresolved, warnings);
}
}
}
private void ParseNestedComponents(
JsonElement root,
List<string> purls,
List<string> cpes,
List<ComponentInfo> unresolved,
List<string> warnings,
ref int totalComponents)
{
// CycloneDX 1.5+ supports dependencies with nested refs
if (root.TryGetProperty("dependencies", out var dependencies))
{
// Dependencies section doesn't contain component data, just refs
// Already handled through components traversal
}
// Check for compositions (CycloneDX 1.4+)
if (root.TryGetProperty("compositions", out var compositions))
{
// Compositions define relationships but don't add new components
}
}
private async Task<SbomParseResult> ParseSpdxAsync(
Stream content,
CancellationToken cancellationToken)
{
using var doc = await JsonDocument.ParseAsync(content, cancellationToken: cancellationToken)
.ConfigureAwait(false);
var root = doc.RootElement;
var purls = new List<string>();
var cpes = new List<string>();
var unresolvedComponents = new List<ComponentInfo>();
var warnings = new List<string>();
string? primaryName = null;
string? primaryVersion = null;
int totalComponents = 0;
// Detect SPDX version
var isSpdx3 = root.TryGetProperty("@context", out _);
if (isSpdx3)
{
return await ParseSpdx3Async(root, cancellationToken).ConfigureAwait(false);
}
// SPDX 2.x parsing
// Get document name as primary
if (root.TryGetProperty("name", out var docName))
{
primaryName = docName.GetString();
}
// Parse packages
if (root.TryGetProperty("packages", out var packages))
{
foreach (var package in packages.EnumerateArray())
{
totalComponents++;
ParseSpdxPackage(package, purls, cpes, unresolvedComponents, warnings);
// First package is often the primary
if (primaryName is null && package.TryGetProperty("name", out var pkgName))
{
primaryName = pkgName.GetString();
primaryVersion = package.TryGetProperty("versionInfo", out var v) ? v.GetString() : null;
}
}
}
_logger.LogDebug(
"Parsed SPDX SBOM: {PurlCount} PURLs, {CpeCount} CPEs, {UnresolvedCount} unresolved from {TotalCount} packages",
purls.Count, cpes.Count, unresolvedComponents.Count, totalComponents);
return new SbomParseResult
{
Purls = purls.Distinct().ToList(),
Cpes = cpes.Distinct().ToList(),
PrimaryName = primaryName,
PrimaryVersion = primaryVersion,
TotalComponents = totalComponents,
UnresolvedComponents = unresolvedComponents,
Warnings = warnings
};
}
private void ParseSpdxPackage(
JsonElement package,
List<string> purls,
List<string> cpes,
List<ComponentInfo> unresolved,
List<string> warnings)
{
var hasPurl = false;
// Extract from externalRefs
if (package.TryGetProperty("externalRefs", out var extRefs))
{
foreach (var extRef in extRefs.EnumerateArray())
{
var refType = extRef.TryGetProperty("referenceType", out var rt) ? rt.GetString() : null;
var refCategory = extRef.TryGetProperty("referenceCategory", out var rc) ? rc.GetString() : null;
var locator = extRef.TryGetProperty("referenceLocator", out var loc) ? loc.GetString() : null;
if (string.IsNullOrWhiteSpace(locator))
continue;
// PURL reference
if (refType?.Equals("purl", StringComparison.OrdinalIgnoreCase) == true ||
refCategory?.Equals("PACKAGE-MANAGER", StringComparison.OrdinalIgnoreCase) == true)
{
if (locator.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
{
purls.Add(locator);
hasPurl = true;
}
}
// CPE reference
if (refType?.StartsWith("cpe", StringComparison.OrdinalIgnoreCase) == true ||
refCategory?.Equals("SECURITY", StringComparison.OrdinalIgnoreCase) == true)
{
if (locator.StartsWith("cpe:", StringComparison.OrdinalIgnoreCase))
{
cpes.Add(locator);
}
}
}
}
// Track unresolved packages (no PURL)
if (!hasPurl)
{
var name = package.TryGetProperty("name", out var n) ? n.GetString() : null;
var version = package.TryGetProperty("versionInfo", out var v) ? v.GetString() : null;
if (!string.IsNullOrWhiteSpace(name))
{
unresolved.Add(new ComponentInfo
{
Name = name,
Version = version,
Type = "package"
});
}
}
}
private Task<SbomParseResult> ParseSpdx3Async(
JsonElement root,
CancellationToken cancellationToken)
{
var purls = new List<string>();
var cpes = new List<string>();
var unresolvedComponents = new List<ComponentInfo>();
var warnings = new List<string>();
string? primaryName = null;
string? primaryVersion = null;
int totalComponents = 0;
// SPDX 3.0 uses "@graph" for elements
if (root.TryGetProperty("@graph", out var graph))
{
foreach (var element in graph.EnumerateArray())
{
var elementType = element.TryGetProperty("@type", out var t) ? t.GetString() : null;
// Skip non-package elements
if (elementType is null ||
(!elementType.Contains("Package", StringComparison.OrdinalIgnoreCase) &&
!elementType.Contains("Software", StringComparison.OrdinalIgnoreCase)))
{
continue;
}
totalComponents++;
var hasPurl = false;
// SPDX 3.0 uses packageUrl property directly
if (element.TryGetProperty("packageUrl", out var purl))
{
var purlStr = purl.GetString();
if (!string.IsNullOrWhiteSpace(purlStr))
{
purls.Add(purlStr);
hasPurl = true;
}
}
// Check externalIdentifier array
if (element.TryGetProperty("externalIdentifier", out var extIds))
{
foreach (var extId in extIds.EnumerateArray())
{
var idType = extId.TryGetProperty("externalIdentifierType", out var eit)
? eit.GetString()
: null;
var idValue = extId.TryGetProperty("identifier", out var id) ? id.GetString() : null;
if (string.IsNullOrWhiteSpace(idValue))
continue;
if (idType?.Equals("purl", StringComparison.OrdinalIgnoreCase) == true)
{
purls.Add(idValue);
hasPurl = true;
}
else if (idType?.StartsWith("cpe", StringComparison.OrdinalIgnoreCase) == true)
{
cpes.Add(idValue);
}
}
}
// Track unresolved
if (!hasPurl)
{
var name = element.TryGetProperty("name", out var n) ? n.GetString() : null;
var version = element.TryGetProperty("packageVersion", out var v)
? v.GetString()
: element.TryGetProperty("softwareVersion", out var sv)
? sv.GetString()
: null;
if (!string.IsNullOrWhiteSpace(name))
{
unresolvedComponents.Add(new ComponentInfo
{
Name = name,
Version = version,
Type = elementType
});
}
}
// Get primary from first package
if (primaryName is null)
{
primaryName = element.TryGetProperty("name", out var n) ? n.GetString() : null;
primaryVersion = element.TryGetProperty("packageVersion", out var v) ? v.GetString() : null;
}
}
}
_logger.LogDebug(
"Parsed SPDX 3.0 SBOM: {PurlCount} PURLs, {CpeCount} CPEs, {UnresolvedCount} unresolved from {TotalCount} elements",
purls.Count, cpes.Count, unresolvedComponents.Count, totalComponents);
return Task.FromResult(new SbomParseResult
{
Purls = purls.Distinct().ToList(),
Cpes = cpes.Distinct().ToList(),
PrimaryName = primaryName,
PrimaryVersion = primaryVersion,
TotalComponents = totalComponents,
UnresolvedComponents = unresolvedComponents,
Warnings = warnings
});
}
}

View File

@@ -0,0 +1,270 @@
// -----------------------------------------------------------------------------
// SbomAdvisoryMatcher.cs
// Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring
// Tasks: SBOM-8200-008, SBOM-8200-009
// Description: Implementation for matching SBOM components against advisories
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using StellaOps.Concelier.Core.Canonical;
using StellaOps.Concelier.SbomIntegration.Models;
namespace StellaOps.Concelier.SbomIntegration;
/// <summary>
/// Service for matching SBOM components against canonical advisories.
/// </summary>
public sealed class SbomAdvisoryMatcher : ISbomAdvisoryMatcher
{
private readonly ICanonicalAdvisoryService _canonicalService;
private readonly ILogger<SbomAdvisoryMatcher> _logger;
public SbomAdvisoryMatcher(
ICanonicalAdvisoryService canonicalService,
ILogger<SbomAdvisoryMatcher> logger)
{
_canonicalService = canonicalService ?? throw new ArgumentNullException(nameof(canonicalService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<IReadOnlyList<SbomAdvisoryMatch>> MatchAsync(
Guid sbomId,
string sbomDigest,
IEnumerable<string> purls,
IReadOnlyDictionary<string, bool>? reachabilityMap = null,
IReadOnlyDictionary<string, bool>? deploymentMap = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(purls);
var purlList = purls.ToList();
if (purlList.Count == 0)
{
return [];
}
_logger.LogDebug("Matching {PurlCount} PURLs against canonical advisories", purlList.Count);
var matches = new ConcurrentBag<SbomAdvisoryMatch>();
var matchedCount = 0;
// Process PURLs in parallel with bounded concurrency
var semaphore = new SemaphoreSlim(16); // Max 16 concurrent lookups
var tasks = purlList.Select(async purl =>
{
await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var purlMatches = await MatchPurlAsync(
sbomId,
sbomDigest,
purl,
reachabilityMap,
deploymentMap,
cancellationToken).ConfigureAwait(false);
foreach (var match in purlMatches)
{
matches.Add(match);
Interlocked.Increment(ref matchedCount);
}
}
finally
{
semaphore.Release();
}
});
await Task.WhenAll(tasks).ConfigureAwait(false);
_logger.LogInformation(
"Found {MatchCount} advisory matches for SBOM {SbomDigest} across {PurlCount} PURLs",
matches.Count, sbomDigest, purlList.Count);
return matches.ToList();
}
/// <inheritdoc />
public async Task<IReadOnlyList<Guid>> FindAffectingCanonicalIdsAsync(
string purl,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(purl))
{
return [];
}
var advisories = await _canonicalService.GetByArtifactAsync(purl, cancellationToken)
.ConfigureAwait(false);
return advisories.Select(a => a.Id).ToList();
}
/// <inheritdoc />
public async Task<SbomAdvisoryMatch?> CheckMatchAsync(
string purl,
Guid canonicalId,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(purl))
{
return null;
}
var advisory = await _canonicalService.GetByIdAsync(canonicalId, cancellationToken)
.ConfigureAwait(false);
if (advisory is null)
{
return null;
}
// Check if this advisory affects the given PURL
var affectsThisPurl = IsArtifactMatch(purl, advisory.AffectsKey);
if (!affectsThisPurl)
{
return null;
}
return new SbomAdvisoryMatch
{
Id = Guid.NewGuid(),
SbomId = Guid.Empty, // Not applicable for single check
SbomDigest = string.Empty,
CanonicalId = canonicalId,
Purl = purl,
Method = DetermineMatchMethod(purl),
IsReachable = false,
IsDeployed = false,
MatchedAt = DateTimeOffset.UtcNow
};
}
private async Task<IReadOnlyList<SbomAdvisoryMatch>> MatchPurlAsync(
Guid sbomId,
string sbomDigest,
string purl,
IReadOnlyDictionary<string, bool>? reachabilityMap,
IReadOnlyDictionary<string, bool>? deploymentMap,
CancellationToken cancellationToken)
{
try
{
var advisories = await _canonicalService.GetByArtifactAsync(purl, cancellationToken)
.ConfigureAwait(false);
if (advisories.Count == 0)
{
return [];
}
var isReachable = reachabilityMap?.TryGetValue(purl, out var reachable) == true && reachable;
var isDeployed = deploymentMap?.TryGetValue(purl, out var deployed) == true && deployed;
var matchMethod = DetermineMatchMethod(purl);
return advisories.Select(advisory => new SbomAdvisoryMatch
{
Id = Guid.NewGuid(),
SbomId = sbomId,
SbomDigest = sbomDigest,
CanonicalId = advisory.Id,
Purl = purl,
Method = matchMethod,
IsReachable = isReachable,
IsDeployed = isDeployed,
MatchedAt = DateTimeOffset.UtcNow
}).ToList();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to match PURL {Purl} against advisories", purl);
return [];
}
}
private static MatchMethod DetermineMatchMethod(string purl)
{
// CPE-based matching for OS packages
if (purl.StartsWith("cpe:", StringComparison.OrdinalIgnoreCase))
{
return MatchMethod.Cpe;
}
// PURL-based matching
if (purl.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
{
// If contains version range markers, it's a range match
if (purl.Contains('@'))
{
return MatchMethod.ExactPurl;
}
return MatchMethod.NameVersion;
}
return MatchMethod.NameVersion;
}
private static bool IsArtifactMatch(string purl, string affectedArtifact)
{
// Exact match
if (string.Equals(purl, affectedArtifact, StringComparison.OrdinalIgnoreCase))
{
return true;
}
// Normalize and compare
var normalizedPurl = NormalizePurl(purl);
var normalizedAffected = NormalizePurl(affectedArtifact);
if (string.Equals(normalizedPurl, normalizedAffected, StringComparison.OrdinalIgnoreCase))
{
return true;
}
// Check if affected artifact is a prefix match (package without version)
if (normalizedPurl.StartsWith(normalizedAffected, StringComparison.OrdinalIgnoreCase))
{
// Ensure it's actually a version boundary (@ or end of string)
var remaining = normalizedPurl[normalizedAffected.Length..];
if (remaining.Length == 0 || remaining[0] == '@')
{
return true;
}
}
return false;
}
private static string NormalizePurl(string purl)
{
// Remove trailing slashes, normalize case for scheme
var normalized = purl.Trim();
// Normalize pkg: prefix
if (normalized.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
{
normalized = "pkg:" + normalized[4..];
}
// Remove qualifiers for comparison (everything after ?)
var qualifierIndex = normalized.IndexOf('?');
if (qualifierIndex > 0)
{
normalized = normalized[..qualifierIndex];
}
// Remove subpath for comparison (everything after #)
var subpathIndex = normalized.IndexOf('#');
if (subpathIndex > 0)
{
normalized = normalized[..subpathIndex];
}
return normalized;
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,64 @@
// -----------------------------------------------------------------------------
// ServiceCollectionExtensions.cs
// Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring
// Task: SBOM-8200-000
// Description: DI registration for SBOM integration services
// -----------------------------------------------------------------------------
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Concelier.SbomIntegration.Index;
using StellaOps.Concelier.SbomIntegration.Matching;
using StellaOps.Concelier.SbomIntegration.Parsing;
namespace StellaOps.Concelier.SbomIntegration;
/// <summary>
/// Extension methods for registering SBOM integration services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds SBOM integration services to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddConcelierSbomIntegration(this IServiceCollection services)
{
// Register parser
services.TryAddSingleton<ISbomParser, SbomParser>();
// Register PURL index (requires Valkey connection)
services.TryAddSingleton<IPurlCanonicalIndex, ValkeyPurlCanonicalIndex>();
// Register matcher
services.TryAddScoped<ISbomAdvisoryMatcher, SbomAdvisoryMatcher>();
// Register service
services.TryAddScoped<ISbomRegistryService, SbomRegistryService>();
return services;
}
/// <summary>
/// Adds SBOM integration services with the specified matcher.
/// </summary>
/// <typeparam name="TMatcher">Matcher implementation type.</typeparam>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddConcelierSbomIntegration<TMatcher>(this IServiceCollection services)
where TMatcher : class, ISbomAdvisoryMatcher
{
// Register parser
services.TryAddSingleton<ISbomParser, SbomParser>();
// Register PURL index (requires Valkey connection)
services.TryAddSingleton<IPurlCanonicalIndex, ValkeyPurlCanonicalIndex>();
// Register matcher and service
services.TryAddScoped<ISbomAdvisoryMatcher, TMatcher>();
services.TryAddScoped<ISbomRegistryService, SbomRegistryService>();
return services;
}
}

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Concelier.SbomIntegration</RootNamespace>
<AssemblyName>StellaOps.Concelier.SbomIntegration</AssemblyName>
<Description>SBOM integration for Concelier advisory matching and interest scoring</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
<PackageReference Include="System.Diagnostics.DiagnosticSource" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
<ProjectReference Include="..\StellaOps.Concelier.Interest\StellaOps.Concelier.Interest.csproj" />
<ProjectReference Include="..\StellaOps.Concelier.Cache.Valkey\StellaOps.Concelier.Cache.Valkey.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj" />
</ItemGroup>
</Project>