using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics; using System.Linq; using System.Security.Claims; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Auth.Abstractions; using StellaOps.Policy; using StellaOps.Scanner.WebService.Contracts; using StellaOps.Scanner.WebService.Options; namespace StellaOps.Scanner.WebService.Services; internal sealed class ReportEventDispatcher : IReportEventDispatcher { private const string DefaultTenant = "default"; private const string Source = "scanner.webservice"; private readonly IPlatformEventPublisher _publisher; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; private readonly string[] _apiBaseSegments; private readonly string _reportsSegment; private readonly string _policySegment; private readonly string[] _consoleBaseSegments; private readonly string _consoleReportsSegment; private readonly string _consolePolicySegment; private readonly string _consoleAttestationsSegment; public ReportEventDispatcher( IPlatformEventPublisher publisher, IOptions options, TimeProvider timeProvider, ILogger logger) { _publisher = publisher ?? throw new ArgumentNullException(nameof(publisher)); if (options is null) { throw new ArgumentNullException(nameof(options)); } var apiOptions = options.Value.Api ?? new ScannerWebServiceOptions.ApiOptions(); _apiBaseSegments = SplitSegments(apiOptions.BasePath); _reportsSegment = string.IsNullOrWhiteSpace(apiOptions.ReportsSegment) ? "reports" : apiOptions.ReportsSegment.Trim('/'); _policySegment = string.IsNullOrWhiteSpace(apiOptions.PolicySegment) ? "policy" : apiOptions.PolicySegment.Trim('/'); var consoleOptions = options.Value.Console ?? new ScannerWebServiceOptions.ConsoleOptions(); _consoleBaseSegments = SplitSegments(consoleOptions.BasePath); _consoleReportsSegment = string.IsNullOrWhiteSpace(consoleOptions.ReportsSegment) ? "reports" : consoleOptions.ReportsSegment.Trim('/'); _consolePolicySegment = string.IsNullOrWhiteSpace(consoleOptions.PolicySegment) ? "policy" : consoleOptions.PolicySegment.Trim('/'); _consoleAttestationsSegment = string.IsNullOrWhiteSpace(consoleOptions.AttestationsSegment) ? "attestations" : consoleOptions.AttestationsSegment.Trim('/'); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task PublishAsync( ReportRequestDto request, PolicyPreviewResponse preview, ReportDocumentDto document, DsseEnvelopeDto? envelope, HttpContext httpContext, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(preview); ArgumentNullException.ThrowIfNull(document); ArgumentNullException.ThrowIfNull(httpContext); cancellationToken.ThrowIfCancellationRequested(); var now = _timeProvider.GetUtcNow(); var occurredAt = document.GeneratedAt == default ? now : document.GeneratedAt; var tenant = ResolveTenant(httpContext); var scope = BuildScope(request, document); var attributes = BuildAttributes(document); var links = BuildLinks(httpContext, document, envelope); var correlationId = document.ReportId; var (traceId, spanId) = ResolveTraceContext(); var reportEvent = new OrchestratorEvent { EventId = Guid.NewGuid(), Kind = OrchestratorEventKinds.ScannerReportReady, Version = 1, Tenant = tenant, OccurredAt = occurredAt, RecordedAt = now, Source = Source, IdempotencyKey = BuildIdempotencyKey(OrchestratorEventKinds.ScannerReportReady, tenant, document.ReportId), CorrelationId = correlationId, TraceId = traceId, SpanId = spanId, Scope = scope, Attributes = attributes, Payload = BuildReportReadyPayload(request, preview, document, envelope, links, correlationId) }; await PublishSafelyAsync(reportEvent, document.ReportId, cancellationToken).ConfigureAwait(false); var scanCompletedEvent = new OrchestratorEvent { EventId = Guid.NewGuid(), Kind = OrchestratorEventKinds.ScannerScanCompleted, Version = 1, Tenant = tenant, OccurredAt = occurredAt, RecordedAt = now, Source = Source, IdempotencyKey = BuildIdempotencyKey(OrchestratorEventKinds.ScannerScanCompleted, tenant, correlationId), CorrelationId = correlationId, TraceId = traceId, SpanId = spanId, Scope = scope, Attributes = attributes, Payload = BuildScanCompletedPayload(request, preview, document, envelope, links, correlationId) }; await PublishSafelyAsync(scanCompletedEvent, document.ReportId, cancellationToken).ConfigureAwait(false); } private async Task PublishSafelyAsync(OrchestratorEvent @event, string reportId, CancellationToken cancellationToken) { try { await _publisher.PublishAsync(@event, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception ex) { _logger.LogError( ex, "Failed to publish orchestrator event {EventKind} for report {ReportId}.", @event.Kind, reportId); } } private static string ResolveTenant(HttpContext context) { var tenant = context.User?.FindFirstValue(StellaOpsClaimTypes.Tenant); if (!string.IsNullOrWhiteSpace(tenant)) { return tenant.Trim(); } if (context.Request.Headers.TryGetValue("X-Stella-Tenant", out var headerTenant)) { var headerValue = headerTenant.ToString(); if (!string.IsNullOrWhiteSpace(headerValue)) { return headerValue.Trim(); } } return DefaultTenant; } private static OrchestratorEventScope BuildScope(ReportRequestDto request, ReportDocumentDto document) { var repository = ResolveRepository(request); var (ns, repo) = SplitRepository(repository); var digest = string.IsNullOrWhiteSpace(document.ImageDigest) ? request.ImageDigest ?? string.Empty : document.ImageDigest; return new OrchestratorEventScope { Namespace = ns, Repo = string.IsNullOrWhiteSpace(repo) ? "(unknown)" : repo, Digest = string.IsNullOrWhiteSpace(digest) ? "(unknown)" : digest }; } private static ImmutableSortedDictionary BuildAttributes(ReportDocumentDto document) { var builder = ImmutableSortedDictionary.CreateBuilder(StringComparer.Ordinal); builder["reportId"] = document.ReportId; builder["verdict"] = document.Verdict; if (!string.IsNullOrWhiteSpace(document.Policy.RevisionId)) { builder["policyRevisionId"] = document.Policy.RevisionId!; } if (!string.IsNullOrWhiteSpace(document.Policy.Digest)) { builder["policyDigest"] = document.Policy.Digest!; } return builder.ToImmutable(); } private static ReportReadyEventPayload BuildReportReadyPayload( ReportRequestDto request, PolicyPreviewResponse preview, ReportDocumentDto document, DsseEnvelopeDto? envelope, ReportLinksPayload links, string correlationId) { return new ReportReadyEventPayload { ReportId = document.ReportId, ScanId = correlationId, ImageDigest = document.ImageDigest, GeneratedAt = document.GeneratedAt, Verdict = MapVerdict(document.Verdict), Summary = document.Summary, Delta = BuildDelta(preview, request), QuietedFindingCount = document.Summary.Quieted, Policy = document.Policy, Links = links, Dsse = envelope, Report = document }; } private static ScanCompletedEventPayload BuildScanCompletedPayload( ReportRequestDto request, PolicyPreviewResponse preview, ReportDocumentDto document, DsseEnvelopeDto? envelope, ReportLinksPayload links, string correlationId) { return new ScanCompletedEventPayload { ReportId = document.ReportId, ScanId = correlationId, ImageDigest = document.ImageDigest, Verdict = MapVerdict(document.Verdict), Summary = document.Summary, Delta = BuildDelta(preview, request), Policy = document.Policy, Findings = BuildFindingSummaries(request), Links = links, Dsse = envelope, Report = document }; } private ReportLinksPayload BuildLinks(HttpContext context, ReportDocumentDto document, DsseEnvelopeDto? envelope) { if (!context.Request.Host.HasValue) { return new ReportLinksPayload(); } var reportUi = BuildAbsoluteUri(context, ConcatSegments(_consoleBaseSegments, _consoleReportsSegment, document.ReportId)); var reportApi = BuildAbsoluteUri(context, ConcatSegments(_apiBaseSegments, _reportsSegment, document.ReportId)); LinkTarget? policyLink = null; if (!string.IsNullOrWhiteSpace(document.Policy.RevisionId)) { var policyRevision = document.Policy.RevisionId!; var policyUi = BuildAbsoluteUri(context, ConcatSegments(_consoleBaseSegments, _consolePolicySegment, "revisions", policyRevision)); var policyApi = BuildAbsoluteUri(context, ConcatSegments(_apiBaseSegments, _policySegment, "revisions", policyRevision)); policyLink = LinkTarget.Create(policyUi, policyApi); } LinkTarget? attestationLink = null; if (envelope is not null) { var attestationUi = BuildAbsoluteUri(context, ConcatSegments(_consoleBaseSegments, _consoleAttestationsSegment, document.ReportId)); var attestationApi = BuildAbsoluteUri(context, ConcatSegments(_apiBaseSegments, _reportsSegment, document.ReportId, "attestation")); attestationLink = LinkTarget.Create(attestationUi, attestationApi); } return new ReportLinksPayload { Report = LinkTarget.Create(reportUi, reportApi), Policy = policyLink, Attestation = attestationLink }; } private static ReportDeltaPayload? BuildDelta(PolicyPreviewResponse preview, ReportRequestDto request) { if (preview.Diffs.IsDefaultOrEmpty) { return null; } var findings = BuildFindingsIndex(request.Findings); var kevIds = new SortedSet(StringComparer.OrdinalIgnoreCase); var newCritical = 0; var newHigh = 0; foreach (var diff in preview.Diffs) { var projected = diff.Projected; if (projected is null || string.IsNullOrWhiteSpace(projected.FindingId)) { continue; } findings.TryGetValue(projected.FindingId, out var finding); if (IsNewlyImportant(diff)) { var severity = finding?.Severity; if (string.Equals(severity, "Critical", StringComparison.OrdinalIgnoreCase)) { newCritical++; } else if (string.Equals(severity, "High", StringComparison.OrdinalIgnoreCase)) { newHigh++; } var kevId = ResolveKevIdentifier(finding); if (!string.IsNullOrWhiteSpace(kevId)) { kevIds.Add(kevId); } } } if (newCritical == 0 && newHigh == 0 && kevIds.Count == 0) { return null; } return new ReportDeltaPayload { NewCritical = newCritical > 0 ? newCritical : null, NewHigh = newHigh > 0 ? newHigh : null, Kev = kevIds.Count > 0 ? kevIds.ToArray() : null }; } private static string BuildAbsoluteUri(HttpContext context, params string[] segments) => BuildAbsoluteUri(context, segments.AsEnumerable()); private static string BuildAbsoluteUri(HttpContext context, IEnumerable segments) { var normalized = segments .Where(segment => !string.IsNullOrWhiteSpace(segment)) .Select(segment => segment.Trim('/')) .Where(segment => segment.Length > 0) .ToArray(); if (!context.Request.Host.HasValue || normalized.Length == 0) { return string.Empty; } var scheme = string.IsNullOrWhiteSpace(context.Request.Scheme) ? "https" : context.Request.Scheme; var builder = new UriBuilder(scheme, context.Request.Host.Host) { Port = context.Request.Host.Port ?? -1, Path = "/" + string.Join('/', normalized.Select(Uri.EscapeDataString)), Query = string.Empty, Fragment = string.Empty }; return builder.Uri.ToString(); } private string[] ConcatSegments(IEnumerable prefix, params string[] suffix) { var segments = new List(); foreach (var segment in prefix) { if (!string.IsNullOrWhiteSpace(segment)) { segments.Add(segment.Trim('/')); } } foreach (var segment in suffix) { if (!string.IsNullOrWhiteSpace(segment)) { segments.Add(segment.Trim('/')); } } return segments.ToArray(); } private static string[] SplitSegments(string? path) { if (string.IsNullOrWhiteSpace(path)) { return Array.Empty(); } return path.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); } private static ImmutableDictionary BuildFindingsIndex( IReadOnlyList? findings) { if (findings is null || findings.Count == 0) { return ImmutableDictionary.Empty; } var builder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); foreach (var finding in findings) { if (string.IsNullOrWhiteSpace(finding.Id)) { continue; } if (!builder.ContainsKey(finding.Id)) { builder.Add(finding.Id, finding); } } return builder.ToImmutable(); } private static IReadOnlyList BuildFindingSummaries(ReportRequestDto request) { if (request.Findings is not { Count: > 0 }) { return Array.Empty(); } var summaries = new List(request.Findings.Count); foreach (var finding in request.Findings) { if (string.IsNullOrWhiteSpace(finding.Id)) { continue; } summaries.Add(new FindingSummaryPayload { Id = finding.Id, Severity = finding.Severity, Cve = finding.Cve, Purl = finding.Purl, Reachability = ResolveReachability(finding.Tags) }); } return summaries; } private static string ResolveRepository(ReportRequestDto request) { if (request.Findings is { Count: > 0 }) { foreach (var finding in request.Findings) { if (!string.IsNullOrWhiteSpace(finding.Repository)) { return finding.Repository!.Trim(); } if (!string.IsNullOrWhiteSpace(finding.Image)) { return finding.Image!.Trim(); } } } return string.Empty; } private static (string? Namespace, string Repo) SplitRepository(string repository) { if (string.IsNullOrWhiteSpace(repository)) { return (null, string.Empty); } var normalized = repository.Trim(); var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (segments.Length == 0) { return (null, normalized); } if (segments.Length == 1) { return (null, segments[0]); } var repo = segments[^1]; var ns = string.Join('/', segments[..^1]); return (ns, repo); } private static bool IsNewlyImportant(PolicyVerdictDiff diff) { var projected = diff.Projected.Status; var baseline = diff.Baseline.Status; return projected switch { PolicyVerdictStatus.Blocked or PolicyVerdictStatus.Escalated => baseline != PolicyVerdictStatus.Blocked && baseline != PolicyVerdictStatus.Escalated, PolicyVerdictStatus.Warned or PolicyVerdictStatus.Deferred or PolicyVerdictStatus.RequiresVex => baseline != PolicyVerdictStatus.Warned && baseline != PolicyVerdictStatus.Deferred && baseline != PolicyVerdictStatus.RequiresVex && baseline != PolicyVerdictStatus.Blocked && baseline != PolicyVerdictStatus.Escalated, _ => false }; } private static string? ResolveKevIdentifier(PolicyPreviewFindingDto? finding) { if (finding is null) { return null; } var tags = finding.Tags; if (tags is not null) { foreach (var tag in tags) { if (string.IsNullOrWhiteSpace(tag)) { continue; } if (string.Equals(tag, "kev", StringComparison.OrdinalIgnoreCase)) { return finding.Cve; } if (tag.StartsWith("kev:", StringComparison.OrdinalIgnoreCase)) { var value = tag["kev:".Length..]; if (!string.IsNullOrWhiteSpace(value)) { return value.Trim(); } } } } return finding.Cve; } private static string? ResolveReachability(IReadOnlyList? tags) { if (tags is null) { return null; } foreach (var tag in tags) { if (string.IsNullOrWhiteSpace(tag)) { continue; } if (tag.StartsWith("reachability:", StringComparison.OrdinalIgnoreCase)) { return tag["reachability:".Length..]; } } return null; } private static string MapVerdict(string verdict) => verdict.ToLowerInvariant() switch { "blocked" or "fail" => "fail", "escalated" => "fail", "warn" or "warned" or "deferred" or "requiresvex" => "warn", _ => "pass" }; private static string BuildIdempotencyKey(string kind, string tenant, string identifier) => $"{kind}:{tenant}:{identifier}".ToLowerInvariant(); private static (string? TraceId, string? SpanId) ResolveTraceContext() { var activity = Activity.Current; if (activity is null) { return (null, null); } var traceId = activity.TraceId.ToString(); var spanId = activity.SpanId.ToString(); return (traceId, spanId); } }