257 lines
8.3 KiB
C#
257 lines
8.3 KiB
C#
using System.Globalization;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
|
|
namespace StellaOps.Scanner.Analyzers.Secrets;
|
|
|
|
/// <summary>
|
|
/// Publishes secret alerts to the Notify service queue.
|
|
/// Transforms SecretFindingAlertEvent to NotifyEvent format.
|
|
/// </summary>
|
|
public sealed class NotifySecretAlertPublisher : ISecretAlertPublisher
|
|
{
|
|
private readonly INotifyEventQueue _notifyQueue;
|
|
private readonly ILogger<NotifySecretAlertPublisher> _logger;
|
|
private readonly TimeProvider _timeProvider;
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
WriteIndented = false
|
|
};
|
|
|
|
public NotifySecretAlertPublisher(
|
|
INotifyEventQueue notifyQueue,
|
|
ILogger<NotifySecretAlertPublisher> 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<string, string>
|
|
{
|
|
["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<string, string>
|
|
{
|
|
["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)
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Interface for queuing events to the Notify service.
|
|
/// </summary>
|
|
public interface INotifyEventQueue
|
|
{
|
|
/// <summary>
|
|
/// Enqueues an event for delivery to Notify.
|
|
/// </summary>
|
|
ValueTask EnqueueAsync(NotifyEventDto eventDto, CancellationToken ct = default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// DTO for events to be sent to Notify service.
|
|
/// </summary>
|
|
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<string, string>? Attributes { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Scope DTO for Notify events.
|
|
/// </summary>
|
|
public sealed record NotifyEventScopeDto
|
|
{
|
|
public string? ImageRef { get; init; }
|
|
public string? Digest { get; init; }
|
|
public string? Namespace { get; init; }
|
|
public string? Repository { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Null implementation of INotifyEventQueue for when Notify is not configured.
|
|
/// </summary>
|
|
public sealed class NullNotifyEventQueue : INotifyEventQueue
|
|
{
|
|
private readonly ILogger<NullNotifyEventQueue> _logger;
|
|
|
|
public NullNotifyEventQueue(ILogger<NullNotifyEventQueue> 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;
|
|
}
|
|
}
|