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;
}
}