save dev progress

This commit is contained in:
StellaOps Bot
2025-12-26 00:32:35 +02:00
parent aa70af062e
commit ed3079543c
142 changed files with 23771 additions and 232 deletions

View File

@@ -0,0 +1,225 @@
// -----------------------------------------------------------------------------
// ScanCompletedEventHandler.cs
// Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring
// Task: SBOM-8200-025
// Description: Hosted service that subscribes to Scanner ScanCompleted events
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Messaging;
using StellaOps.Messaging.Abstractions;
namespace StellaOps.Concelier.SbomIntegration.Events;
/// <summary>
/// Background service that subscribes to Scanner ScanCompleted events
/// and triggers automatic SBOM learning.
/// </summary>
public sealed class ScanCompletedEventHandler : BackgroundService
{
private readonly IEventStream<ScanCompletedEvent>? _eventStream;
private readonly ISbomRegistryService _sbomService;
private readonly ILogger<ScanCompletedEventHandler> _logger;
private readonly ScanCompletedHandlerOptions _options;
public ScanCompletedEventHandler(
IEventStream<ScanCompletedEvent>? eventStream,
ISbomRegistryService sbomService,
IOptions<ScanCompletedHandlerOptions> options,
ILogger<ScanCompletedEventHandler> logger)
{
_eventStream = eventStream;
_sbomService = sbomService ?? throw new ArgumentNullException(nameof(sbomService));
_options = options?.Value ?? new ScanCompletedHandlerOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (_eventStream is null)
{
_logger.LogWarning("Event stream not configured, ScanCompleted event handler disabled");
return;
}
if (!_options.Enabled)
{
_logger.LogInformation("ScanCompleted event handler disabled by configuration");
return;
}
_logger.LogInformation(
"Starting ScanCompleted event handler, subscribing to stream {StreamName}",
_eventStream.StreamName);
try
{
await foreach (var streamEvent in _eventStream.SubscribeAsync(
StreamPosition.End, // Start from latest events
stoppingToken))
{
await ProcessEventAsync(streamEvent.Event, stoppingToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("ScanCompleted event handler stopped");
}
catch (Exception ex)
{
_logger.LogError(ex, "ScanCompleted event handler failed");
throw;
}
}
private async Task ProcessEventAsync(ScanCompletedEvent @event, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(@event.SbomDigest))
{
_logger.LogDebug(
"Scan {ScanId} completed without SBOM digest, skipping SBOM learning",
@event.ScanId);
return;
}
_logger.LogInformation(
"Processing ScanCompleted event: ScanId={ScanId}, Image={ImageDigest}, SBOM={SbomDigest}",
@event.ScanId, @event.ImageDigest, @event.SbomDigest);
try
{
// Build PURL list from scan findings
var purls = @event.Purls ?? [];
if (purls.Count == 0)
{
_logger.LogDebug(
"Scan {ScanId} has no PURLs, skipping SBOM learning",
@event.ScanId);
return;
}
// Build reachability map from findings
var reachabilityMap = BuildReachabilityMap(@event);
var input = new SbomRegistrationInput
{
Digest = @event.SbomDigest,
Format = ParseSbomFormat(@event.SbomFormat),
SpecVersion = @event.SbomSpecVersion ?? "1.6",
PrimaryName = @event.ImageName,
PrimaryVersion = @event.ImageTag,
Purls = purls,
Source = "scanner",
TenantId = @event.TenantId,
ReachabilityMap = reachabilityMap
};
var result = await _sbomService.LearnSbomAsync(input, cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation(
"Auto-learned SBOM from scan {ScanId}: {MatchCount} matches, {ScoresUpdated} scores updated",
@event.ScanId, result.Matches.Count, result.ScoresUpdated);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Failed to process ScanCompleted event for scan {ScanId}",
@event.ScanId);
// Don't rethrow - continue processing other events
}
}
private static Dictionary<string, bool>? BuildReachabilityMap(ScanCompletedEvent @event)
{
if (@event.ReachabilityData is null || @event.ReachabilityData.Count == 0)
{
return null;
}
return @event.ReachabilityData.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value);
}
private static SbomFormat ParseSbomFormat(string? format)
{
return format?.ToLowerInvariant() switch
{
"cyclonedx" => SbomFormat.CycloneDX,
"spdx" => SbomFormat.SPDX,
_ => SbomFormat.CycloneDX
};
}
}
/// <summary>
/// Event published when a scan completes.
/// </summary>
public sealed record ScanCompletedEvent
{
/// <summary>Unique scan identifier.</summary>
public required string ScanId { get; init; }
/// <summary>Report identifier.</summary>
public string? ReportId { get; init; }
/// <summary>Scanned image digest.</summary>
public string? ImageDigest { get; init; }
/// <summary>Image name (repository).</summary>
public string? ImageName { get; init; }
/// <summary>Image tag.</summary>
public string? ImageTag { get; init; }
/// <summary>SBOM content digest.</summary>
public string? SbomDigest { get; init; }
/// <summary>SBOM format.</summary>
public string? SbomFormat { get; init; }
/// <summary>SBOM specification version.</summary>
public string? SbomSpecVersion { get; init; }
/// <summary>Extracted PURLs from SBOM.</summary>
public IReadOnlyList<string>? Purls { get; init; }
/// <summary>Reachability data per PURL.</summary>
public IReadOnlyDictionary<string, bool>? ReachabilityData { get; init; }
/// <summary>Deployment data per PURL.</summary>
public IReadOnlyDictionary<string, bool>? DeploymentData { get; init; }
/// <summary>Tenant identifier.</summary>
public string? TenantId { get; init; }
/// <summary>Scan verdict (pass/fail).</summary>
public string? Verdict { get; init; }
/// <summary>When the scan completed.</summary>
public DateTimeOffset CompletedAt { get; init; } = DateTimeOffset.UtcNow;
}
/// <summary>
/// Configuration options for ScanCompleted event handler.
/// </summary>
public sealed class ScanCompletedHandlerOptions
{
/// <summary>Whether the handler is enabled.</summary>
public bool Enabled { get; set; } = true;
/// <summary>Stream name to subscribe to.</summary>
public string StreamName { get; set; } = "scanner:events:scan-completed";
/// <summary>Maximum concurrent event processing.</summary>
public int MaxConcurrency { get; set; } = 4;
/// <summary>Retry count for failed processing.</summary>
public int RetryCount { get; set; } = 3;
}

