// ----------------------------------------------------------------------------- // SecretDetectionSettingsService.cs // Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API) // Task: SDC-005 - Create Settings CRUD API endpoints // Description: Service layer for secret detection configuration. // ----------------------------------------------------------------------------- using StellaOps.Scanner.Core.Secrets.Configuration; using StellaOps.Scanner.Storage.Entities; using StellaOps.Scanner.Storage.Repositories; using StellaOps.Scanner.WebService.Contracts; using System.Globalization; using System.Text.Json; namespace StellaOps.Scanner.WebService.Services; /// /// Service interface for secret detection settings. /// public interface ISecretDetectionSettingsService { /// Gets settings for a tenant. Task GetSettingsAsync( Guid tenantId, CancellationToken cancellationToken = default); /// Creates default settings for a tenant. Task CreateSettingsAsync( Guid tenantId, string createdBy, CancellationToken cancellationToken = default); /// Updates settings with optimistic concurrency. Task<(bool Success, SecretDetectionSettingsResponseDto? Settings, string? Error)> UpdateSettingsAsync( Guid tenantId, SecretDetectionSettingsDto settings, int expectedVersion, string updatedBy, CancellationToken cancellationToken = default); /// Gets available rule categories. Task GetRuleCategoriesAsync(CancellationToken cancellationToken = default); } /// /// Service interface for secret exception patterns. /// public interface ISecretExceptionPatternService { /// Gets all exception patterns for a tenant. Task GetPatternsAsync( Guid tenantId, bool includeInactive = false, CancellationToken cancellationToken = default); /// Gets a specific pattern by ID. Task GetPatternAsync( Guid tenantId, Guid patternId, CancellationToken cancellationToken = default); /// Creates a new exception pattern. Task<(SecretExceptionPatternResponseDto? Pattern, IReadOnlyList Errors)> CreatePatternAsync( Guid tenantId, SecretExceptionPatternDto pattern, string createdBy, CancellationToken cancellationToken = default); /// Updates an exception pattern. Task<(bool Success, SecretExceptionPatternResponseDto? Pattern, IReadOnlyList Errors)> UpdatePatternAsync( Guid tenantId, Guid patternId, SecretExceptionPatternDto pattern, string updatedBy, CancellationToken cancellationToken = default); /// Deletes an exception pattern. Task DeletePatternAsync( Guid tenantId, Guid patternId, CancellationToken cancellationToken = default); } /// /// Implementation of secret detection settings service. /// public sealed class SecretDetectionSettingsService : ISecretDetectionSettingsService { private readonly Storage.Repositories.ISecretDetectionSettingsRepository _repository; private readonly TimeProvider _timeProvider; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; public SecretDetectionSettingsService( Storage.Repositories.ISecretDetectionSettingsRepository repository, TimeProvider timeProvider) { _repository = repository ?? throw new ArgumentNullException(nameof(repository)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); } public async Task GetSettingsAsync( Guid tenantId, CancellationToken cancellationToken = default) { var row = await _repository.GetByTenantAsync(tenantId, cancellationToken).ConfigureAwait(false); return row is null ? null : MapToDto(row); } public async Task CreateSettingsAsync( Guid tenantId, string createdBy, CancellationToken cancellationToken = default) { var defaultSettings = SecretDetectionSettings.CreateDefault(tenantId, createdBy); var row = MapToRow(defaultSettings, tenantId, createdBy); var created = await _repository.CreateAsync(row, cancellationToken).ConfigureAwait(false); return MapToDto(created); } public async Task<(bool Success, SecretDetectionSettingsResponseDto? Settings, string? Error)> UpdateSettingsAsync( Guid tenantId, SecretDetectionSettingsDto settings, int expectedVersion, string updatedBy, CancellationToken cancellationToken = default) { var existing = await _repository.GetByTenantAsync(tenantId, cancellationToken).ConfigureAwait(false); if (existing is null) { return (false, null, "Settings not found for tenant"); } // Validate settings var validationErrors = ValidateSettings(settings); if (validationErrors.Count > 0) { return (false, null, string.Join("; ", validationErrors)); } // Apply updates existing.Enabled = settings.Enabled; existing.RevelationPolicy = JsonSerializer.Serialize(settings.RevelationPolicy, JsonOptions); existing.EnabledRuleCategories = settings.EnabledRuleCategories.ToArray(); existing.DisabledRuleIds = settings.DisabledRuleIds.ToArray(); existing.AlertSettings = JsonSerializer.Serialize(settings.AlertSettings, JsonOptions); existing.MaxFileSizeBytes = settings.MaxFileSizeBytes; existing.ExcludedFileExtensions = settings.ExcludedFileExtensions.ToArray(); existing.ExcludedPaths = settings.ExcludedPaths.ToArray(); existing.ScanBinaryFiles = settings.ScanBinaryFiles; existing.RequireSignedRuleBundles = settings.RequireSignedRuleBundles; existing.UpdatedBy = updatedBy; var success = await _repository.UpdateAsync(existing, expectedVersion, cancellationToken).ConfigureAwait(false); if (!success) { return (false, null, "Version conflict - settings were modified by another request"); } // Fetch updated version var updated = await _repository.GetByTenantAsync(tenantId, cancellationToken).ConfigureAwait(false); return (true, updated is null ? null : MapToDto(updated), null); } public Task GetRuleCategoriesAsync(CancellationToken cancellationToken = default) { var categories = new List { new() { Id = SecretRuleCategories.Aws, Name = "AWS", Description = "Amazon Web Services credentials", RuleCount = 15 }, new() { Id = SecretRuleCategories.Gcp, Name = "GCP", Description = "Google Cloud Platform credentials", RuleCount = 12 }, new() { Id = SecretRuleCategories.Azure, Name = "Azure", Description = "Microsoft Azure credentials", RuleCount = 10 }, new() { Id = SecretRuleCategories.Generic, Name = "Generic", Description = "Generic secrets and passwords", RuleCount = 25 }, new() { Id = SecretRuleCategories.PrivateKeys, Name = "Private Keys", Description = "SSH, PGP, and other private keys", RuleCount = 8 }, new() { Id = SecretRuleCategories.Database, Name = "Database", Description = "Database connection strings and credentials", RuleCount = 18 }, new() { Id = SecretRuleCategories.Messaging, Name = "Messaging", Description = "Messaging platform credentials (Slack, Discord)", RuleCount = 6 }, new() { Id = SecretRuleCategories.Payment, Name = "Payment", Description = "Payment processor credentials (Stripe, PayPal)", RuleCount = 5 }, new() { Id = SecretRuleCategories.SocialMedia, Name = "Social Media", Description = "Social media API keys", RuleCount = 8 }, new() { Id = SecretRuleCategories.Internal, Name = "Internal", Description = "Custom internal secrets", RuleCount = 0 } }; return Task.FromResult(new RuleCategoriesResponseDto { Categories = categories }); } private static IReadOnlyList ValidateSettings(SecretDetectionSettingsDto settings) { var errors = new List(); if (settings.MaxFileSizeBytes < 1024) { errors.Add("MaxFileSizeBytes must be at least 1024 bytes"); } if (settings.MaxFileSizeBytes > 100 * 1024 * 1024) { errors.Add("MaxFileSizeBytes must be 100 MB or less"); } if (settings.RevelationPolicy.PartialRevealChars < 1 || settings.RevelationPolicy.PartialRevealChars > 10) { errors.Add("PartialRevealChars must be between 1 and 10"); } if (settings.AlertSettings.Enabled && settings.AlertSettings.Destinations.Count == 0) { errors.Add("At least one destination is required when alerting is enabled"); } if (settings.AlertSettings.MaxAlertsPerScan < 1 || settings.AlertSettings.MaxAlertsPerScan > 100) { errors.Add("MaxAlertsPerScan must be between 1 and 100"); } return errors; } private static SecretDetectionSettingsResponseDto MapToDto(SecretDetectionSettingsRow row) { var revelationPolicy = JsonSerializer.Deserialize(row.RevelationPolicy, JsonOptions) ?? new RevelationPolicyDto { DefaultPolicy = SecretRevelationPolicyType.PartialReveal, ExportPolicy = SecretRevelationPolicyType.FullMask, PartialRevealChars = 4, MaxMaskChars = 8, FullRevealRoles = [] }; var alertSettings = JsonSerializer.Deserialize(row.AlertSettings, JsonOptions) ?? new SecretAlertSettingsDto { Enabled = false, MinimumAlertSeverity = SecretSeverityType.High, Destinations = [], MaxAlertsPerScan = 10, DeduplicationWindowMinutes = 1440, IncludeFilePath = true, IncludeMaskedValue = true, IncludeImageRef = true }; return new SecretDetectionSettingsResponseDto { TenantId = row.TenantId, Settings = new SecretDetectionSettingsDto { Enabled = row.Enabled, RevelationPolicy = revelationPolicy, EnabledRuleCategories = row.EnabledRuleCategories, DisabledRuleIds = row.DisabledRuleIds, AlertSettings = alertSettings, MaxFileSizeBytes = row.MaxFileSizeBytes, ExcludedFileExtensions = row.ExcludedFileExtensions, ExcludedPaths = row.ExcludedPaths, ScanBinaryFiles = row.ScanBinaryFiles, RequireSignedRuleBundles = row.RequireSignedRuleBundles }, Version = row.Version, UpdatedAt = row.UpdatedAt, UpdatedBy = row.UpdatedBy }; } private static SecretDetectionSettingsRow MapToRow(SecretDetectionSettings settings, Guid tenantId, string updatedBy) { var revelationPolicyDto = new RevelationPolicyDto { DefaultPolicy = (SecretRevelationPolicyType)settings.RevelationPolicy.DefaultPolicy, ExportPolicy = (SecretRevelationPolicyType)settings.RevelationPolicy.ExportPolicy, PartialRevealChars = settings.RevelationPolicy.PartialRevealChars, MaxMaskChars = settings.RevelationPolicy.MaxMaskChars, FullRevealRoles = settings.RevelationPolicy.FullRevealRoles }; var alertSettingsDto = new SecretAlertSettingsDto { Enabled = settings.AlertSettings.Enabled, MinimumAlertSeverity = (SecretSeverityType)settings.AlertSettings.MinimumAlertSeverity, Destinations = settings.AlertSettings.Destinations.Select(d => new SecretAlertDestinationDto { Id = d.Id, Name = d.Name, ChannelType = (Contracts.AlertChannelType)d.ChannelType, ChannelId = d.ChannelId, SeverityFilter = d.SeverityFilter?.Select(s => (SecretSeverityType)s).ToList(), RuleCategoryFilter = d.RuleCategoryFilter?.ToList(), IsActive = d.IsActive }).ToList(), MaxAlertsPerScan = settings.AlertSettings.MaxAlertsPerScan, DeduplicationWindowMinutes = (int)settings.AlertSettings.DeduplicationWindow.TotalMinutes, IncludeFilePath = settings.AlertSettings.IncludeFilePath, IncludeMaskedValue = settings.AlertSettings.IncludeMaskedValue, IncludeImageRef = settings.AlertSettings.IncludeImageRef, AlertMessagePrefix = settings.AlertSettings.AlertMessagePrefix }; return new SecretDetectionSettingsRow { TenantId = tenantId, Enabled = settings.Enabled, RevelationPolicy = JsonSerializer.Serialize(revelationPolicyDto, JsonOptions), EnabledRuleCategories = settings.EnabledRuleCategories.ToArray(), DisabledRuleIds = settings.DisabledRuleIds.ToArray(), AlertSettings = JsonSerializer.Serialize(alertSettingsDto, JsonOptions), MaxFileSizeBytes = settings.MaxFileSizeBytes, ExcludedFileExtensions = settings.ExcludedFileExtensions.ToArray(), ExcludedPaths = settings.ExcludedPaths.ToArray(), ScanBinaryFiles = settings.ScanBinaryFiles, RequireSignedRuleBundles = settings.RequireSignedRuleBundles, UpdatedBy = updatedBy }; } } /// /// Implementation of secret exception pattern service. /// public sealed class SecretExceptionPatternService : ISecretExceptionPatternService { private readonly ISecretExceptionPatternRepository _repository; private readonly TimeProvider _timeProvider; public SecretExceptionPatternService( ISecretExceptionPatternRepository repository, TimeProvider timeProvider) { _repository = repository ?? throw new ArgumentNullException(nameof(repository)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); } public async Task GetPatternsAsync( Guid tenantId, bool includeInactive = false, CancellationToken cancellationToken = default) { var patterns = includeInactive ? await _repository.GetAllByTenantAsync(tenantId, cancellationToken).ConfigureAwait(false) : await _repository.GetActiveByTenantAsync(tenantId, cancellationToken).ConfigureAwait(false); return new SecretExceptionPatternListResponseDto { Patterns = patterns.Select(MapToDto).ToList(), TotalCount = patterns.Count }; } public async Task GetPatternAsync( Guid tenantId, Guid patternId, CancellationToken cancellationToken = default) { var pattern = await _repository.GetByIdAsync(tenantId, patternId, cancellationToken).ConfigureAwait(false); return pattern is null ? null : MapToDto(pattern); } public async Task<(SecretExceptionPatternResponseDto? Pattern, IReadOnlyList Errors)> CreatePatternAsync( Guid tenantId, SecretExceptionPatternDto pattern, string createdBy, CancellationToken cancellationToken = default) { var errors = ValidatePattern(pattern); if (errors.Count > 0) { return (null, errors); } var row = new SecretExceptionPatternRow { TenantId = tenantId, Name = pattern.Name, Description = pattern.Description, ValuePattern = pattern.ValuePattern, ApplicableRuleIds = pattern.ApplicableRuleIds.ToArray(), FilePathGlob = pattern.FilePathGlob, Justification = pattern.Justification, ExpiresAt = pattern.ExpiresAt, IsActive = pattern.IsActive, CreatedBy = createdBy }; var created = await _repository.CreateAsync(row, cancellationToken).ConfigureAwait(false); return (MapToDto(created), []); } public async Task<(bool Success, SecretExceptionPatternResponseDto? Pattern, IReadOnlyList Errors)> UpdatePatternAsync( Guid tenantId, Guid patternId, SecretExceptionPatternDto pattern, string updatedBy, CancellationToken cancellationToken = default) { var existing = await _repository.GetByIdAsync(tenantId, patternId, cancellationToken).ConfigureAwait(false); if (existing is null) { return (false, null, ["Pattern not found"]); } var errors = ValidatePattern(pattern); if (errors.Count > 0) { return (false, null, errors); } existing.Name = pattern.Name; existing.Description = pattern.Description; existing.ValuePattern = pattern.ValuePattern; existing.ApplicableRuleIds = pattern.ApplicableRuleIds.ToArray(); existing.FilePathGlob = pattern.FilePathGlob; existing.Justification = pattern.Justification; existing.ExpiresAt = pattern.ExpiresAt; existing.IsActive = pattern.IsActive; existing.UpdatedBy = updatedBy; existing.UpdatedAt = _timeProvider.GetUtcNow(); var success = await _repository.UpdateAsync(tenantId, existing, cancellationToken).ConfigureAwait(false); if (!success) { return (false, null, ["Failed to update pattern"]); } var updated = await _repository.GetByIdAsync(tenantId, patternId, cancellationToken).ConfigureAwait(false); return (true, updated is null ? null : MapToDto(updated), []); } public async Task DeletePatternAsync( Guid tenantId, Guid patternId, CancellationToken cancellationToken = default) { return await _repository.DeleteAsync(tenantId, patternId, cancellationToken).ConfigureAwait(false); } private static IReadOnlyList ValidatePattern(SecretExceptionPatternDto pattern) { var errors = new List(); if (string.IsNullOrWhiteSpace(pattern.Name)) { errors.Add("Name is required"); } else if (pattern.Name.Length > 100) { errors.Add("Name must be 100 characters or less"); } if (string.IsNullOrWhiteSpace(pattern.ValuePattern)) { errors.Add("ValuePattern is required"); } else { try { _ = new System.Text.RegularExpressions.Regex(pattern.ValuePattern); } catch (System.Text.RegularExpressions.RegexParseException ex) { errors.Add(string.Format(CultureInfo.InvariantCulture, "ValuePattern is not a valid regex: {0}", ex.Message)); } } if (string.IsNullOrWhiteSpace(pattern.Justification)) { errors.Add("Justification is required"); } else if (pattern.Justification.Length < 20) { errors.Add("Justification must be at least 20 characters"); } return errors; } private static SecretExceptionPatternResponseDto MapToDto(SecretExceptionPatternRow row) { return new SecretExceptionPatternResponseDto { Id = row.ExceptionId, TenantId = row.TenantId, Pattern = new SecretExceptionPatternDto { Name = row.Name, Description = row.Description, ValuePattern = row.ValuePattern, ApplicableRuleIds = row.ApplicableRuleIds, FilePathGlob = row.FilePathGlob, Justification = row.Justification, ExpiresAt = row.ExpiresAt, IsActive = row.IsActive }, MatchCount = row.MatchCount, LastMatchedAt = row.LastMatchedAt, CreatedAt = row.CreatedAt, CreatedBy = row.CreatedBy, UpdatedAt = row.UpdatedAt, UpdatedBy = row.UpdatedBy }; } }