save development progress
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user