finish secrets finding work and audit remarks work save
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user