Files
git.stella-ops.org/src/Scanner/StellaOps.Scanner.WebService/Services/ReportEventDispatcher.cs
master 950f238a93
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Add MergeUsageAnalyzer to detect legacy merge service usage
- Implemented MergeUsageAnalyzer to flag usage of AdvisoryMergeService and AddMergeModule.
- Created AnalyzerReleases.Shipped.md and AnalyzerReleases.Unshipped.md for release documentation.
- Added tests for MergeUsageAnalyzer to ensure correct diagnostics for various scenarios.
- Updated project files for analyzers and tests to include necessary dependencies and configurations.
- Introduced a sample report structure for scanner output.
2025-11-06 15:03:39 +02:00

609 lines
21 KiB
C#

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<ReportEventDispatcher> _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<ScannerWebServiceOptions> options,
TimeProvider timeProvider,
ILogger<ReportEventDispatcher> 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<string, string> BuildAttributes(ReportDocumentDto document)
{
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(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<string>(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<string> 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<string> prefix, params string[] suffix)
{
var segments = new List<string>();
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<string>();
}
return path.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static ImmutableDictionary<string, PolicyPreviewFindingDto> BuildFindingsIndex(
IReadOnlyList<PolicyPreviewFindingDto>? findings)
{
if (findings is null || findings.Count == 0)
{
return ImmutableDictionary<string, PolicyPreviewFindingDto>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<string, PolicyPreviewFindingDto>(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<FindingSummaryPayload> BuildFindingSummaries(ReportRequestDto request)
{
if (request.Findings is not { Count: > 0 })
{
return Array.Empty<FindingSummaryPayload>();
}
var summaries = new List<FindingSummaryPayload>(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<string>? 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);
}
}