warnings fixes, tests fixes, sprints completions

This commit is contained in:
Codex Assistant
2026-01-08 08:38:27 +02:00
parent 75611a505f
commit 0b5d786ddb
125 changed files with 14610 additions and 368 deletions

View File

@@ -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");
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)

View File

@@ -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);

View File

@@ -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>

View File

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

View File

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

View File

@@ -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();
}

View File

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

View File

@@ -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();
}
}

View File

@@ -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()

View File

@@ -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();