505 lines
20 KiB
C#
505 lines
20 KiB
C#
// -----------------------------------------------------------------------------
|
|
// 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;
|
|
|
|
/// <summary>
|
|
/// Service interface for secret detection settings.
|
|
/// </summary>
|
|
public interface ISecretDetectionSettingsService
|
|
{
|
|
/// <summary>Gets settings for a tenant.</summary>
|
|
Task<SecretDetectionSettingsResponseDto?> GetSettingsAsync(
|
|
Guid tenantId,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>Creates default settings for a tenant.</summary>
|
|
Task<SecretDetectionSettingsResponseDto> CreateSettingsAsync(
|
|
Guid tenantId,
|
|
string createdBy,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>Updates settings with optimistic concurrency.</summary>
|
|
Task<(bool Success, SecretDetectionSettingsResponseDto? Settings, string? Error)> UpdateSettingsAsync(
|
|
Guid tenantId,
|
|
SecretDetectionSettingsDto settings,
|
|
int expectedVersion,
|
|
string updatedBy,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>Gets available rule categories.</summary>
|
|
Task<RuleCategoriesResponseDto> GetRuleCategoriesAsync(CancellationToken cancellationToken = default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Service interface for secret exception patterns.
|
|
/// </summary>
|
|
public interface ISecretExceptionPatternService
|
|
{
|
|
/// <summary>Gets all exception patterns for a tenant.</summary>
|
|
Task<SecretExceptionPatternListResponseDto> GetPatternsAsync(
|
|
Guid tenantId,
|
|
bool includeInactive = false,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>Gets a specific pattern by ID.</summary>
|
|
Task<SecretExceptionPatternResponseDto?> GetPatternAsync(
|
|
Guid tenantId,
|
|
Guid patternId,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>Creates a new exception pattern.</summary>
|
|
Task<(SecretExceptionPatternResponseDto? Pattern, IReadOnlyList<string> Errors)> CreatePatternAsync(
|
|
Guid tenantId,
|
|
SecretExceptionPatternDto pattern,
|
|
string createdBy,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>Updates an exception pattern.</summary>
|
|
Task<(bool Success, SecretExceptionPatternResponseDto? Pattern, IReadOnlyList<string> Errors)> UpdatePatternAsync(
|
|
Guid tenantId,
|
|
Guid patternId,
|
|
SecretExceptionPatternDto pattern,
|
|
string updatedBy,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>Deletes an exception pattern.</summary>
|
|
Task<bool> DeletePatternAsync(
|
|
Guid tenantId,
|
|
Guid patternId,
|
|
CancellationToken cancellationToken = default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Implementation of secret detection settings service.
|
|
/// </summary>
|
|
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<SecretDetectionSettingsResponseDto?> 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<SecretDetectionSettingsResponseDto> 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<RuleCategoriesResponseDto> GetRuleCategoriesAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
var categories = new List<RuleCategoryDto>
|
|
{
|
|
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<string> ValidateSettings(SecretDetectionSettingsDto settings)
|
|
{
|
|
var errors = new List<string>();
|
|
|
|
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<RevelationPolicyDto>(row.RevelationPolicy, JsonOptions)
|
|
?? new RevelationPolicyDto
|
|
{
|
|
DefaultPolicy = SecretRevelationPolicyType.PartialReveal,
|
|
ExportPolicy = SecretRevelationPolicyType.FullMask,
|
|
PartialRevealChars = 4,
|
|
MaxMaskChars = 8,
|
|
FullRevealRoles = []
|
|
};
|
|
|
|
var alertSettings = JsonSerializer.Deserialize<SecretAlertSettingsDto>(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
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Implementation of secret exception pattern service.
|
|
/// </summary>
|
|
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<SecretExceptionPatternListResponseDto> 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<SecretExceptionPatternResponseDto?> 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<string> 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<string> 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<bool> DeletePatternAsync(
|
|
Guid tenantId,
|
|
Guid patternId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return await _repository.DeleteAsync(tenantId, patternId, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
private static IReadOnlyList<string> ValidatePattern(SecretExceptionPatternDto pattern)
|
|
{
|
|
var errors = new List<string>();
|
|
|
|
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
|
|
};
|
|
}
|
|
}
|