warnings fixes, tests fixes, sprints completions
This commit is contained in:
@@ -15,6 +15,7 @@ internal sealed class PythonRuntimeEvidenceCollector
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly List<PythonRuntimeEvent> _events = [];
|
||||
private readonly Dictionary<string, string> _pathHashes = new();
|
||||
private readonly HashSet<string> _loadedModules = new(StringComparer.Ordinal);
|
||||
@@ -25,6 +26,15 @@ internal sealed class PythonRuntimeEvidenceCollector
|
||||
private string? _pythonVersion;
|
||||
private string? _platform;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PythonRuntimeEvidenceCollector"/> class.
|
||||
/// </summary>
|
||||
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
|
||||
public PythonRuntimeEvidenceCollector(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a JSON line from the runtime evidence output.
|
||||
/// </summary>
|
||||
@@ -389,8 +399,8 @@ internal sealed class PythonRuntimeEvidenceCollector
|
||||
ThreadId: null));
|
||||
}
|
||||
|
||||
private static string GetUtcTimestamp()
|
||||
private string GetUtcTimestamp()
|
||||
{
|
||||
return DateTime.UtcNow.ToString("O");
|
||||
return _timeProvider.GetUtcNow().ToString("O");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Service responsible for emitting alert events when secrets are detected.
|
||||
/// Handles rate limiting, deduplication, and routing to appropriate channels.
|
||||
/// </summary>
|
||||
public sealed class SecretAlertEmitter : ISecretAlertEmitter
|
||||
{
|
||||
private readonly ISecretAlertPublisher _publisher;
|
||||
private readonly ILogger<SecretAlertEmitter> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly StellaOps.Determinism.IGuidProvider _guidProvider;
|
||||
|
||||
// Deduplication cache: key -> last alert time
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _deduplicationCache = new();
|
||||
|
||||
public SecretAlertEmitter(
|
||||
ISecretAlertPublisher publisher,
|
||||
ILogger<SecretAlertEmitter> logger,
|
||||
TimeProvider timeProvider,
|
||||
StellaOps.Determinism.IGuidProvider guidProvider)
|
||||
{
|
||||
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits alerts for the detected secrets according to the settings.
|
||||
/// </summary>
|
||||
public async ValueTask EmitAlertsAsync(
|
||||
IReadOnlyList<SecretLeakEvidence> findings,
|
||||
SecretAlertSettings settings,
|
||||
ScanContext scanContext,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!settings.Enabled || findings.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("Alert emission skipped: Enabled={Enabled}, FindingsCount={Count}",
|
||||
settings.Enabled, findings.Count);
|
||||
return;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Filter findings that meet minimum severity
|
||||
var alertableFindings = findings
|
||||
.Where(f => f.Severity >= settings.MinimumAlertSeverity)
|
||||
.ToList();
|
||||
|
||||
if (alertableFindings.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No findings meet minimum severity threshold {Severity}",
|
||||
settings.MinimumAlertSeverity);
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply deduplication
|
||||
var dedupedFindings = DeduplicateFindings(alertableFindings, settings.DeduplicationWindow, now);
|
||||
|
||||
if (dedupedFindings.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("All findings were deduplicated");
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply rate limiting
|
||||
var rateLimitedFindings = dedupedFindings.Take(settings.MaxAlertsPerScan).ToList();
|
||||
|
||||
if (rateLimitedFindings.Count < dedupedFindings.Count)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Rate limit applied: {Sent} of {Total} alerts sent (max {Max})",
|
||||
rateLimitedFindings.Count,
|
||||
dedupedFindings.Count,
|
||||
settings.MaxAlertsPerScan);
|
||||
}
|
||||
|
||||
// Convert to alert events
|
||||
var alertEvents = rateLimitedFindings
|
||||
.Select(f => SecretFindingAlertEvent.FromEvidence(
|
||||
f,
|
||||
scanContext.ScanId,
|
||||
scanContext.TenantId,
|
||||
scanContext.ImageRef,
|
||||
scanContext.ArtifactDigest,
|
||||
scanContext.TriggeredBy,
|
||||
_guidProvider))
|
||||
.ToList();
|
||||
|
||||
// Check if we should send a summary instead
|
||||
if (settings.AggregateSummary && alertEvents.Count >= settings.SummaryThreshold)
|
||||
{
|
||||
await EmitSummaryAlertAsync(alertEvents, settings, scanContext, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await EmitIndividualAlertsAsync(alertEvents, settings, ct);
|
||||
}
|
||||
|
||||
// Update deduplication cache
|
||||
foreach (var finding in rateLimitedFindings)
|
||||
{
|
||||
var key = ComputeDeduplicationKey(finding);
|
||||
_deduplicationCache[key] = now;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Emitted {Count} secret alerts for scan {ScanId}",
|
||||
alertEvents.Count,
|
||||
scanContext.ScanId);
|
||||
}
|
||||
|
||||
private List<SecretLeakEvidence> DeduplicateFindings(
|
||||
List<SecretLeakEvidence> findings,
|
||||
TimeSpan window,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
var result = new List<SecretLeakEvidence>();
|
||||
|
||||
foreach (var finding in findings)
|
||||
{
|
||||
var key = ComputeDeduplicationKey(finding);
|
||||
|
||||
if (_deduplicationCache.TryGetValue(key, out var lastAlert))
|
||||
{
|
||||
if (now - lastAlert < window)
|
||||
{
|
||||
_logger.LogDebug("Finding deduplicated: {Key}, last alert {LastAlert}",
|
||||
key, lastAlert);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
result.Add(finding);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string ComputeDeduplicationKey(SecretLeakEvidence finding)
|
||||
{
|
||||
return $"{finding.RuleId}:{finding.FilePath}:{finding.LineNumber}";
|
||||
}
|
||||
|
||||
private async ValueTask EmitIndividualAlertsAsync(
|
||||
List<SecretFindingAlertEvent> events,
|
||||
SecretAlertSettings settings,
|
||||
CancellationToken ct)
|
||||
{
|
||||
foreach (var alertEvent in events)
|
||||
{
|
||||
var destinations = settings.Destinations
|
||||
.Where(d => d.ShouldAlert(alertEvent.Severity, alertEvent.RuleCategory))
|
||||
.ToList();
|
||||
|
||||
if (destinations.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No destinations configured for alert {EventId}", alertEvent.EventId);
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var destination in destinations)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
await _publisher.PublishAsync(alertEvent, destination, settings, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to publish alert {EventId} to destination {DestinationId}",
|
||||
alertEvent.EventId,
|
||||
destination.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask EmitSummaryAlertAsync(
|
||||
List<SecretFindingAlertEvent> events,
|
||||
SecretAlertSettings settings,
|
||||
ScanContext scanContext,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var findingsBySeverity = events
|
||||
.GroupBy(e => e.Severity)
|
||||
.ToImmutableDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var findingsByCategory = events
|
||||
.Where(e => e.RuleCategory is not null)
|
||||
.GroupBy(e => e.RuleCategory!)
|
||||
.ToImmutableDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var topFindings = events
|
||||
.OrderByDescending(e => e.Severity)
|
||||
.ThenByDescending(e => e.Confidence)
|
||||
.Take(5)
|
||||
.ToImmutableArray();
|
||||
|
||||
var summary = new SecretFindingSummaryEvent
|
||||
{
|
||||
EventId = _guidProvider.NewGuid(),
|
||||
TenantId = scanContext.TenantId,
|
||||
ScanId = scanContext.ScanId,
|
||||
ImageRef = scanContext.ImageRef,
|
||||
TotalFindings = events.Count,
|
||||
FindingsBySeverity = findingsBySeverity,
|
||||
FindingsByCategory = findingsByCategory,
|
||||
TopFindings = topFindings,
|
||||
DetectedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
foreach (var destination in settings.Destinations.Where(d => d.Enabled))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
await _publisher.PublishSummaryAsync(summary, destination, settings, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to publish summary alert {EventId} to destination {DestinationId}",
|
||||
summary.EventId,
|
||||
destination.Id);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Emitted summary alert for {Count} findings in scan {ScanId}",
|
||||
events.Count,
|
||||
scanContext.ScanId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up expired entries from the deduplication cache.
|
||||
/// Call periodically to prevent unbounded memory growth.
|
||||
/// </summary>
|
||||
public void CleanupDeduplicationCache(TimeSpan maxAge)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiredKeys = _deduplicationCache
|
||||
.Where(kvp => now - kvp.Value > maxAge)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in expiredKeys)
|
||||
{
|
||||
_deduplicationCache.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Cleaned up {Count} expired deduplication entries", expiredKeys.Count);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for emitting secret detection alerts.
|
||||
/// </summary>
|
||||
public interface ISecretAlertEmitter
|
||||
{
|
||||
/// <summary>
|
||||
/// Emits alerts for the detected secrets according to the settings.
|
||||
/// </summary>
|
||||
ValueTask EmitAlertsAsync(
|
||||
IReadOnlyList<SecretLeakEvidence> findings,
|
||||
SecretAlertSettings settings,
|
||||
ScanContext scanContext,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for publishing alerts to external channels.
|
||||
/// </summary>
|
||||
public interface ISecretAlertPublisher
|
||||
{
|
||||
/// <summary>
|
||||
/// Publishes an individual alert event.
|
||||
/// </summary>
|
||||
ValueTask PublishAsync(
|
||||
SecretFindingAlertEvent alertEvent,
|
||||
SecretAlertDestination destination,
|
||||
SecretAlertSettings settings,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a summary alert event.
|
||||
/// </summary>
|
||||
ValueTask PublishSummaryAsync(
|
||||
SecretFindingSummaryEvent summary,
|
||||
SecretAlertDestination destination,
|
||||
SecretAlertSettings settings,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context information about the scan for alert events.
|
||||
/// </summary>
|
||||
public sealed record ScanContext
|
||||
{
|
||||
public required Guid ScanId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string ImageRef { get; init; }
|
||||
public required string ArtifactDigest { get; init; }
|
||||
public string? TriggeredBy { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for secret detection alerting.
|
||||
/// Defines how and when alerts are sent for detected secrets.
|
||||
/// </summary>
|
||||
public sealed record SecretAlertSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable/disable alerting for this tenant.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum severity to trigger alert.
|
||||
/// </summary>
|
||||
public SecretSeverity MinimumAlertSeverity { get; init; } = SecretSeverity.High;
|
||||
|
||||
/// <summary>
|
||||
/// Alert destinations by channel type.
|
||||
/// </summary>
|
||||
public ImmutableArray<SecretAlertDestination> Destinations { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Rate limit: max alerts per scan.
|
||||
/// </summary>
|
||||
public int MaxAlertsPerScan { get; init; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Deduplication window: don't re-alert same secret within this period.
|
||||
/// </summary>
|
||||
public TimeSpan DeduplicationWindow { get; init; } = TimeSpan.FromHours(24);
|
||||
|
||||
/// <summary>
|
||||
/// Include file path in alert (may reveal repo structure).
|
||||
/// </summary>
|
||||
public bool IncludeFilePath { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Include masked secret value in alert.
|
||||
/// </summary>
|
||||
public bool IncludeMaskedValue { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Alert title template. Supports {{severity}}, {{ruleName}}, {{imageRef}} placeholders.
|
||||
/// </summary>
|
||||
public string TitleTemplate { get; init; } = "Secret Detected: {{ruleName}} ({{severity}})";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to aggregate findings into a single summary alert.
|
||||
/// </summary>
|
||||
public bool AggregateSummary { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum number of findings to trigger a summary alert when AggregateSummary is true.
|
||||
/// </summary>
|
||||
public int SummaryThreshold { get; init; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the settings and returns any errors.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Validate()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
if (MaxAlertsPerScan < 0)
|
||||
{
|
||||
errors.Add("MaxAlertsPerScan must be non-negative");
|
||||
}
|
||||
|
||||
if (DeduplicationWindow < TimeSpan.Zero)
|
||||
{
|
||||
errors.Add("DeduplicationWindow must be non-negative");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(TitleTemplate))
|
||||
{
|
||||
errors.Add("TitleTemplate is required");
|
||||
}
|
||||
|
||||
foreach (var dest in Destinations)
|
||||
{
|
||||
var destErrors = dest.Validate();
|
||||
errors.AddRange(destErrors);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single alert destination configuration.
|
||||
/// </summary>
|
||||
public sealed record SecretAlertDestination
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this destination.
|
||||
/// </summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Name of the destination for display purposes.
|
||||
/// </summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The channel type for this destination.
|
||||
/// </summary>
|
||||
public required SecretAlertChannelType ChannelType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Channel-specific identifier (Slack channel ID, email address, webhook URL).
|
||||
/// </summary>
|
||||
public required string ChannelId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional severity filter. If null, all severities meeting minimum are sent.
|
||||
/// </summary>
|
||||
public ImmutableArray<SecretSeverity>? SeverityFilter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional rule category filter. If null, all categories are sent.
|
||||
/// </summary>
|
||||
public ImmutableArray<string>? RuleCategoryFilter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this destination is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the destination and returns any errors.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Validate()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
if (Id == Guid.Empty)
|
||||
{
|
||||
errors.Add($"Destination Id cannot be empty");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ChannelId))
|
||||
{
|
||||
errors.Add($"Destination {Id}: ChannelId is required");
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the destination should receive an alert for the given severity and category.
|
||||
/// </summary>
|
||||
public bool ShouldAlert(SecretSeverity severity, string? ruleCategory)
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check severity filter
|
||||
if (SeverityFilter is { Length: > 0 } severities)
|
||||
{
|
||||
if (!severities.Contains(severity))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check category filter
|
||||
if (RuleCategoryFilter is { Length: > 0 } categories)
|
||||
{
|
||||
if (string.IsNullOrEmpty(ruleCategory) || !categories.Contains(ruleCategory, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Supported alert channel types for secret detection.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum SecretAlertChannelType
|
||||
{
|
||||
/// <summary>Slack channel via webhook or API.</summary>
|
||||
Slack,
|
||||
|
||||
/// <summary>Microsoft Teams channel.</summary>
|
||||
Teams,
|
||||
|
||||
/// <summary>Email notification.</summary>
|
||||
Email,
|
||||
|
||||
/// <summary>Generic webhook (JSON payload).</summary>
|
||||
Webhook,
|
||||
|
||||
/// <summary>PagerDuty incident.</summary>
|
||||
PagerDuty,
|
||||
|
||||
/// <summary>OpsGenie alert.</summary>
|
||||
OpsGenie
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a secret is detected, for consumption by the alert system.
|
||||
/// This is the bridge between Scanner findings and Notify service.
|
||||
/// </summary>
|
||||
public sealed record SecretFindingAlertEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this event.
|
||||
/// </summary>
|
||||
public required Guid EventId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant that owns the scanned artifact.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ID of the scan that produced this finding.
|
||||
/// </summary>
|
||||
public required Guid ScanId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Image reference (e.g., "registry/repo:tag@sha256:...").
|
||||
/// </summary>
|
||||
public required string ImageRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the scanned artifact.
|
||||
/// </summary>
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity of the detected secret.
|
||||
/// </summary>
|
||||
public required SecretSeverity Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ID of the rule that detected this secret.
|
||||
/// </summary>
|
||||
public required string RuleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable rule name.
|
||||
/// </summary>
|
||||
public required string RuleName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Category of the rule (e.g., "cloud-credentials", "api-keys", "private-keys").
|
||||
/// </summary>
|
||||
public string? RuleCategory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File path where the secret was found (relative to scan root).
|
||||
/// </summary>
|
||||
public required string FilePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Line number where the secret was found (1-based).
|
||||
/// </summary>
|
||||
public required int LineNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Masked value of the detected secret (never the actual secret).
|
||||
/// </summary>
|
||||
public required string MaskedValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this finding was detected.
|
||||
/// </summary>
|
||||
public required DateTimeOffset DetectedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Who or what triggered the scan (e.g., "ci-pipeline", "user:alice", "webhook").
|
||||
/// </summary>
|
||||
public string? ScanTriggeredBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level of the detection.
|
||||
/// </summary>
|
||||
public SecretConfidence Confidence { get; init; } = SecretConfidence.Medium;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle ID that contained the rule.
|
||||
/// </summary>
|
||||
public string? BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle version that contained the rule.
|
||||
/// </summary>
|
||||
public string? BundleVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional attributes for the event.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Attributes { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Deduplication key for rate limiting. Two events with the same key
|
||||
/// within the deduplication window are considered duplicates.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string DeduplicationKey =>
|
||||
string.Create(CultureInfo.InvariantCulture, $"{TenantId}:{RuleId}:{FilePath}:{LineNumber}");
|
||||
|
||||
/// <summary>
|
||||
/// The event kind for Notify service routing.
|
||||
/// </summary>
|
||||
public const string EventKind = "secret.finding";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a SecretFindingAlertEvent from a SecretLeakEvidence.
|
||||
/// </summary>
|
||||
public static SecretFindingAlertEvent FromEvidence(
|
||||
SecretLeakEvidence evidence,
|
||||
Guid scanId,
|
||||
string tenantId,
|
||||
string imageRef,
|
||||
string artifactDigest,
|
||||
string? scanTriggeredBy,
|
||||
StellaOps.Determinism.IGuidProvider guidProvider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evidence);
|
||||
ArgumentNullException.ThrowIfNull(guidProvider);
|
||||
|
||||
return new SecretFindingAlertEvent
|
||||
{
|
||||
EventId = guidProvider.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
ScanId = scanId,
|
||||
ImageRef = imageRef,
|
||||
ArtifactDigest = artifactDigest,
|
||||
Severity = evidence.Severity,
|
||||
RuleId = evidence.RuleId,
|
||||
RuleName = evidence.RuleId, // Could be enhanced with rule name lookup
|
||||
RuleCategory = GetRuleCategory(evidence.RuleId),
|
||||
FilePath = evidence.FilePath,
|
||||
LineNumber = evidence.LineNumber,
|
||||
MaskedValue = evidence.Mask,
|
||||
DetectedAt = evidence.DetectedAt,
|
||||
ScanTriggeredBy = scanTriggeredBy,
|
||||
Confidence = evidence.Confidence,
|
||||
BundleId = evidence.BundleId,
|
||||
BundleVersion = evidence.BundleVersion
|
||||
};
|
||||
}
|
||||
|
||||
private static string? GetRuleCategory(string ruleId)
|
||||
{
|
||||
// Extract category from rule ID convention: "stellaops.secrets.<category>.<name>"
|
||||
var parts = ruleId.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length >= 3 && parts[0] == "stellaops" && parts[1] == "secrets")
|
||||
{
|
||||
return parts[2];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary event for aggregated secret findings.
|
||||
/// Sent when AggregateSummary is enabled and multiple secrets are found.
|
||||
/// </summary>
|
||||
public sealed record SecretFindingSummaryEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this event.
|
||||
/// </summary>
|
||||
public required Guid EventId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant that owns the scanned artifact.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ID of the scan that produced these findings.
|
||||
/// </summary>
|
||||
public required Guid ScanId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Image reference.
|
||||
/// </summary>
|
||||
public required string ImageRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of secrets found.
|
||||
/// </summary>
|
||||
public required int TotalFindings { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Breakdown by severity.
|
||||
/// </summary>
|
||||
public required ImmutableDictionary<SecretSeverity, int> FindingsBySeverity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Breakdown by rule category.
|
||||
/// </summary>
|
||||
public required ImmutableDictionary<string, int> FindingsByCategory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Top N findings (most severe) included for detail.
|
||||
/// </summary>
|
||||
public required ImmutableArray<SecretFindingAlertEvent> TopFindings { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the scan completed.
|
||||
/// </summary>
|
||||
public required DateTimeOffset DetectedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The event kind for Notify service routing.
|
||||
/// </summary>
|
||||
public const string EventKind = "secret.finding.summary";
|
||||
}
|
||||
@@ -334,6 +334,17 @@ public sealed record FindingContext
|
||||
/// </summary>
|
||||
public sealed class DefaultFalsificationConditionGenerator : IFalsificationConditionGenerator
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DefaultFalsificationConditionGenerator"/> class.
|
||||
/// </summary>
|
||||
/// <param name="timeProvider">Time provider for deterministic timestamps.</param>
|
||||
public DefaultFalsificationConditionGenerator(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public FalsificationConditions Generate(FindingContext context)
|
||||
{
|
||||
var conditions = new List<FalsificationCondition>();
|
||||
@@ -425,7 +436,7 @@ public sealed class DefaultFalsificationConditionGenerator : IFalsificationCondi
|
||||
ComponentPurl = context.ComponentPurl,
|
||||
Conditions = conditions.ToImmutableArray(),
|
||||
Operator = FalsificationOperator.Any,
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
GeneratedAt = _timeProvider.GetUtcNow(),
|
||||
Generator = "StellaOps.DefaultFalsificationGenerator/1.0"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -298,6 +298,17 @@ public interface IZeroDayWindowTracker
|
||||
/// </summary>
|
||||
public sealed class ZeroDayWindowCalculator
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ZeroDayWindowCalculator"/> class.
|
||||
/// </summary>
|
||||
/// <param name="timeProvider">Time provider for deterministic timestamps.</param>
|
||||
public ZeroDayWindowCalculator(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the risk score for a window.
|
||||
/// </summary>
|
||||
@@ -326,7 +337,7 @@ public sealed class ZeroDayWindowCalculator
|
||||
{
|
||||
// Patch available but not applied
|
||||
var hoursSincePatch = window.PatchAvailableAt.HasValue
|
||||
? (DateTimeOffset.UtcNow - window.PatchAvailableAt.Value).TotalHours
|
||||
? (_timeProvider.GetUtcNow() - window.PatchAvailableAt.Value).TotalHours
|
||||
: 0;
|
||||
|
||||
score = hoursSincePatch switch
|
||||
@@ -359,7 +370,7 @@ public sealed class ZeroDayWindowCalculator
|
||||
return new ZeroDayWindowStats
|
||||
{
|
||||
ArtifactDigest = artifactDigest,
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
ComputedAt = _timeProvider.GetUtcNow(),
|
||||
TotalWindows = 0,
|
||||
AggregateRiskScore = 0
|
||||
};
|
||||
@@ -390,7 +401,7 @@ public sealed class ZeroDayWindowCalculator
|
||||
return new ZeroDayWindowStats
|
||||
{
|
||||
ArtifactDigest = artifactDigest,
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
ComputedAt = _timeProvider.GetUtcNow(),
|
||||
TotalWindows = windowList.Count,
|
||||
ActiveWindows = windowList.Count(w =>
|
||||
w.Status == ZeroDayWindowStatus.ActiveNoPatch ||
|
||||
@@ -415,7 +426,7 @@ public sealed class ZeroDayWindowCalculator
|
||||
DateTimeOffset? patchAvailableAt = null,
|
||||
DateTimeOffset? remediatedAt = null)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var timeline = new List<WindowTimelineEvent>();
|
||||
|
||||
if (disclosedAt.HasValue)
|
||||
|
||||
@@ -111,6 +111,7 @@ public sealed class ProofBundleWriterOptions
|
||||
public sealed class ProofBundleWriter : IProofBundleWriter
|
||||
{
|
||||
private readonly ProofBundleWriterOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
@@ -119,9 +120,10 @@ public sealed class ProofBundleWriter : IProofBundleWriter
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public ProofBundleWriter(ProofBundleWriterOptions? options = null)
|
||||
public ProofBundleWriter(TimeProvider? timeProvider = null, ProofBundleWriterOptions? options = null)
|
||||
{
|
||||
_options = options ?? new ProofBundleWriterOptions();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -134,7 +136,7 @@ public sealed class ProofBundleWriter : IProofBundleWriter
|
||||
ArgumentNullException.ThrowIfNull(ledger);
|
||||
|
||||
var rootHash = ledger.RootHash();
|
||||
var createdAt = DateTimeOffset.UtcNow;
|
||||
var createdAt = _timeProvider.GetUtcNow();
|
||||
|
||||
// Ensure storage directory exists
|
||||
Directory.CreateDirectory(_options.StorageBasePath);
|
||||
|
||||
@@ -77,11 +77,11 @@ public sealed record ManifestVerificationResult(
|
||||
string? ErrorMessage = null,
|
||||
string? KeyId = null)
|
||||
{
|
||||
public static ManifestVerificationResult Success(ScanManifest manifest, string? keyId = null) =>
|
||||
new(true, manifest, DateTimeOffset.UtcNow, null, keyId);
|
||||
public static ManifestVerificationResult Success(ScanManifest manifest, DateTimeOffset verifiedAt, string? keyId = null) =>
|
||||
new(true, manifest, verifiedAt, null, keyId);
|
||||
|
||||
public static ManifestVerificationResult Failure(string error) =>
|
||||
new(false, null, DateTimeOffset.UtcNow, error);
|
||||
public static ManifestVerificationResult Failure(DateTimeOffset verifiedAt, string error) =>
|
||||
new(false, null, verifiedAt, error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ISecretDetectionSettingsRepository.cs
|
||||
// Sprint: SPRINT_20260104_006_BE - Secret Detection Configuration API
|
||||
// Task: SDC-004 - Add persistence interface
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Scanner.Core.Secrets.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for secret detection settings persistence.
|
||||
/// </summary>
|
||||
public interface ISecretDetectionSettingsRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets settings for a tenant.
|
||||
/// </summary>
|
||||
Task<SecretDetectionSettings?> GetByTenantIdAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates settings for a tenant.
|
||||
/// </summary>
|
||||
Task<SecretDetectionSettings> UpsertAsync(
|
||||
SecretDetectionSettings settings,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds an exception pattern for a tenant.
|
||||
/// </summary>
|
||||
Task<SecretExceptionPattern> AddExceptionAsync(
|
||||
Guid tenantId,
|
||||
SecretExceptionPattern exception,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an exception pattern.
|
||||
/// </summary>
|
||||
Task<SecretExceptionPattern?> UpdateExceptionAsync(
|
||||
Guid tenantId,
|
||||
SecretExceptionPattern exception,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes an exception pattern.
|
||||
/// </summary>
|
||||
Task<bool> RemoveExceptionAsync(
|
||||
Guid tenantId,
|
||||
Guid exceptionId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all exceptions for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SecretExceptionPattern>> GetExceptionsAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets active (non-expired) exceptions for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SecretExceptionPattern>> GetActiveExceptionsAsync(
|
||||
Guid tenantId,
|
||||
DateTimeOffset asOf,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds an alert destination for a tenant.
|
||||
/// </summary>
|
||||
Task<SecretAlertDestination> AddAlertDestinationAsync(
|
||||
Guid tenantId,
|
||||
SecretAlertDestination destination,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an alert destination.
|
||||
/// </summary>
|
||||
Task<SecretAlertDestination?> UpdateAlertDestinationAsync(
|
||||
Guid tenantId,
|
||||
SecretAlertDestination destination,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes an alert destination.
|
||||
/// </summary>
|
||||
Task<bool> RemoveAlertDestinationAsync(
|
||||
Guid tenantId,
|
||||
Guid destinationId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the last test result for an alert destination.
|
||||
/// </summary>
|
||||
Task UpdateAlertDestinationTestResultAsync(
|
||||
Guid tenantId,
|
||||
Guid destinationId,
|
||||
AlertDestinationTestResult testResult,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SecretAlertSettings.cs
|
||||
// Sprint: SPRINT_20260104_006_BE - Secret Detection Configuration API
|
||||
// Sprint: SPRINT_20260104_007_BE - Secret Detection Alert Integration
|
||||
// Task: SDC-001, SDA-001 - Define alert settings models
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using StellaOps.Scanner.Analyzers.Secrets;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Secrets.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Alert configuration for secret detection findings.
|
||||
/// </summary>
|
||||
public sealed record SecretAlertSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable/disable alerting for this tenant.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum severity to trigger alert.
|
||||
/// </summary>
|
||||
public SecretSeverity MinimumAlertSeverity { get; init; } = SecretSeverity.High;
|
||||
|
||||
/// <summary>
|
||||
/// Alert destinations by channel type.
|
||||
/// </summary>
|
||||
public ImmutableArray<SecretAlertDestination> Destinations { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Rate limit: max alerts per scan.
|
||||
/// </summary>
|
||||
[Range(1, 1000)]
|
||||
public int MaxAlertsPerScan { get; init; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Rate limit: max alerts per hour per tenant.
|
||||
/// </summary>
|
||||
[Range(1, 10000)]
|
||||
public int MaxAlertsPerHour { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Deduplication window: don't re-alert same secret within this period.
|
||||
/// </summary>
|
||||
public TimeSpan DeduplicationWindow { get; init; } = TimeSpan.FromHours(24);
|
||||
|
||||
/// <summary>
|
||||
/// Include file path in alert (may reveal repo structure).
|
||||
/// </summary>
|
||||
public bool IncludeFilePath { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Include masked secret value in alert.
|
||||
/// </summary>
|
||||
public bool IncludeMaskedValue { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Include line number in alert.
|
||||
/// </summary>
|
||||
public bool IncludeLineNumber { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Group similar findings into a single alert.
|
||||
/// </summary>
|
||||
public bool GroupSimilarFindings { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum findings to group in a single alert.
|
||||
/// </summary>
|
||||
[Range(1, 100)]
|
||||
public int MaxFindingsPerGroupedAlert { get; init; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Default alert settings.
|
||||
/// </summary>
|
||||
public static readonly SecretAlertSettings Default = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alert destination configuration.
|
||||
/// </summary>
|
||||
public sealed record SecretAlertDestination
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this destination.
|
||||
/// </summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable name for this destination.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(200, MinimumLength = 1)]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of alert channel.
|
||||
/// </summary>
|
||||
public required AlertChannelType ChannelType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Channel identifier (Slack channel ID, email, webhook URL, etc.).
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(1000, MinimumLength = 1)]
|
||||
public required string ChannelId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional severity filter for this destination.
|
||||
/// </summary>
|
||||
public ImmutableArray<SecretSeverity>? SeverityFilter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional rule category filter for this destination.
|
||||
/// </summary>
|
||||
public ImmutableArray<string>? RuleCategoryFilter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this destination is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When this destination was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this destination was last tested.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LastTestedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Result of the last test.
|
||||
/// </summary>
|
||||
public AlertDestinationTestResult? LastTestResult { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of alert channel.
|
||||
/// </summary>
|
||||
public enum AlertChannelType
|
||||
{
|
||||
/// <summary>
|
||||
/// Slack channel or DM.
|
||||
/// </summary>
|
||||
Slack = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Microsoft Teams channel.
|
||||
/// </summary>
|
||||
Teams = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Email address.
|
||||
/// </summary>
|
||||
Email = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Generic webhook URL.
|
||||
/// </summary>
|
||||
Webhook = 3,
|
||||
|
||||
/// <summary>
|
||||
/// PagerDuty service.
|
||||
/// </summary>
|
||||
PagerDuty = 4,
|
||||
|
||||
/// <summary>
|
||||
/// Opsgenie service.
|
||||
/// </summary>
|
||||
Opsgenie = 5,
|
||||
|
||||
/// <summary>
|
||||
/// Discord webhook.
|
||||
/// </summary>
|
||||
Discord = 6
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of testing an alert destination.
|
||||
/// </summary>
|
||||
public sealed record AlertDestinationTestResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the test was successful.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the test was performed.
|
||||
/// </summary>
|
||||
public required DateTimeOffset TestedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if the test failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Response time in milliseconds.
|
||||
/// </summary>
|
||||
public int? ResponseTimeMs { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SecretDetectionSettings.cs
|
||||
// Sprint: SPRINT_20260104_006_BE - Secret Detection Configuration API
|
||||
// Task: SDC-001 - Define SecretDetectionSettings domain model
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Secrets.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Per-tenant settings for secret leak detection.
|
||||
/// </summary>
|
||||
public sealed record SecretDetectionSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for the tenant.
|
||||
/// </summary>
|
||||
public required Guid TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether secret detection is enabled for this tenant.
|
||||
/// </summary>
|
||||
public required bool Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy controlling how detected secrets are revealed/masked.
|
||||
/// </summary>
|
||||
public required SecretRevelationPolicy RevelationPolicy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for revelation policy behavior.
|
||||
/// </summary>
|
||||
public required RevelationPolicyConfig RevelationConfig { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Categories of rules that are enabled for scanning.
|
||||
/// </summary>
|
||||
public required ImmutableArray<string> EnabledRuleCategories { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Exception patterns for allowlisting known false positives.
|
||||
/// </summary>
|
||||
public required ImmutableArray<SecretExceptionPattern> Exceptions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Alert configuration for this tenant.
|
||||
/// </summary>
|
||||
public required SecretAlertSettings AlertSettings { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When these settings were last updated.
|
||||
/// </summary>
|
||||
public required DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Identity of the user who last updated settings.
|
||||
/// </summary>
|
||||
public required string UpdatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates default settings for a new tenant.
|
||||
/// </summary>
|
||||
public static SecretDetectionSettings CreateDefault(
|
||||
Guid tenantId,
|
||||
TimeProvider timeProvider,
|
||||
string createdBy = "system")
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
|
||||
return new SecretDetectionSettings
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Enabled = false, // Opt-in by default
|
||||
RevelationPolicy = SecretRevelationPolicy.PartialReveal,
|
||||
RevelationConfig = RevelationPolicyConfig.Default,
|
||||
EnabledRuleCategories = DefaultRuleCategories,
|
||||
Exceptions = [],
|
||||
AlertSettings = SecretAlertSettings.Default,
|
||||
UpdatedAt = timeProvider.GetUtcNow(),
|
||||
UpdatedBy = createdBy
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default rule categories for new tenants.
|
||||
/// </summary>
|
||||
public static readonly ImmutableArray<string> DefaultRuleCategories =
|
||||
[
|
||||
"cloud-credentials",
|
||||
"api-keys",
|
||||
"private-keys",
|
||||
"tokens",
|
||||
"passwords"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// All available rule categories.
|
||||
/// </summary>
|
||||
public static readonly ImmutableArray<string> AllRuleCategories =
|
||||
[
|
||||
"cloud-credentials",
|
||||
"api-keys",
|
||||
"private-keys",
|
||||
"tokens",
|
||||
"passwords",
|
||||
"certificates",
|
||||
"database-credentials",
|
||||
"messaging-credentials",
|
||||
"oauth-secrets",
|
||||
"generic-secrets"
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Controls how detected secrets appear in different contexts.
|
||||
/// </summary>
|
||||
public enum SecretRevelationPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Show only that a secret was detected, no value shown.
|
||||
/// Example: [SECRET_DETECTED: aws_access_key_id]
|
||||
/// </summary>
|
||||
FullMask = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Show first and last characters.
|
||||
/// Example: AKIA****WXYZ
|
||||
/// </summary>
|
||||
PartialReveal = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Show full value (requires elevated permissions).
|
||||
/// Use only for debugging/incident response.
|
||||
/// </summary>
|
||||
FullReveal = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detailed configuration for revelation policy behavior.
|
||||
/// </summary>
|
||||
public sealed record RevelationPolicyConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Default policy for UI/API responses.
|
||||
/// </summary>
|
||||
public SecretRevelationPolicy DefaultPolicy { get; init; } = SecretRevelationPolicy.PartialReveal;
|
||||
|
||||
/// <summary>
|
||||
/// Policy for exported reports (PDF, JSON).
|
||||
/// </summary>
|
||||
public SecretRevelationPolicy ExportPolicy { get; init; } = SecretRevelationPolicy.FullMask;
|
||||
|
||||
/// <summary>
|
||||
/// Policy for logs and telemetry.
|
||||
/// </summary>
|
||||
public SecretRevelationPolicy LogPolicy { get; init; } = SecretRevelationPolicy.FullMask;
|
||||
|
||||
/// <summary>
|
||||
/// Roles allowed to use FullReveal.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> FullRevealRoles { get; init; } =
|
||||
["security-admin", "incident-responder"];
|
||||
|
||||
/// <summary>
|
||||
/// Number of characters to show at start for PartialReveal.
|
||||
/// </summary>
|
||||
[Range(0, 8)]
|
||||
public int PartialRevealPrefixChars { get; init; } = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Number of characters to show at end for PartialReveal.
|
||||
/// </summary>
|
||||
[Range(0, 8)]
|
||||
public int PartialRevealSuffixChars { get; init; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Default configuration.
|
||||
/// </summary>
|
||||
public static readonly RevelationPolicyConfig Default = new();
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SecretExceptionPattern.cs
|
||||
// Sprint: SPRINT_20260104_006_BE - Secret Detection Configuration API
|
||||
// Task: SDC-003 - Create SecretExceptionPattern model for allowlists
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Secrets.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Pattern for allowlisting known false positives in secret detection.
|
||||
/// </summary>
|
||||
public sealed record SecretExceptionPattern
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this exception.
|
||||
/// </summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable name for this exception.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(200, MinimumLength = 1)]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of why this exception exists.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(2000, MinimumLength = 1)]
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Regex pattern to match against detected secret value.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(1000, MinimumLength = 1)]
|
||||
public required string Pattern { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of pattern matching to use.
|
||||
/// </summary>
|
||||
public SecretExceptionMatchType MatchType { get; init; } = SecretExceptionMatchType.Regex;
|
||||
|
||||
/// <summary>
|
||||
/// Optional: Only apply to specific rule IDs (glob patterns supported).
|
||||
/// </summary>
|
||||
public ImmutableArray<string>? ApplicableRuleIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional: Only apply to specific file paths (glob pattern).
|
||||
/// </summary>
|
||||
[StringLength(500)]
|
||||
public string? FilePathGlob { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Justification for this exception (audit trail).
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(2000, MinimumLength = 10)]
|
||||
public required string Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expiration date (null = permanent).
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this exception was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Identity of the user who created this exception.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(200)]
|
||||
public required string CreatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this exception was last modified.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ModifiedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Identity of the user who last modified this exception.
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? ModifiedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this exception is currently active.
|
||||
/// </summary>
|
||||
public bool IsActive { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the pattern and returns any errors.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Validate()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Pattern))
|
||||
{
|
||||
errors.Add("Pattern cannot be empty");
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (MatchType == SecretExceptionMatchType.Regex)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = new Regex(Pattern, RegexOptions.Compiled, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
errors.Add($"Invalid regex pattern: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (ExpiresAt.HasValue && ExpiresAt.Value < CreatedAt)
|
||||
{
|
||||
errors.Add("ExpiresAt cannot be before CreatedAt");
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if this exception matches a detected secret.
|
||||
/// </summary>
|
||||
/// <param name="maskedValue">The masked secret value</param>
|
||||
/// <param name="ruleId">The rule ID that detected the secret</param>
|
||||
/// <param name="filePath">The file path where the secret was found</param>
|
||||
/// <param name="now">Current time for expiration check</param>
|
||||
/// <returns>True if this exception applies</returns>
|
||||
public bool Matches(string maskedValue, string ruleId, string filePath, DateTimeOffset now)
|
||||
{
|
||||
// Check if active
|
||||
if (!IsActive)
|
||||
return false;
|
||||
|
||||
// Check expiration
|
||||
if (ExpiresAt.HasValue && now > ExpiresAt.Value)
|
||||
return false;
|
||||
|
||||
// Check rule ID filter
|
||||
if (ApplicableRuleIds is { Length: > 0 })
|
||||
{
|
||||
var matchesRule = ApplicableRuleIds.Any(pattern =>
|
||||
MatchesGlobPattern(ruleId, pattern));
|
||||
if (!matchesRule)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check file path filter
|
||||
if (!string.IsNullOrEmpty(FilePathGlob))
|
||||
{
|
||||
if (!MatchesGlobPattern(filePath, FilePathGlob))
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check value pattern
|
||||
return MatchType switch
|
||||
{
|
||||
SecretExceptionMatchType.Exact => maskedValue.Equals(Pattern, StringComparison.Ordinal),
|
||||
SecretExceptionMatchType.Contains => maskedValue.Contains(Pattern, StringComparison.Ordinal),
|
||||
SecretExceptionMatchType.Regex => MatchesRegex(maskedValue, Pattern),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private static bool MatchesRegex(string value, string pattern)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Regex.IsMatch(value, pattern, RegexOptions.None, TimeSpan.FromMilliseconds(100));
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool MatchesGlobPattern(string value, string pattern)
|
||||
{
|
||||
if (string.IsNullOrEmpty(pattern))
|
||||
return true;
|
||||
|
||||
// Simple glob matching: * matches any sequence, ? matches single char
|
||||
var regexPattern = "^" + Regex.Escape(pattern)
|
||||
.Replace("\\*", ".*")
|
||||
.Replace("\\?", ".") + "$";
|
||||
|
||||
try
|
||||
{
|
||||
return Regex.IsMatch(value, regexPattern, RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(100));
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of pattern matching for secret exceptions.
|
||||
/// </summary>
|
||||
public enum SecretExceptionMatchType
|
||||
{
|
||||
/// <summary>
|
||||
/// Exact string match.
|
||||
/// </summary>
|
||||
Exact = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Substring contains match.
|
||||
/// </summary>
|
||||
Contains = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Regular expression match.
|
||||
/// </summary>
|
||||
Regex = 2
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SecretRevelationService.cs
|
||||
// Sprint: SPRINT_20260104_006_BE - Secret Detection Configuration API
|
||||
// Task: SDC-008 - Implement revelation policy in findings output
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Secrets.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Service for applying revelation policies to secret findings.
|
||||
/// </summary>
|
||||
public interface ISecretRevelationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Applies revelation policy to a secret value.
|
||||
/// </summary>
|
||||
/// <param name="rawValue">The raw secret value</param>
|
||||
/// <param name="context">The revelation context</param>
|
||||
/// <returns>Masked/revealed value according to policy</returns>
|
||||
string ApplyPolicy(ReadOnlySpan<char> rawValue, RevelationContext context);
|
||||
|
||||
/// <summary>
|
||||
/// Determines the effective revelation policy for a context.
|
||||
/// </summary>
|
||||
RevelationResult GetEffectivePolicy(RevelationContext context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context for revelation policy decisions.
|
||||
/// </summary>
|
||||
public sealed record RevelationContext
|
||||
{
|
||||
/// <summary>
|
||||
/// The tenant's revelation policy configuration.
|
||||
/// </summary>
|
||||
public required RevelationPolicyConfig PolicyConfig { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The output context (UI, Export, Log).
|
||||
/// </summary>
|
||||
public required RevelationOutputContext OutputContext { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The current user's claims (for role-based revelation).
|
||||
/// </summary>
|
||||
public ClaimsPrincipal? User { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule ID that detected the secret (for rule-specific policies).
|
||||
/// </summary>
|
||||
public string? RuleId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Output context for revelation policy.
|
||||
/// </summary>
|
||||
public enum RevelationOutputContext
|
||||
{
|
||||
/// <summary>
|
||||
/// UI/API response.
|
||||
/// </summary>
|
||||
Ui = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Exported report (PDF, JSON, etc.).
|
||||
/// </summary>
|
||||
Export = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Logs and telemetry.
|
||||
/// </summary>
|
||||
Log = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of revelation policy evaluation.
|
||||
/// </summary>
|
||||
public sealed record RevelationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The effective policy to apply.
|
||||
/// </summary>
|
||||
public required SecretRevelationPolicy Policy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for the policy decision.
|
||||
/// </summary>
|
||||
public required string Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether full reveal was requested but denied.
|
||||
/// </summary>
|
||||
public bool FullRevealDenied { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of the revelation service.
|
||||
/// </summary>
|
||||
public sealed class SecretRevelationService : ISecretRevelationService
|
||||
{
|
||||
private const char MaskChar = '*';
|
||||
private const int MinMaskedLength = 8;
|
||||
private const int MaxMaskLength = 16;
|
||||
|
||||
public string ApplyPolicy(ReadOnlySpan<char> rawValue, RevelationContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var result = GetEffectivePolicy(context);
|
||||
|
||||
return result.Policy switch
|
||||
{
|
||||
SecretRevelationPolicy.FullMask => ApplyFullMask(rawValue, context.RuleId),
|
||||
SecretRevelationPolicy.PartialReveal => ApplyPartialReveal(rawValue, context.PolicyConfig),
|
||||
SecretRevelationPolicy.FullReveal => rawValue.ToString(),
|
||||
_ => ApplyFullMask(rawValue, context.RuleId)
|
||||
};
|
||||
}
|
||||
|
||||
public RevelationResult GetEffectivePolicy(RevelationContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var config = context.PolicyConfig;
|
||||
|
||||
// Determine base policy from output context
|
||||
var basePolicy = context.OutputContext switch
|
||||
{
|
||||
RevelationOutputContext.Ui => config.DefaultPolicy,
|
||||
RevelationOutputContext.Export => config.ExportPolicy,
|
||||
RevelationOutputContext.Log => config.LogPolicy,
|
||||
_ => SecretRevelationPolicy.FullMask
|
||||
};
|
||||
|
||||
// Check if full reveal is allowed for this user
|
||||
if (basePolicy == SecretRevelationPolicy.FullReveal)
|
||||
{
|
||||
if (!CanFullReveal(context))
|
||||
{
|
||||
return new RevelationResult
|
||||
{
|
||||
Policy = SecretRevelationPolicy.PartialReveal,
|
||||
Reason = "User does not have full reveal permission",
|
||||
FullRevealDenied = true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return new RevelationResult
|
||||
{
|
||||
Policy = basePolicy,
|
||||
Reason = $"Policy from {context.OutputContext} context",
|
||||
FullRevealDenied = false
|
||||
};
|
||||
}
|
||||
|
||||
private static bool CanFullReveal(RevelationContext context)
|
||||
{
|
||||
if (context.User is null)
|
||||
return false;
|
||||
|
||||
var allowedRoles = context.PolicyConfig.FullRevealRoles;
|
||||
if (allowedRoles.IsDefault || allowedRoles.Length == 0)
|
||||
return false;
|
||||
|
||||
return allowedRoles.Any(role => context.User.IsInRole(role));
|
||||
}
|
||||
|
||||
private static string ApplyFullMask(ReadOnlySpan<char> rawValue, string? ruleId)
|
||||
{
|
||||
var ruleHint = string.IsNullOrEmpty(ruleId) ? "secret" : ruleId.Split('.').LastOrDefault() ?? "secret";
|
||||
return $"[SECRET_DETECTED: {ruleHint}]";
|
||||
}
|
||||
|
||||
private static string ApplyPartialReveal(ReadOnlySpan<char> rawValue, RevelationPolicyConfig config)
|
||||
{
|
||||
if (rawValue.Length == 0)
|
||||
return "[EMPTY]";
|
||||
|
||||
var prefixLen = Math.Min(config.PartialRevealPrefixChars, rawValue.Length / 3);
|
||||
var suffixLen = Math.Min(config.PartialRevealSuffixChars, rawValue.Length / 3);
|
||||
|
||||
// Ensure we don't reveal too much
|
||||
var revealedTotal = prefixLen + suffixLen;
|
||||
if (revealedTotal > 6 || revealedTotal > rawValue.Length / 2)
|
||||
{
|
||||
// Fall back to safer reveal
|
||||
prefixLen = Math.Min(2, rawValue.Length / 4);
|
||||
suffixLen = Math.Min(2, rawValue.Length / 4);
|
||||
}
|
||||
|
||||
var maskLen = Math.Min(MaxMaskLength, rawValue.Length - prefixLen - suffixLen);
|
||||
maskLen = Math.Max(4, maskLen); // At least 4 asterisks
|
||||
|
||||
var sb = new StringBuilder(prefixLen + maskLen + suffixLen);
|
||||
|
||||
// Prefix
|
||||
if (prefixLen > 0)
|
||||
{
|
||||
sb.Append(rawValue[..prefixLen]);
|
||||
}
|
||||
|
||||
// Mask
|
||||
sb.Append(MaskChar, maskLen);
|
||||
|
||||
// Suffix
|
||||
if (suffixLen > 0)
|
||||
{
|
||||
sb.Append(rawValue[^suffixLen..]);
|
||||
}
|
||||
|
||||
// Ensure minimum length
|
||||
if (sb.Length < MinMaskedLength)
|
||||
{
|
||||
return $"[SECRET: {sb.Length} chars]";
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ public sealed class SurfaceEnvironmentBuilder
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<SurfaceEnvironmentBuilder> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly SurfaceEnvironmentOptions _options;
|
||||
private readonly Dictionary<string, string> _raw = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -24,11 +25,13 @@ public sealed class SurfaceEnvironmentBuilder
|
||||
IServiceProvider services,
|
||||
IConfiguration configuration,
|
||||
ILogger<SurfaceEnvironmentBuilder> logger,
|
||||
TimeProvider timeProvider,
|
||||
SurfaceEnvironmentOptions options)
|
||||
{
|
||||
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
|
||||
if (_options.Prefixes.Count == 0)
|
||||
@@ -62,7 +65,7 @@ public sealed class SurfaceEnvironmentBuilder
|
||||
tenant,
|
||||
tls);
|
||||
|
||||
return settings with { CreatedAtUtc = DateTimeOffset.UtcNow };
|
||||
return settings with { CreatedAtUtc = _timeProvider.GetUtcNow() };
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, string> GetRawVariables()
|
||||
|
||||
@@ -31,6 +31,7 @@ public sealed class VulnSurfaceBuilder : IVulnSurfaceBuilder
|
||||
private readonly IMethodDiffEngine _diffEngine;
|
||||
private readonly ITriggerMethodExtractor _triggerExtractor;
|
||||
private readonly IEnumerable<IInternalCallGraphBuilder> _graphBuilders;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<VulnSurfaceBuilder> _logger;
|
||||
|
||||
public VulnSurfaceBuilder(
|
||||
@@ -39,6 +40,7 @@ public sealed class VulnSurfaceBuilder : IVulnSurfaceBuilder
|
||||
IMethodDiffEngine diffEngine,
|
||||
ITriggerMethodExtractor triggerExtractor,
|
||||
IEnumerable<IInternalCallGraphBuilder> graphBuilders,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<VulnSurfaceBuilder> logger)
|
||||
{
|
||||
_downloaders = downloaders ?? throw new ArgumentNullException(nameof(downloaders));
|
||||
@@ -46,6 +48,7 @@ public sealed class VulnSurfaceBuilder : IVulnSurfaceBuilder
|
||||
_diffEngine = diffEngine ?? throw new ArgumentNullException(nameof(diffEngine));
|
||||
_triggerExtractor = triggerExtractor ?? throw new ArgumentNullException(nameof(triggerExtractor));
|
||||
_graphBuilders = graphBuilders ?? throw new ArgumentNullException(nameof(graphBuilders));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
@@ -239,7 +242,7 @@ public sealed class VulnSurfaceBuilder : IVulnSurfaceBuilder
|
||||
TriggerCount = triggerCount,
|
||||
Status = VulnSurfaceStatus.Computed,
|
||||
Confidence = ComputeConfidence(diff, sinks.Count),
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
ComputedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
sw.Stop();
|
||||
|
||||
Reference in New Issue
Block a user