using System.Globalization; using System.Text.Json; using System.Text.Json.Nodes; namespace StellaOps.Scanner.Analyzers.Secrets; /// /// Publishes secret alerts to the Notify service queue. /// Transforms SecretFindingAlertEvent to NotifyEvent format. /// public sealed class NotifySecretAlertPublisher : ISecretAlertPublisher { private readonly INotifyEventQueue _notifyQueue; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false }; public NotifySecretAlertPublisher( INotifyEventQueue notifyQueue, ILogger logger, TimeProvider timeProvider) { _notifyQueue = notifyQueue ?? throw new ArgumentNullException(nameof(notifyQueue)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); } public async ValueTask PublishAsync( SecretFindingAlertEvent alertEvent, SecretAlertDestination destination, SecretAlertSettings settings, CancellationToken ct = default) { var payload = BuildPayload(alertEvent, settings); var notifyEvent = new NotifyEventDto { EventId = alertEvent.EventId, Kind = SecretFindingAlertEvent.EventKind, Tenant = alertEvent.TenantId, Ts = alertEvent.DetectedAt, Payload = payload, Scope = new NotifyEventScopeDto { ImageRef = alertEvent.ImageRef, Digest = alertEvent.ArtifactDigest }, Attributes = new Dictionary { ["severity"] = alertEvent.Severity.ToString().ToLowerInvariant(), ["ruleId"] = alertEvent.RuleId, ["channelType"] = destination.ChannelType.ToString().ToLowerInvariant(), ["destinationId"] = destination.Id.ToString() } }; await _notifyQueue.EnqueueAsync(notifyEvent, ct); _logger.LogDebug( "Published secret alert {EventId} to {ChannelType}:{ChannelId}", alertEvent.EventId, destination.ChannelType, destination.ChannelId); } public async ValueTask PublishSummaryAsync( SecretFindingSummaryEvent summary, SecretAlertDestination destination, SecretAlertSettings settings, CancellationToken ct = default) { var payload = BuildSummaryPayload(summary, settings); var notifyEvent = new NotifyEventDto { EventId = summary.EventId, Kind = SecretFindingSummaryEvent.EventKind, Tenant = summary.TenantId, Ts = summary.DetectedAt, Payload = payload, Scope = new NotifyEventScopeDto { ImageRef = summary.ImageRef }, Attributes = new Dictionary { ["totalFindings"] = summary.TotalFindings.ToString(CultureInfo.InvariantCulture), ["channelType"] = destination.ChannelType.ToString().ToLowerInvariant(), ["destinationId"] = destination.Id.ToString() } }; await _notifyQueue.EnqueueAsync(notifyEvent, ct); _logger.LogDebug( "Published secret summary alert {EventId} with {Count} findings to {ChannelType}", summary.EventId, summary.TotalFindings, destination.ChannelType); } private static JsonNode BuildPayload(SecretFindingAlertEvent alert, SecretAlertSettings settings) { var payload = new JsonObject { ["eventId"] = alert.EventId.ToString(), ["scanId"] = alert.ScanId.ToString(), ["severity"] = alert.Severity.ToString(), ["confidence"] = alert.Confidence.ToString(), ["ruleId"] = alert.RuleId, ["ruleName"] = alert.RuleName, ["detectedAt"] = alert.DetectedAt.ToString("O", CultureInfo.InvariantCulture) }; if (settings.IncludeFilePath) { payload["filePath"] = alert.FilePath; payload["lineNumber"] = alert.LineNumber; } if (settings.IncludeMaskedValue) { payload["maskedValue"] = alert.MaskedValue; } if (!string.IsNullOrEmpty(alert.RuleCategory)) { payload["ruleCategory"] = alert.RuleCategory; } if (!string.IsNullOrEmpty(alert.ScanTriggeredBy)) { payload["triggeredBy"] = alert.ScanTriggeredBy; } if (!string.IsNullOrEmpty(alert.BundleVersion)) { payload["bundleVersion"] = alert.BundleVersion; } return payload; } private static JsonNode BuildSummaryPayload(SecretFindingSummaryEvent summary, SecretAlertSettings settings) { var severityBreakdown = new JsonObject(); foreach (var (severity, count) in summary.FindingsBySeverity) { severityBreakdown[severity.ToString().ToLowerInvariant()] = count; } var categoryBreakdown = new JsonObject(); foreach (var (category, count) in summary.FindingsByCategory) { categoryBreakdown[category] = count; } var topFindings = new JsonArray(); foreach (var finding in summary.TopFindings) { var findingNode = new JsonObject { ["ruleId"] = finding.RuleId, ["severity"] = finding.Severity.ToString() }; if (settings.IncludeFilePath) { findingNode["filePath"] = finding.FilePath; findingNode["lineNumber"] = finding.LineNumber; } if (settings.IncludeMaskedValue) { findingNode["maskedValue"] = finding.MaskedValue; } topFindings.Add(findingNode); } return new JsonObject { ["eventId"] = summary.EventId.ToString(), ["scanId"] = summary.ScanId.ToString(), ["totalFindings"] = summary.TotalFindings, ["severityBreakdown"] = severityBreakdown, ["categoryBreakdown"] = categoryBreakdown, ["topFindings"] = topFindings, ["detectedAt"] = summary.DetectedAt.ToString("O", CultureInfo.InvariantCulture) }; } } /// /// Interface for queuing events to the Notify service. /// public interface INotifyEventQueue { /// /// Enqueues an event for delivery to Notify. /// ValueTask EnqueueAsync(NotifyEventDto eventDto, CancellationToken ct = default); } /// /// DTO for events to be sent to Notify service. /// public sealed record NotifyEventDto { public required Guid EventId { get; init; } public required string Kind { get; init; } public required string Tenant { get; init; } public required DateTimeOffset Ts { get; init; } public JsonNode? Payload { get; init; } public NotifyEventScopeDto? Scope { get; init; } public Dictionary? Attributes { get; init; } } /// /// Scope DTO for Notify events. /// public sealed record NotifyEventScopeDto { public string? ImageRef { get; init; } public string? Digest { get; init; } public string? Namespace { get; init; } public string? Repository { get; init; } } /// /// Null implementation of INotifyEventQueue for when Notify is not configured. /// public sealed class NullNotifyEventQueue : INotifyEventQueue { private readonly ILogger _logger; public NullNotifyEventQueue(ILogger logger) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public ValueTask EnqueueAsync(NotifyEventDto eventDto, CancellationToken ct = default) { _logger.LogDebug( "Notify not configured, dropping event {EventId} of kind {Kind}", eventDto.EventId, eventDto.Kind); return ValueTask.CompletedTask; } }