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

@@ -12,8 +12,7 @@ public sealed record BunPackagesResponse
public string ImageDigest { get; init; } = string.Empty;
[JsonPropertyName("generatedAt")]
public DateTimeOffset GeneratedAt { get; init; }
= DateTimeOffset.UtcNow;
public required DateTimeOffset GeneratedAt { get; init; }
[JsonPropertyName("packages")]
public IReadOnlyList<BunPackageArtifact> Packages { get; init; }

View File

@@ -12,8 +12,7 @@ public sealed record RubyPackagesResponse
public string ImageDigest { get; init; } = string.Empty;
[JsonPropertyName("generatedAt")]
public DateTimeOffset GeneratedAt { get; init; }
= DateTimeOffset.UtcNow;
public required DateTimeOffset GeneratedAt { get; init; }
[JsonPropertyName("packages")]
public IReadOnlyList<RubyPackageArtifact> Packages { get; init; }

View File

@@ -0,0 +1,319 @@
// -----------------------------------------------------------------------------
// SecretDetectionConfigContracts.cs
// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
// Task: SDC-005 - Create Settings CRUD API endpoints
// Description: API contracts for secret detection configuration.
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.WebService.Contracts;
// ============================================================================
// Settings DTOs
// ============================================================================
/// <summary>
/// Request to get or update secret detection settings.
/// </summary>
public sealed record SecretDetectionSettingsDto
{
/// <summary>Whether secret detection is enabled.</summary>
public bool Enabled { get; init; }
/// <summary>Revelation policy configuration.</summary>
public required RevelationPolicyDto RevelationPolicy { get; init; }
/// <summary>Enabled rule categories.</summary>
public IReadOnlyList<string> EnabledRuleCategories { get; init; } = [];
/// <summary>Disabled rule IDs.</summary>
public IReadOnlyList<string> DisabledRuleIds { get; init; } = [];
/// <summary>Alert settings.</summary>
public required SecretAlertSettingsDto AlertSettings { get; init; }
/// <summary>Maximum file size to scan (bytes).</summary>
public long MaxFileSizeBytes { get; init; }
/// <summary>File extensions to exclude.</summary>
public IReadOnlyList<string> ExcludedFileExtensions { get; init; } = [];
/// <summary>Path patterns to exclude (glob).</summary>
public IReadOnlyList<string> ExcludedPaths { get; init; } = [];
/// <summary>Whether to scan binary files.</summary>
public bool ScanBinaryFiles { get; init; }
/// <summary>Whether to require signed rule bundles.</summary>
public bool RequireSignedRuleBundles { get; init; }
}
/// <summary>
/// Response containing settings with metadata.
/// </summary>
public sealed record SecretDetectionSettingsResponseDto
{
/// <summary>Tenant ID.</summary>
public Guid TenantId { get; init; }
/// <summary>Settings data.</summary>
public required SecretDetectionSettingsDto Settings { get; init; }
/// <summary>Version for optimistic concurrency.</summary>
public int Version { get; init; }
/// <summary>When settings were last updated.</summary>
public DateTimeOffset UpdatedAt { get; init; }
/// <summary>Who last updated settings.</summary>
public required string UpdatedBy { get; init; }
}
/// <summary>
/// Revelation policy configuration.
/// </summary>
public sealed record RevelationPolicyDto
{
/// <summary>Default masking policy.</summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public SecretRevelationPolicyType DefaultPolicy { get; init; }
/// <summary>Export masking policy.</summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public SecretRevelationPolicyType ExportPolicy { get; init; }
/// <summary>Roles allowed to see full secrets.</summary>
public IReadOnlyList<string> FullRevealRoles { get; init; } = [];
/// <summary>Characters to reveal at start/end for partial.</summary>
public int PartialRevealChars { get; init; }
/// <summary>Maximum mask characters.</summary>
public int MaxMaskChars { get; init; }
}
/// <summary>
/// Revelation policy types.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum SecretRevelationPolicyType
{
/// <summary>Fully masked (e.g., [REDACTED]).</summary>
FullMask = 0,
/// <summary>Partially revealed (e.g., AKIA****WXYZ).</summary>
PartialReveal = 1,
/// <summary>Full value shown (audit logged).</summary>
FullReveal = 2
}
/// <summary>
/// Alert settings configuration.
/// </summary>
public sealed record SecretAlertSettingsDto
{
/// <summary>Whether alerting is enabled.</summary>
public bool Enabled { get; init; }
/// <summary>Minimum severity to trigger alerts.</summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public SecretSeverityType MinimumAlertSeverity { get; init; }
/// <summary>Alert destinations.</summary>
public IReadOnlyList<SecretAlertDestinationDto> Destinations { get; init; } = [];
/// <summary>Maximum alerts per scan.</summary>
public int MaxAlertsPerScan { get; init; }
/// <summary>Deduplication window in minutes.</summary>
public int DeduplicationWindowMinutes { get; init; }
/// <summary>Include file path in alerts.</summary>
public bool IncludeFilePath { get; init; }
/// <summary>Include masked value in alerts.</summary>
public bool IncludeMaskedValue { get; init; }
/// <summary>Include image reference in alerts.</summary>
public bool IncludeImageRef { get; init; }
/// <summary>Custom alert message prefix.</summary>
public string? AlertMessagePrefix { get; init; }
}
/// <summary>
/// Secret severity levels.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum SecretSeverityType
{
Low = 0,
Medium = 1,
High = 2,
Critical = 3
}
/// <summary>
/// Alert channel types.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum AlertChannelType
{
Slack = 0,
Teams = 1,
Email = 2,
Webhook = 3,
PagerDuty = 4
}
/// <summary>
/// Alert destination configuration.
/// </summary>
public sealed record SecretAlertDestinationDto
{
/// <summary>Destination ID.</summary>
public Guid Id { get; init; }
/// <summary>Destination name.</summary>
public required string Name { get; init; }
/// <summary>Channel type.</summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public AlertChannelType ChannelType { get; init; }
/// <summary>Channel identifier (webhook URL, email, channel ID).</summary>
public required string ChannelId { get; init; }
/// <summary>Severity filter (if empty, uses MinimumAlertSeverity).</summary>
public IReadOnlyList<SecretSeverityType>? SeverityFilter { get; init; }
/// <summary>Rule category filter (if empty, alerts for all).</summary>
public IReadOnlyList<string>? RuleCategoryFilter { get; init; }
/// <summary>Whether this destination is active.</summary>
public bool IsActive { get; init; }
}
// ============================================================================
// Exception Pattern DTOs
// ============================================================================
/// <summary>
/// Request to create or update an exception pattern.
/// </summary>
public sealed record SecretExceptionPatternDto
{
/// <summary>Human-readable name.</summary>
public required string Name { get; init; }
/// <summary>Description of why this exception exists.</summary>
public required string Description { get; init; }
/// <summary>Regex pattern to match secret value.</summary>
public required string ValuePattern { get; init; }
/// <summary>Rule IDs this applies to (empty = all).</summary>
public IReadOnlyList<string> ApplicableRuleIds { get; init; } = [];
/// <summary>File path glob pattern.</summary>
public string? FilePathGlob { get; init; }
/// <summary>Business justification (required).</summary>
public required string Justification { get; init; }
/// <summary>Expiration date (null = permanent).</summary>
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>Whether this exception is active.</summary>
public bool IsActive { get; init; }
}
/// <summary>
/// Response containing exception pattern with metadata.
/// </summary>
public sealed record SecretExceptionPatternResponseDto
{
/// <summary>Exception ID.</summary>
public Guid Id { get; init; }
/// <summary>Tenant ID.</summary>
public Guid TenantId { get; init; }
/// <summary>Exception data.</summary>
public required SecretExceptionPatternDto Pattern { get; init; }
/// <summary>Number of times matched.</summary>
public long MatchCount { get; init; }
/// <summary>Last match time.</summary>
public DateTimeOffset? LastMatchedAt { get; init; }
/// <summary>Creation time.</summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>Creator.</summary>
public required string CreatedBy { get; init; }
/// <summary>Last update time.</summary>
public DateTimeOffset? UpdatedAt { get; init; }
/// <summary>Last updater.</summary>
public string? UpdatedBy { get; init; }
}
/// <summary>
/// List response for exception patterns.
/// </summary>
public sealed record SecretExceptionPatternListResponseDto
{
/// <summary>Exception patterns.</summary>
public required IReadOnlyList<SecretExceptionPatternResponseDto> Patterns { get; init; }
/// <summary>Total count.</summary>
public int TotalCount { get; init; }
}
// ============================================================================
// Update Request DTOs
// ============================================================================
/// <summary>
/// Request to update settings with optimistic concurrency.
/// </summary>
public sealed record UpdateSecretDetectionSettingsRequestDto
{
/// <summary>Settings to apply.</summary>
public required SecretDetectionSettingsDto Settings { get; init; }
/// <summary>Expected version (for optimistic concurrency).</summary>
public int ExpectedVersion { get; init; }
}
/// <summary>
/// Available rule categories response.
/// </summary>
public sealed record RuleCategoriesResponseDto
{
/// <summary>All available categories.</summary>
public required IReadOnlyList<RuleCategoryDto> Categories { get; init; }
}
/// <summary>
/// Rule category information.
/// </summary>
public sealed record RuleCategoryDto
{
/// <summary>Category ID.</summary>
public required string Id { get; init; }
/// <summary>Display name.</summary>
public required string Name { get; init; }
/// <summary>Description.</summary>
public required string Description { get; init; }
/// <summary>Number of rules in this category.</summary>
public int RuleCount { get; init; }
}

