finish secrets finding work and audit remarks work save

This commit is contained in:
StellaOps Bot
2026-01-04 21:48:13 +02:00
parent 75611a505f
commit 8862e112c4
157 changed files with 11702 additions and 416 deletions

View File

@@ -0,0 +1,497 @@
// -----------------------------------------------------------------------------
// 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 System.Globalization;
using System.Text.Json;
using StellaOps.Scanner.Core.Secrets.Configuration;
using StellaOps.Scanner.Storage.Entities;
using StellaOps.Scanner.Storage.Repositories;
using StellaOps.Scanner.WebService.Contracts;
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 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 patternId,
SecretExceptionPatternDto pattern,
string updatedBy,
CancellationToken cancellationToken = default);
/// <summary>Deletes an exception pattern.</summary>
Task<bool> DeletePatternAsync(
Guid patternId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Implementation of secret detection settings service.
/// </summary>
public sealed class SecretDetectionSettingsService : ISecretDetectionSettingsService
{
private readonly ISecretDetectionSettingsRepository _repository;
private readonly TimeProvider _timeProvider;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public SecretDetectionSettingsService(
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 patternId,
CancellationToken cancellationToken = default)
{
var pattern = await _repository.GetByIdAsync(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 patternId,
SecretExceptionPatternDto pattern,
string updatedBy,
CancellationToken cancellationToken = default)
{
var existing = await _repository.GetByIdAsync(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(existing, cancellationToken).ConfigureAwait(false);
if (!success)
{
return (false, null, ["Failed to update pattern"]);
}
var updated = await _repository.GetByIdAsync(patternId, cancellationToken).ConfigureAwait(false);
return (true, updated is null ? null : MapToDto(updated), []);
}
public async Task<bool> DeletePatternAsync(
Guid patternId,
CancellationToken cancellationToken = default)
{
return await _repository.DeleteAsync(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
};
}
}