View File

@@ -0,0 +1,306 @@
// -----------------------------------------------------------------------------
// ScannerEventHandler.cs
// Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring
// Task: SBOM-8200-025
// Description: Subscribes to Scanner events for auto-learning SBOMs
// -----------------------------------------------------------------------------
using System.Text.Json;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Messaging;
using StellaOps.Messaging.Abstractions;
namespace StellaOps.Concelier.SbomIntegration.Events;
/// <summary>
/// Hosted service that subscribes to Scanner SBOM events for auto-learning.
/// </summary>
public sealed class ScannerEventHandler : BackgroundService
{
/// <summary>
/// Stream name for orchestrator events.
/// </summary>
public const string OrchestratorStreamName = "orchestrator:events";
/// <summary>
/// Event kind for SBOM generated.
/// </summary>
public const string SbomGeneratedKind = "scanner.event.sbom.generated";
/// <summary>
/// Event kind for scan completed.
/// </summary>
public const string ScanCompletedKind = "scanner.event.scan.completed";
private readonly IEventStream<OrchestratorEventEnvelope>? _eventStream;
private readonly ISbomRegistryService _registryService;
private readonly IScannerSbomFetcher? _sbomFetcher;
private readonly ILogger<ScannerEventHandler> _logger;
private long _eventsProcessed;
private long _sbomsLearned;
private long _errors;
public ScannerEventHandler(
ISbomRegistryService registryService,
ILogger<ScannerEventHandler> logger,
IEventStream<OrchestratorEventEnvelope>? eventStream = null,
IScannerSbomFetcher? sbomFetcher = null)
{
_registryService = registryService ?? throw new ArgumentNullException(nameof(registryService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_eventStream = eventStream;
_sbomFetcher = sbomFetcher;
}
/// <summary>
/// Gets the number of events processed.
/// </summary>
public long EventsProcessed => Interlocked.Read(ref _eventsProcessed);
/// <summary>
/// Gets the number of SBOMs learned.
/// </summary>
public long SbomsLearned => Interlocked.Read(ref _sbomsLearned);
/// <summary>
/// Gets the number of errors.
/// </summary>
public long Errors => Interlocked.Read(ref _errors);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (_eventStream is null)
{
_logger.LogWarning(
"ScannerEventHandler disabled: no IEventStream<OrchestratorEventEnvelope> configured");
return;
}
_logger.LogInformation(
"ScannerEventHandler started, subscribing to {StreamName}",
_eventStream.StreamName);
try
{
await foreach (var streamEvent in _eventStream.SubscribeAsync(StreamPosition.End, stoppingToken))
{
try
{
await HandleEventAsync(streamEvent.Event, stoppingToken).ConfigureAwait(false);
Interlocked.Increment(ref _eventsProcessed);
}
catch (Exception ex)
{
Interlocked.Increment(ref _errors);
_logger.LogError(ex,
"Error processing orchestrator event {EventId} kind {Kind}",
streamEvent.Event.EventId,
streamEvent.Event.Kind);
}
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Normal shutdown
}
catch (Exception ex)
{
_logger.LogError(ex, "Fatal error in ScannerEventHandler event processing loop");
throw;
}
}
private async Task HandleEventAsync(OrchestratorEventEnvelope envelope, CancellationToken cancellationToken)
{
switch (envelope.Kind)
{
case SbomGeneratedKind:
await HandleSbomGeneratedAsync(envelope, cancellationToken).ConfigureAwait(false);
break;
case ScanCompletedKind:
// ScanCompleted events contain findings but not the full SBOM
// We could use this to enrich reachability data
_logger.LogDebug(
"Received ScanCompleted event {EventId} for digest {Digest}",
envelope.EventId,
envelope.Scope?.Digest);
break;
default:
// Ignore other event types
break;
}
}
private async Task HandleSbomGeneratedAsync(
OrchestratorEventEnvelope envelope,
CancellationToken cancellationToken)
{
if (envelope.Payload is null)
{
_logger.LogWarning("SbomGenerated event {EventId} has no payload", envelope.EventId);
return;
}
// Parse the SBOM generated payload
var payload = ParseSbomGeneratedPayload(envelope.Payload.Value);
if (payload is null || string.IsNullOrEmpty(payload.Digest))
{
_logger.LogWarning(
"SbomGenerated event {EventId} has invalid payload",
envelope.EventId);
return;
}
_logger.LogInformation(
"Processing SbomGenerated event {EventId}: SBOM {SbomId} with {ComponentCount} components",
envelope.EventId,
payload.SbomId,
payload.ComponentCount);
// Fetch SBOM content if we have a fetcher
IReadOnlyList<string> purls;
if (_sbomFetcher is not null && !string.IsNullOrEmpty(payload.SbomRef))
{
purls = await _sbomFetcher.FetchPurlsAsync(payload.SbomRef, cancellationToken)
.ConfigureAwait(false);
}
else
{
_logger.LogWarning(
"Cannot fetch SBOM content for {SbomId}: no fetcher configured or no SbomRef",
payload.SbomId);
return;
}
if (purls.Count == 0)
{
_logger.LogWarning("SBOM {SbomId} has no PURLs", payload.SbomId);
return;
}
// Create registration input
var input = new SbomRegistrationInput
{
Digest = payload.Digest,
Format = ParseSbomFormat(payload.Format),
SpecVersion = payload.SpecVersion ?? "1.6",
PrimaryName = envelope.Scope?.Repo,
PrimaryVersion = envelope.Scope?.Digest,
Purls = purls,
Source = "scanner-event",
TenantId = envelope.Tenant
};
// Learn the SBOM
try
{
var result = await _registryService.LearnSbomAsync(input, cancellationToken)
.ConfigureAwait(false);
Interlocked.Increment(ref _sbomsLearned);
_logger.LogInformation(
"Auto-learned SBOM {Digest} from scanner event: {MatchCount} advisories matched, {ScoresUpdated} scores updated",
payload.Digest,
result.Matches.Count,
result.ScoresUpdated);
}
catch (Exception ex)
{
Interlocked.Increment(ref _errors);
_logger.LogError(ex,
"Failed to auto-learn SBOM {Digest} from scanner event",
payload.Digest);
}
}
private static SbomGeneratedPayload? ParseSbomGeneratedPayload(JsonElement? payload)
{
if (payload is null || payload.Value.ValueKind == JsonValueKind.Undefined)
{
return null;
}
try
{
return payload.Value.Deserialize<SbomGeneratedPayload>();
}
catch
{
return null;
}
}
private static SbomFormat ParseSbomFormat(string? format)
{
return format?.ToLowerInvariant() switch
{
"spdx" => SbomFormat.SPDX,
_ => SbomFormat.CycloneDX
};
}
}
/// <summary>
/// Envelope for orchestrator events received from the event stream.
/// </summary>
public sealed record OrchestratorEventEnvelope
{
public Guid EventId { get; init; }
public string Kind { get; init; } = string.Empty;
public int Version { get; init; } = 1;
public string? Tenant { get; init; }
public DateTimeOffset OccurredAt { get; init; }
public DateTimeOffset? RecordedAt { get; init; }
public string? Source { get; init; }
public string? IdempotencyKey { get; init; }
public string? CorrelationId { get; init; }
public OrchestratorEventScope? Scope { get; init; }
public JsonElement? Payload { get; init; }
}
/// <summary>
/// Scope for orchestrator events.
/// </summary>
public sealed record OrchestratorEventScope
{
public string? Namespace { get; init; }
public string? Repo { get; init; }
public string? Digest { get; init; }
}
/// <summary>
/// Payload for SBOM generated events.
/// </summary>
internal sealed record SbomGeneratedPayload
{
public string ScanId { get; init; } = string.Empty;
public string SbomId { get; init; } = string.Empty;
public DateTimeOffset GeneratedAt { get; init; }
public string Format { get; init; } = "cyclonedx";
public string? SpecVersion { get; init; }
public int ComponentCount { get; init; }
public string? SbomRef { get; init; }
public string? Digest { get; init; }
}
/// <summary>
/// Interface for fetching SBOM content from Scanner service.
/// </summary>
public interface IScannerSbomFetcher
{
/// <summary>
/// Fetches PURLs from an SBOM by reference.
/// </summary>
/// <param name="sbomRef">Reference to the SBOM (URL or ID).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of PURLs extracted from the SBOM.</returns>
Task<IReadOnlyList<string>> FetchPurlsAsync(
string sbomRef,
CancellationToken cancellationToken = default);
}

View File

@@ -108,5 +108,13 @@ public interface ISbomRegistryRepository
DateTimeOffset lastMatched,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates the PURL list for an SBOM.
/// </summary>
Task UpdatePurlsAsync(
string digest,
IReadOnlyList<string> purls,
CancellationToken cancellationToken = default);
#endregion
}

View File

@@ -1,12 +1,13 @@
// -----------------------------------------------------------------------------
// ServiceCollectionExtensions.cs
// Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring
// Task: SBOM-8200-000
// Tasks: SBOM-8200-000, SBOM-8200-025
// Description: DI registration for SBOM integration services
// -----------------------------------------------------------------------------
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Concelier.SbomIntegration.Events;
using StellaOps.Concelier.SbomIntegration.Index;
using StellaOps.Concelier.SbomIntegration.Matching;
using StellaOps.Concelier.SbomIntegration.Parsing;
@@ -61,4 +62,30 @@ public static class ServiceCollectionExtensions
return services;
}
/// <summary>
/// Adds the Scanner event handler for auto-learning SBOMs.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddConcelierSbomAutoLearning(this IServiceCollection services)
{
services.AddHostedService<ScanCompletedEventHandler>();
return services;
}
/// <summary>
/// Adds the Scanner event handler with custom options.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configureOptions">Options configuration action.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddConcelierSbomAutoLearning(
this IServiceCollection services,
Action<ScanCompletedHandlerOptions> configureOptions)
{
services.Configure(configureOptions);
services.AddHostedService<ScanCompletedEventHandler>();
return services;
}
}