View File

@@ -12,8 +12,7 @@ public sealed record SurfacePointersDto
[JsonPropertyName("generatedAt")]
[JsonPropertyOrder(1)]
public DateTimeOffset GeneratedAt { get; init; }
= DateTimeOffset.UtcNow;
public required DateTimeOffset GeneratedAt { get; init; }
[JsonPropertyName("manifestDigest")]
[JsonPropertyOrder(2)]

View File

@@ -0,0 +1,373 @@
// -----------------------------------------------------------------------------
// SecretDetectionSettingsEndpoints.cs
// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
// Task: SDC-005 - Create Settings CRUD API endpoints
// Description: HTTP endpoints for secret detection configuration.
// -----------------------------------------------------------------------------
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Services;
namespace StellaOps.Scanner.WebService.Endpoints;
/// <summary>
/// Endpoints for secret detection configuration.
/// Per SPRINT_20260104_006_BE.
/// </summary>
internal static class SecretDetectionSettingsEndpoints
{
/// <summary>
/// Maps secret detection settings endpoints.
/// </summary>
public static void MapSecretDetectionSettingsEndpoints(this RouteGroupBuilder apiGroup, string prefix = "/secrets/config")
{
ArgumentNullException.ThrowIfNull(apiGroup);
var settings = apiGroup.MapGroup($"{prefix}/settings")
.WithTags("Secret Detection Settings");
var exceptions = apiGroup.MapGroup($"{prefix}/exceptions")
.WithTags("Secret Detection Exceptions");
var rules = apiGroup.MapGroup($"{prefix}/rules")
.WithTags("Secret Detection Rules");
// ====================================================================
// Settings Endpoints
// ====================================================================
// GET /v1/secrets/config/settings/{tenantId} - Get settings
settings.MapGet("/{tenantId:guid}", HandleGetSettingsAsync)
.WithName("scanner.secrets.settings.get")
.WithDescription("Get secret detection settings for a tenant.")
.Produces<SecretDetectionSettingsResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.SecretSettingsRead);
// POST /v1/secrets/config/settings/{tenantId} - Create default settings
settings.MapPost("/{tenantId:guid}", HandleCreateSettingsAsync)
.WithName("scanner.secrets.settings.create")
.WithDescription("Create default secret detection settings for a tenant.")
.Produces<SecretDetectionSettingsResponseDto>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status409Conflict)
.RequireAuthorization(ScannerPolicies.SecretSettingsWrite);
// PUT /v1/secrets/config/settings/{tenantId} - Update settings
settings.MapPut("/{tenantId:guid}", HandleUpdateSettingsAsync)
.WithName("scanner.secrets.settings.update")
.WithDescription("Update secret detection settings for a tenant.")
.Produces<SecretDetectionSettingsResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict)
.RequireAuthorization(ScannerPolicies.SecretSettingsWrite);
// ====================================================================
// Exception Pattern Endpoints
// ====================================================================
// GET /v1/secrets/config/exceptions/{tenantId} - List exception patterns
exceptions.MapGet("/{tenantId:guid}", HandleListExceptionsAsync)
.WithName("scanner.secrets.exceptions.list")
.WithDescription("List secret exception patterns for a tenant.")
.Produces<SecretExceptionPatternListResponseDto>(StatusCodes.Status200OK)
.RequireAuthorization(ScannerPolicies.SecretExceptionsRead);
// GET /v1/secrets/config/exceptions/{tenantId}/{exceptionId} - Get exception pattern
exceptions.MapGet("/{tenantId:guid}/{exceptionId:guid}", HandleGetExceptionAsync)
.WithName("scanner.secrets.exceptions.get")
.WithDescription("Get a specific secret exception pattern.")
.Produces<SecretExceptionPatternResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.SecretExceptionsRead);
// POST /v1/secrets/config/exceptions/{tenantId} - Create exception pattern
exceptions.MapPost("/{tenantId:guid}", HandleCreateExceptionAsync)
.WithName("scanner.secrets.exceptions.create")
.WithDescription("Create a new secret exception pattern.")
.Produces<SecretExceptionPatternResponseDto>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.SecretExceptionsWrite);
// PUT /v1/secrets/config/exceptions/{tenantId}/{exceptionId} - Update exception pattern
exceptions.MapPut("/{tenantId:guid}/{exceptionId:guid}", HandleUpdateExceptionAsync)
.WithName("scanner.secrets.exceptions.update")
.WithDescription("Update a secret exception pattern.")
.Produces<SecretExceptionPatternResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.SecretExceptionsWrite);
// DELETE /v1/secrets/config/exceptions/{tenantId}/{exceptionId} - Delete exception pattern
exceptions.MapDelete("/{tenantId:guid}/{exceptionId:guid}", HandleDeleteExceptionAsync)
.WithName("scanner.secrets.exceptions.delete")
.WithDescription("Delete a secret exception pattern.")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.SecretExceptionsWrite);
// ====================================================================
// Rule Catalog Endpoints
// ====================================================================
// GET /v1/secrets/config/rules/categories - Get available rule categories
rules.MapGet("/categories", HandleGetRuleCategoriesAsync)
.WithName("scanner.secrets.rules.categories")
.WithDescription("Get available secret detection rule categories.")
.Produces<RuleCategoriesResponseDto>(StatusCodes.Status200OK)
.RequireAuthorization(ScannerPolicies.SecretSettingsRead);
}
// ========================================================================
// Settings Handlers
// ========================================================================
private static async Task<IResult> HandleGetSettingsAsync(
Guid tenantId,
ISecretDetectionSettingsService service,
CancellationToken cancellationToken)
{
var settings = await service.GetSettingsAsync(tenantId, cancellationToken);
if (settings is null)
{
return Results.NotFound(new
{
type = "not-found",
title = "Settings not found",
detail = $"No secret detection settings found for tenant '{tenantId}'."
});
}
return Results.Ok(settings);
}
private static async Task<IResult> HandleCreateSettingsAsync(
Guid tenantId,
ISecretDetectionSettingsService service,
HttpContext context,
CancellationToken cancellationToken)
{
// Check if settings already exist
var existing = await service.GetSettingsAsync(tenantId, cancellationToken);
if (existing is not null)
{
return Results.Conflict(new
{
type = "conflict",
title = "Settings already exist",
detail = $"Secret detection settings already exist for tenant '{tenantId}'."
});
}
var username = context.User.Identity?.Name ?? "system";
var settings = await service.CreateSettingsAsync(tenantId, username, cancellationToken);
return Results.Created($"/v1/secrets/config/settings/{tenantId}", settings);
}
private static async Task<IResult> HandleUpdateSettingsAsync(
Guid tenantId,
UpdateSecretDetectionSettingsRequestDto request,
ISecretDetectionSettingsService service,
HttpContext context,
CancellationToken cancellationToken)
{
var username = context.User.Identity?.Name ?? "system";
var (success, settings, error) = await service.UpdateSettingsAsync(
tenantId,
request.Settings,
request.ExpectedVersion,
username,
cancellationToken);
if (!success)
{
if (error?.Contains("not found", StringComparison.OrdinalIgnoreCase) == true)
{
return Results.NotFound(new
{
type = "not-found",
title = "Settings not found",
detail = error
});
}
if (error?.Contains("conflict", StringComparison.OrdinalIgnoreCase) == true)
{
return Results.Conflict(new
{
type = "conflict",
title = "Version conflict",
detail = error
});
}
return Results.BadRequest(new
{
type = "validation-error",
title = "Validation failed",
detail = error
});
}
return Results.Ok(settings);
}
// ========================================================================
// Exception Pattern Handlers
// ========================================================================
private static async Task<IResult> HandleListExceptionsAsync(
Guid tenantId,
ISecretExceptionPatternService service,
bool includeInactive = false,
CancellationToken cancellationToken = default)
{
var patterns = await service.GetPatternsAsync(tenantId, includeInactive, cancellationToken);
return Results.Ok(patterns);
}
private static async Task<IResult> HandleGetExceptionAsync(
Guid tenantId,
Guid exceptionId,
ISecretExceptionPatternService service,
CancellationToken cancellationToken)
{
var pattern = await service.GetPatternAsync(exceptionId, cancellationToken);
if (pattern is null || pattern.TenantId != tenantId)
{
return Results.NotFound(new
{
type = "not-found",
title = "Exception pattern not found",
detail = $"No exception pattern found with ID '{exceptionId}'."
});
}
return Results.Ok(pattern);
}
private static async Task<IResult> HandleCreateExceptionAsync(
Guid tenantId,
SecretExceptionPatternDto request,
ISecretExceptionPatternService service,
HttpContext context,
CancellationToken cancellationToken)
{
var username = context.User.Identity?.Name ?? "system";
var (pattern, errors) = await service.CreatePatternAsync(tenantId, request, username, cancellationToken);
if (errors.Count > 0)
{
return Results.BadRequest(new
{
type = "validation-error",
title = "Validation failed",
detail = string.Join("; ", errors),
errors
});
}
return Results.Created($"/v1/secrets/config/exceptions/{tenantId}/{pattern!.Id}", pattern);
}
private static async Task<IResult> HandleUpdateExceptionAsync(
Guid tenantId,
Guid exceptionId,
SecretExceptionPatternDto request,
ISecretExceptionPatternService service,
HttpContext context,
CancellationToken cancellationToken)
{
// Verify pattern belongs to tenant
var existing = await service.GetPatternAsync(exceptionId, cancellationToken);
if (existing is null || existing.TenantId != tenantId)
{
return Results.NotFound(new
{
type = "not-found",
title = "Exception pattern not found",
detail = $"No exception pattern found with ID '{exceptionId}'."
});
}
var username = context.User.Identity?.Name ?? "system";
var (success, pattern, errors) = await service.UpdatePatternAsync(
exceptionId,
request,
username,
cancellationToken);
if (!success)
{
if (errors.Count > 0 && errors[0].Contains("not found", StringComparison.OrdinalIgnoreCase))
{
return Results.NotFound(new
{
type = "not-found",
title = "Exception pattern not found",
detail = errors[0]
});
}
return Results.BadRequest(new
{
type = "validation-error",
title = "Validation failed",
detail = string.Join("; ", errors),
errors
});
}
return Results.Ok(pattern);
}
private static async Task<IResult> HandleDeleteExceptionAsync(
Guid tenantId,
Guid exceptionId,
ISecretExceptionPatternService service,
CancellationToken cancellationToken)
{
// Verify pattern belongs to tenant
var existing = await service.GetPatternAsync(exceptionId, cancellationToken);
if (existing is null || existing.TenantId != tenantId)
{
return Results.NotFound(new
{
type = "not-found",
title = "Exception pattern not found",
detail = $"No exception pattern found with ID '{exceptionId}'."
});
}
var deleted = await service.DeletePatternAsync(exceptionId, cancellationToken);
if (!deleted)
{
return Results.NotFound(new
{
type = "not-found",
title = "Exception pattern not found",
detail = $"No exception pattern found with ID '{exceptionId}'."
});
}
return Results.NoContent();
}
// ========================================================================
// Rule Catalog Handlers
// ========================================================================
private static async Task<IResult> HandleGetRuleCategoriesAsync(
ISecretDetectionSettingsService service,
CancellationToken cancellationToken)
{
var categories = await service.GetRuleCategoriesAsync(cancellationToken);
return Results.Ok(categories);
}
}

View File

@@ -25,13 +25,16 @@ public sealed class IdempotencyMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<IdempotencyMiddleware> _logger;
private readonly TimeProvider _timeProvider;
public IdempotencyMiddleware(
RequestDelegate next,
ILogger<IdempotencyMiddleware> logger)
ILogger<IdempotencyMiddleware> logger,
TimeProvider timeProvider)
{
_next = next ?? throw new ArgumentNullException(nameof(next));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task InvokeAsync(
@@ -108,6 +111,7 @@ public sealed class IdempotencyMiddleware
var responseBody = await new StreamReader(responseBuffer).ReadToEndAsync(context.RequestAborted)
.ConfigureAwait(false);
var now = _timeProvider.GetUtcNow();
var idempotencyKey = new IdempotencyKeyRow
{
TenantId = tenantId,
@@ -116,8 +120,8 @@ public sealed class IdempotencyMiddleware
ResponseStatus = context.Response.StatusCode,
ResponseBody = responseBody,
ResponseHeaders = SerializeHeaders(context.Response.Headers),
CreatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.Add(opts.Window)
CreatedAt = now,
ExpiresAt = now.Add(opts.Window)
};
try

View File

@@ -155,6 +155,11 @@ builder.Services.AddSingleton<IDeltaCompareService, DeltaCompareService>();
builder.Services.AddSingleton<IBaselineService, BaselineService>();
builder.Services.AddSingleton<IActionablesService, ActionablesService>();
builder.Services.AddSingleton<ICounterfactualApiService, CounterfactualApiService>();
// Secret Detection Settings (Sprint: SPRINT_20260104_006_BE)
builder.Services.AddScoped<ISecretDetectionSettingsService, SecretDetectionSettingsService>();
builder.Services.AddScoped<ISecretExceptionPatternService, SecretExceptionPatternService>();
builder.Services.AddDbContext<TriageDbContext>(options =>
options.UseNpgsql(bootstrapOptions.Storage.Dsn, npgsqlOptions =>
{
@@ -580,6 +585,7 @@ apiGroup.MapEpssEndpoints(); // Sprint: SPRINT_3410_0002_0001
apiGroup.MapTriageStatusEndpoints();
apiGroup.MapTriageInboxEndpoints();
apiGroup.MapProofBundleEndpoints();
apiGroup.MapSecretDetectionSettingsEndpoints(); // Sprint: SPRINT_20260104_006_BE
if (resolvedOptions.Features.EnablePolicyPreview)
{

View File

@@ -26,4 +26,10 @@ internal static class ScannerPolicies
public const string SourcesRead = "scanner.sources.read";
public const string SourcesWrite = "scanner.sources.write";
public const string SourcesAdmin = "scanner.sources.admin";
// Secret detection settings policies
public const string SecretSettingsRead = "scanner.secrets.settings.read";
public const string SecretSettingsWrite = "scanner.secrets.settings.write";
public const string SecretExceptionsRead = "scanner.secrets.exceptions.read";
public const string SecretExceptionsWrite = "scanner.secrets.exceptions.write";
}

View File

@@ -16,12 +16,23 @@ namespace StellaOps.Scanner.WebService.Services;
/// </summary>
public sealed class EvidenceBundleExporter : IEvidenceBundleExporter
{
private readonly TimeProvider _timeProvider;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>
/// Initializes a new instance of the <see cref="EvidenceBundleExporter"/> class.
/// </summary>
/// <param name="timeProvider">The time provider for deterministic timestamps. Defaults to system time if null.</param>
public EvidenceBundleExporter(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public async Task<EvidenceExportResult> ExportAsync(
UnifiedEvidenceResponseDto evidence,
@@ -43,7 +54,7 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter
var manifest = new ArchiveManifestDto
{
FindingId = evidence.FindingId,
GeneratedAt = DateTimeOffset.UtcNow,
GeneratedAt = _timeProvider.GetUtcNow(),
CacheKey = evidence.CacheKey ?? string.Empty,
Files = fileEntries,
ScannerVersion = null // Scanner version not directly available in manifests
@@ -136,7 +147,7 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter
var findingManifest = new ArchiveManifestDto
{
FindingId = evidence.FindingId,
GeneratedAt = DateTimeOffset.UtcNow,
GeneratedAt = _timeProvider.GetUtcNow(),
CacheKey = evidence.CacheKey ?? string.Empty,
Files = fileEntries,
ScannerVersion = null
@@ -155,7 +166,7 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter
var runManifest = new RunArchiveManifestDto
{
ScanId = scanId,
GeneratedAt = DateTimeOffset.UtcNow,
GeneratedAt = _timeProvider.GetUtcNow(),
Findings = findingManifests,
TotalFiles = totalFiles,
ScannerVersion = null
@@ -221,7 +232,7 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter
}
}
private static string GenerateRunReadme(
private string GenerateRunReadme(
string scanId,
IReadOnlyList<UnifiedEvidenceResponseDto> findings,
IReadOnlyList<ArchiveManifestDto> manifests)
@@ -233,7 +244,7 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter
sb.AppendLine();
sb.AppendLine($"- **Scan ID:** `{scanId}`");
sb.AppendLine($"- **Finding Count:** {findings.Count}");
sb.AppendLine($"- **Generated:** {DateTimeOffset.UtcNow:O}");
sb.AppendLine($"- **Generated:** {_timeProvider.GetUtcNow():O}");
sb.AppendLine();
sb.AppendLine("## Findings");
sb.AppendLine();
@@ -388,12 +399,12 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter
await Task.CompletedTask.ConfigureAwait(false);
}
private static string GenerateBashReplayScript(UnifiedEvidenceResponseDto evidence)
private string GenerateBashReplayScript(UnifiedEvidenceResponseDto evidence)
{
var sb = new StringBuilder();
sb.AppendLine("#!/usr/bin/env bash");
sb.AppendLine("# StellaOps Evidence Bundle Replay Script");
sb.AppendLine($"# Generated: {DateTimeOffset.UtcNow:O}");
sb.AppendLine($"# Generated: {_timeProvider.GetUtcNow():O}");
sb.AppendLine($"# Finding: {evidence.FindingId}");
sb.AppendLine($"# CVE: {evidence.CveId}");
sb.AppendLine();
@@ -425,11 +436,11 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter
return sb.ToString();
}
private static string GeneratePowerShellReplayScript(UnifiedEvidenceResponseDto evidence)
private string GeneratePowerShellReplayScript(UnifiedEvidenceResponseDto evidence)
{
var sb = new StringBuilder();
sb.AppendLine("# StellaOps Evidence Bundle Replay Script");
sb.AppendLine($"# Generated: {DateTimeOffset.UtcNow:O}");
sb.AppendLine($"# Generated: {_timeProvider.GetUtcNow():O}");
sb.AppendLine($"# Finding: {evidence.FindingId}");
sb.AppendLine($"# CVE: {evidence.CveId}");
sb.AppendLine();
@@ -461,7 +472,7 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter
return sb.ToString();
}
private static string GenerateReadme(UnifiedEvidenceResponseDto evidence, List<ArchiveFileEntry> entries)
private string GenerateReadme(UnifiedEvidenceResponseDto evidence, List<ArchiveFileEntry> entries)
{
var sb = new StringBuilder();
sb.AppendLine("# StellaOps Evidence Bundle");
@@ -671,7 +682,7 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter
Encoding.ASCII.GetBytes(sizeOctal).CopyTo(header, 124);
// Mtime (136-147) - current time in octal
var mtime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var mtime = _timeProvider.GetUtcNow().ToUnixTimeSeconds();
var mtimeOctal = Convert.ToString(mtime, 8).PadLeft(11, '0');
Encoding.ASCII.GetBytes(mtimeOctal).CopyTo(header, 136);

View File

@@ -55,6 +55,7 @@ public sealed class FeedChangeRescoreJob : BackgroundService
private readonly IScoreReplayService _replayService;
private readonly IOptions<FeedChangeRescoreOptions> _options;
private readonly ILogger<FeedChangeRescoreJob> _logger;
private readonly TimeProvider _timeProvider;
private readonly ActivitySource _activitySource = new("StellaOps.Scanner.FeedChangeRescore");
private string? _lastConcelierSnapshot;
@@ -66,13 +67,15 @@ public sealed class FeedChangeRescoreJob : BackgroundService
IScanManifestRepository manifestRepository,
IScoreReplayService replayService,
IOptions<FeedChangeRescoreOptions> options,
ILogger<FeedChangeRescoreJob> logger)
ILogger<FeedChangeRescoreJob> logger,
TimeProvider? timeProvider = null)
{
_feedTracker = feedTracker ?? throw new ArgumentNullException(nameof(feedTracker));
_manifestRepository = manifestRepository ?? throw new ArgumentNullException(nameof(manifestRepository));
_replayService = replayService ?? throw new ArgumentNullException(nameof(replayService));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -221,7 +224,7 @@ public sealed class FeedChangeRescoreJob : BackgroundService
FeedChangeRescoreOptions opts,
CancellationToken ct)
{
var cutoff = DateTimeOffset.UtcNow - opts.ScanAgeLimit;
var cutoff = _timeProvider.GetUtcNow() - opts.ScanAgeLimit;
// Find scans using the old snapshot hashes
var query = new AffectedScansQuery

View File

@@ -18,16 +18,19 @@ public sealed class GatingReasonService : IGatingReasonService
{
private readonly TriageDbContext _dbContext;
private readonly ILogger<GatingReasonService> _logger;
private readonly TimeProvider _timeProvider;
// Default policy trust threshold (configurable in real implementation)
private const double DefaultPolicyTrustThreshold = 0.7;
public GatingReasonService(
TriageDbContext dbContext,
ILogger<GatingReasonService> logger)
ILogger<GatingReasonService> logger,
TimeProvider? timeProvider = null)
{
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
@@ -262,11 +265,11 @@ public sealed class GatingReasonService : IGatingReasonService
};
}
private static double GetRecencyTrust(DateTimeOffset? timestamp)
private double GetRecencyTrust(DateTimeOffset? timestamp)
{
if (timestamp is null) return 0.3;
var age = DateTimeOffset.UtcNow - timestamp.Value;
var age = _timeProvider.GetUtcNow() - timestamp.Value;
return age.TotalDays switch
{
<= 7 => 1.0, // Within a week

View File

@@ -89,9 +89,9 @@ public sealed record BundleVerifyResult(
DateTimeOffset VerifiedAt,
string? ErrorMessage = null)
{
public static BundleVerifyResult Success(string computedRootHash) =>
new(true, computedRootHash, true, true, DateTimeOffset.UtcNow);
public static BundleVerifyResult Success(string computedRootHash, TimeProvider? timeProvider = null) =>
new(true, computedRootHash, true, true, (timeProvider ?? TimeProvider.System).GetUtcNow());
public static BundleVerifyResult Failure(string error, string computedRootHash = "") =>
new(false, computedRootHash, false, false, DateTimeOffset.UtcNow, error);
public static BundleVerifyResult Failure(string error, string computedRootHash = "", TimeProvider? timeProvider = null) =>
new(false, computedRootHash, false, false, (timeProvider ?? TimeProvider.System).GetUtcNow(), error);
}

View File

@@ -19,13 +19,16 @@ internal sealed class OfflineKitManifestService
private readonly OfflineKitStateStore _stateStore;
private readonly ILogger<OfflineKitManifestService> _logger;
private readonly TimeProvider _timeProvider;
public OfflineKitManifestService(
OfflineKitStateStore stateStore,
ILogger<OfflineKitManifestService> logger)
ILogger<OfflineKitManifestService> logger,
TimeProvider? timeProvider = null)
{
_stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
@@ -49,7 +52,7 @@ internal sealed class OfflineKitManifestService
Version = status.Current.BundleId ?? "unknown",
Assets = BuildAssetMap(status.Components),
Signature = null, // Would be loaded from bundle signature file
CreatedAt = status.Current.CapturedAt ?? DateTimeOffset.UtcNow,
CreatedAt = status.Current.CapturedAt ?? _timeProvider.GetUtcNow(),
ExpiresAt = status.Current.CapturedAt?.AddDays(30) // Default 30-day expiry
};
}
@@ -155,7 +158,7 @@ internal sealed class OfflineKitManifestService
private void ValidateExpiration(OfflineKitManifestTransport manifest, OfflineKitValidationResult result)
{
if (manifest.ExpiresAt.HasValue && manifest.ExpiresAt.Value < DateTimeOffset.UtcNow)
if (manifest.ExpiresAt.HasValue && manifest.ExpiresAt.Value < _timeProvider.GetUtcNow())
{
result.Warnings.Add(new OfflineKitValidationWarning
{
@@ -166,7 +169,7 @@ internal sealed class OfflineKitManifestService
}
// Check freshness (warn if older than 7 days)
var age = DateTimeOffset.UtcNow - manifest.CreatedAt;
var age = _timeProvider.GetUtcNow() - manifest.CreatedAt;
if (age.TotalDays > 30)
{
result.Warnings.Add(new OfflineKitValidationWarning
@@ -218,7 +221,7 @@ internal sealed class OfflineKitManifestService
Valid = true,
Algorithm = "ECDSA-P256",
KeyId = "authority-key-001",
SignedAt = DateTimeOffset.UtcNow
SignedAt = _timeProvider.GetUtcNow()
};
}
catch (FormatException)

View File

@@ -20,6 +20,7 @@ public sealed class ReplayCommandService : IReplayCommandService
{
private readonly TriageDbContext _dbContext;
private readonly ILogger<ReplayCommandService> _logger;
private readonly TimeProvider _timeProvider;
// Configuration (would come from IOptions in real implementation)
private const string DefaultBinary = "stellaops";
@@ -27,10 +28,12 @@ public sealed class ReplayCommandService : IReplayCommandService
public ReplayCommandService(
TriageDbContext dbContext,
ILogger<ReplayCommandService> logger)
ILogger<ReplayCommandService> logger,
TimeProvider? timeProvider = null)
{
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
@@ -92,7 +95,7 @@ public sealed class ReplayCommandService : IReplayCommandService
OfflineCommand = offlineCommand,
Snapshot = snapshotInfo,
Bundle = bundleInfo,
GeneratedAt = DateTimeOffset.UtcNow,
GeneratedAt = _timeProvider.GetUtcNow(),
ExpectedVerdictHash = verdictHash
};
}
@@ -141,7 +144,7 @@ public sealed class ReplayCommandService : IReplayCommandService
OfflineCommand = offlineCommand,
Snapshot = snapshotInfo,
Bundle = bundleInfo,
GeneratedAt = DateTimeOffset.UtcNow,
GeneratedAt = _timeProvider.GetUtcNow(),
ExpectedFinalDigest = scan.FinalDigest ?? ComputeDigest($"scan:{scan.Id}")
};
}
@@ -358,7 +361,7 @@ public sealed class ReplayCommandService : IReplayCommandService
return new SnapshotInfoDto
{
Id = snapshotId,
CreatedAt = scan?.SnapshotCreatedAt ?? DateTimeOffset.UtcNow,
CreatedAt = scan?.SnapshotCreatedAt ?? _timeProvider.GetUtcNow(),
FeedVersions = scan?.FeedVersions ?? new Dictionary<string, string>
{
["nvd"] = "latest",
@@ -381,7 +384,7 @@ public sealed class ReplayCommandService : IReplayCommandService
SizeBytes = null, // Would be computed when bundle is generated
ContentHash = contentHash,
Format = "tar.gz",
ExpiresAt = DateTimeOffset.UtcNow.AddDays(7),
ExpiresAt = _timeProvider.GetUtcNow().AddDays(7),
Contents = new[]
{
"manifest.json",
@@ -405,7 +408,7 @@ public sealed class ReplayCommandService : IReplayCommandService
SizeBytes = null,
ContentHash = contentHash,
Format = "tar.gz",
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
ExpiresAt = _timeProvider.GetUtcNow().AddDays(30),
Contents = new[]
{
"manifest.json",

View File

@@ -84,13 +84,13 @@ internal sealed record RuntimeReconciliationResult
public string? ErrorMessage { get; init; }
public static RuntimeReconciliationResult Error(string imageDigest, string code, string message)
public static RuntimeReconciliationResult Error(string imageDigest, string code, string message, TimeProvider? timeProvider = null)
=> new()
{
ImageDigest = imageDigest,
ErrorCode = code,
ErrorMessage = message,
ReconciledAt = DateTimeOffset.UtcNow
ReconciledAt = (timeProvider ?? TimeProvider.System).GetUtcNow()
};
}

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

View File

@@ -11,12 +11,14 @@
<ItemGroup>
<PackageReference Include="CycloneDX.Core" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="YamlDotNet" />
<PackageReference Include="StackExchange.Redis" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />