finish secrets finding work and audit remarks work save

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

View File

@@ -0,0 +1,445 @@
// -----------------------------------------------------------------------------
// SecretFindingAlertTemplates.cs
// Sprint: SPRINT_20260104_007_BE (Secret Detection Alert Integration)
// Task: SDA-003 - Add secret-finding alert template
// Task: SDA-004 - Implement Slack/Teams formatters
// Description: Default templates for secret finding alert notifications
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Engine.Templates;
/// <summary>
/// Provides default templates for secret finding alert notifications.
/// Templates support scanner.secret.finding event with severity-based styling.
/// </summary>
/// <remarks>
/// Per SPRINT_20260104_007_BE tasks SDA-003 and SDA-004.
/// </remarks>
public static class SecretFindingAlertTemplates
{
/// <summary>
/// Template key for secret finding notifications.
/// </summary>
public const string SecretFindingKey = "notification.scanner.secret.finding";
/// <summary>
/// Get all default secret finding alert templates for a tenant.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="locale">Locale code (default: en-us).</param>
/// <returns>Collection of default templates.</returns>
public static IReadOnlyList<NotifyTemplate> GetDefaultTemplates(
string tenantId,
string locale = "en-us")
{
var templates = new List<NotifyTemplate>();
// Channel-specific templates
templates.Add(CreateSlackTemplate(tenantId, locale));
templates.Add(CreateTeamsTemplate(tenantId, locale));
templates.Add(CreateEmailTemplate(tenantId, locale));
templates.Add(CreateWebhookTemplate(tenantId, locale));
templates.Add(CreatePagerDutyTemplate(tenantId, locale));
return templates;
}
#region Slack Template
private static NotifyTemplate CreateSlackTemplate(string tenantId, string locale) =>
NotifyTemplate.Create(
templateId: $"tmpl-secret-finding-slack-{tenantId}",
tenantId: tenantId,
channelType: NotifyChannelType.Slack,
key: SecretFindingKey,
locale: locale,
body: SlackBody,
renderMode: NotifyTemplateRenderMode.Json,
format: NotifyDeliveryFormat.Slack,
description: "Slack notification for secret detection findings",
metadata: CreateMetadata("1.0.0"),
createdBy: "system:secret-templates");
private const string SlackBody = """
{
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "Secret Detected in Container Scan",
"emoji": true
}
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*Severity:*\n{{#if (eq severity \"Critical\")}}:rotating_light:{{/if}}{{#if (eq severity \"High\")}}:warning:{{/if}}{{#if (eq severity \"Medium\")}}:large_yellow_circle:{{/if}}{{#if (eq severity \"Low\")}}:information_source:{{/if}} {{severity}}"
},
{
"type": "mrkdwn",
"text": "*Rule:*\n{{ruleName}}"
},
{
"type": "mrkdwn",
"text": "*Category:*\n{{ruleCategory}}"
},
{
"type": "mrkdwn",
"text": "*Rule ID:*\n`{{ruleId}}`"
}
]
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*Image:*\n`{{imageRef}}`"
},
{
"type": "mrkdwn",
"text": "*File:*\n`{{filePath}}:{{lineNumber}}`"
}
]
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Detected Value (masked):*\n```{{maskedValue}}```"
}
},
{{#if remediationGuidance}}
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Remediation:*\n{{remediationGuidance}}"
}
},
{{/if}}
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": "Scan ID: `{{scanId}}` | Triggered by: {{scanTriggeredBy}} | Detected: {{detectedAt}}"
}
]
},
{{#if findingUrl}}
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {
"type": "plain_text",
"text": "View in StellaOps",
"emoji": true
},
"url": "{{findingUrl}}",
"style": "primary"
}
]
}
{{/if}}
]
}
""";
#endregion
#region Teams Template
private static NotifyTemplate CreateTeamsTemplate(string tenantId, string locale) =>
NotifyTemplate.Create(
templateId: $"tmpl-secret-finding-teams-{tenantId}",
tenantId: tenantId,
channelType: NotifyChannelType.Teams,
key: SecretFindingKey,
locale: locale,
body: TeamsBody,
renderMode: NotifyTemplateRenderMode.AdaptiveCard,
format: NotifyDeliveryFormat.Teams,
description: "Teams notification for secret detection findings",
metadata: CreateMetadata("1.0.0"),
createdBy: "system:secret-templates");
private const string TeamsBody = """
{
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.4",
"body": [
{
"type": "TextBlock",
"size": "Large",
"weight": "Bolder",
"text": "Secret Detected in Container Scan",
"wrap": true
},
{
"type": "FactSet",
"facts": [
{
"title": "Severity",
"value": "{{severity}}"
},
{
"title": "Rule",
"value": "{{ruleName}}"
},
{
"title": "Category",
"value": "{{ruleCategory}}"
},
{
"title": "Image",
"value": "{{imageRef}}"
},
{
"title": "File",
"value": "{{filePath}}:{{lineNumber}}"
}
]
},
{
"type": "TextBlock",
"text": "**Detected Value (masked):**",
"wrap": true
},
{
"type": "TextBlock",
"text": "{{maskedValue}}",
"fontType": "Monospace",
"wrap": true
},
{{#if remediationGuidance}}
{
"type": "TextBlock",
"text": "**Remediation:** {{remediationGuidance}}",
"wrap": true
},
{{/if}}
{
"type": "TextBlock",
"text": "Scan: {{scanId}} | By: {{scanTriggeredBy}} | At: {{detectedAt}}",
"size": "Small",
"isSubtle": true,
"wrap": true
}
],
"actions": [
{{#if findingUrl}}
{
"type": "Action.OpenUrl",
"title": "View in StellaOps",
"url": "{{findingUrl}}"
}
{{/if}}
]
}
""";
#endregion
#region Email Template
private static NotifyTemplate CreateEmailTemplate(string tenantId, string locale) =>
NotifyTemplate.Create(
templateId: $"tmpl-secret-finding-email-{tenantId}",
tenantId: tenantId,
channelType: NotifyChannelType.Email,
key: SecretFindingKey,
locale: locale,
body: EmailBody,
renderMode: NotifyTemplateRenderMode.Html,
format: NotifyDeliveryFormat.Html,
description: "Email notification for secret detection findings",
metadata: CreateMetadata("1.0.0"),
createdBy: "system:secret-templates");
private const string EmailBody = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: {{#if (eq severity "Critical")}}#dc3545{{/if}}{{#if (eq severity "High")}}#fd7e14{{/if}}{{#if (eq severity "Medium")}}#ffc107{{/if}}{{#if (eq severity "Low")}}#17a2b8{{/if}}; color: white; padding: 15px; border-radius: 8px 8px 0 0; }
.header h1 { margin: 0; font-size: 18px; }
.content { background: #f8f9fa; padding: 20px; border-radius: 0 0 8px 8px; }
.field { margin-bottom: 15px; }
.field-label { font-weight: 600; color: #666; font-size: 12px; text-transform: uppercase; }
.field-value { font-size: 14px; margin-top: 4px; }
.code { background: #e9ecef; padding: 10px; border-radius: 4px; font-family: monospace; word-break: break-all; }
.severity-critical { color: #dc3545; font-weight: bold; }
.severity-high { color: #fd7e14; font-weight: bold; }
.severity-medium { color: #ffc107; font-weight: bold; }
.severity-low { color: #17a2b8; }
.footer { font-size: 12px; color: #666; margin-top: 20px; padding-top: 15px; border-top: 1px solid #dee2e6; }
.btn { display: inline-block; padding: 10px 20px; background: #0d6efd; color: white; text-decoration: none; border-radius: 4px; margin-top: 15px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Secret Detected in Container Scan</h1>
</div>
<div class="content">
<div class="field">
<div class="field-label">Severity</div>
<div class="field-value severity-{{lowercase severity}}">{{severity}}</div>
</div>
<div class="field">
<div class="field-label">Rule</div>
<div class="field-value">{{ruleName}} ({{ruleId}})</div>
</div>
<div class="field">
<div class="field-label">Category</div>
<div class="field-value">{{ruleCategory}}</div>
</div>
<div class="field">
<div class="field-label">Image</div>
<div class="field-value code">{{imageRef}}</div>
</div>
<div class="field">
<div class="field-label">Location</div>
<div class="field-value code">{{filePath}}:{{lineNumber}}</div>
</div>
<div class="field">
<div class="field-label">Detected Value (masked)</div>
<div class="field-value code">{{maskedValue}}</div>
</div>
{{#if remediationGuidance}}
<div class="field">
<div class="field-label">Remediation</div>
<div class="field-value">{{remediationGuidance}}</div>
</div>
{{/if}}
{{#if findingUrl}}
<a href="{{findingUrl}}" class="btn">View in StellaOps</a>
{{/if}}
<div class="footer">
Scan ID: {{scanId}}<br>
Triggered by: {{scanTriggeredBy}}<br>
Detected: {{detectedAt}}
</div>
</div>
</div>
</body>
</html>
""";
#endregion
#region Webhook Template
private static NotifyTemplate CreateWebhookTemplate(string tenantId, string locale) =>
NotifyTemplate.Create(
templateId: $"tmpl-secret-finding-webhook-{tenantId}",
tenantId: tenantId,
channelType: NotifyChannelType.Webhook,
key: SecretFindingKey,
locale: locale,
body: WebhookBody,
renderMode: NotifyTemplateRenderMode.Json,
format: NotifyDeliveryFormat.Json,
description: "Webhook notification for secret detection findings",
metadata: CreateMetadata("1.0.0"),
createdBy: "system:secret-templates");
private const string WebhookBody = """
{
"event": "scanner.secret.finding",
"version": "1.0",
"timestamp": "{{detectedAt}}",
"payload": {
"eventId": "{{eventId}}",
"tenantId": "{{tenantId}}",
"scanId": "{{scanId}}",
"imageRef": "{{imageRef}}",
"imageDigest": "{{imageDigest}}",
"finding": {
"severity": "{{severity}}",
"ruleId": "{{ruleId}}",
"ruleName": "{{ruleName}}",
"ruleCategory": "{{ruleCategory}}",
"filePath": "{{filePath}}",
"lineNumber": {{lineNumber}},
"maskedValue": "{{maskedValue}}"
},
"context": {
"triggeredBy": "{{scanTriggeredBy}}",
"findingUrl": "{{findingUrl}}"
}
}
}
""";
#endregion
#region PagerDuty Template
private static NotifyTemplate CreatePagerDutyTemplate(string tenantId, string locale) =>
NotifyTemplate.Create(
templateId: $"tmpl-secret-finding-pagerduty-{tenantId}",
tenantId: tenantId,
channelType: NotifyChannelType.PagerDuty,
key: SecretFindingKey,
locale: locale,
body: PagerDutyBody,
renderMode: NotifyTemplateRenderMode.Json,
format: NotifyDeliveryFormat.Json,
description: "PagerDuty incident for secret detection findings",
metadata: CreateMetadata("1.0.0"),
createdBy: "system:secret-templates");
private const string PagerDutyBody = """
{
"routing_key": "{{pagerDutyRoutingKey}}",
"event_action": "trigger",
"dedup_key": "secret-{{tenantId}}-{{ruleId}}-{{imageRef}}",
"payload": {
"summary": "[{{severity}}] Secret detected: {{ruleName}} in {{imageRef}}",
"severity": "{{#if (eq severity \"Critical\")}}critical{{/if}}{{#if (eq severity \"High\")}}error{{/if}}{{#if (eq severity \"Medium\")}}warning{{/if}}{{#if (eq severity \"Low\")}}info{{/if}}",
"source": "stellaops-scanner",
"component": "secret-detection",
"group": "{{ruleCategory}}",
"class": "{{ruleId}}",
"custom_details": {
"image_ref": "{{imageRef}}",
"file_path": "{{filePath}}",
"line_number": {{lineNumber}},
"masked_value": "{{maskedValue}}",
"scan_id": "{{scanId}}",
"triggered_by": "{{scanTriggeredBy}}"
}
},
"links": [
{{#if findingUrl}}
{
"href": "{{findingUrl}}",
"text": "View in StellaOps"
}
{{/if}}
]
}
""";
#endregion
private static IEnumerable<KeyValuePair<string, string>> CreateMetadata(string version) =>
ImmutableDictionary<string, string>.Empty
.Add("template-version", version)
.Add("template-source", "system")
.Add("template-category", "secret-detection");
}

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" />

View File

@@ -10,12 +10,14 @@ public sealed class FidelityMetricsService
private readonly BitwiseFidelityCalculator _bitwiseCalculator;
private readonly SemanticFidelityCalculator _semanticCalculator;
private readonly PolicyFidelityCalculator _policyCalculator;
private readonly TimeProvider _timeProvider;
public FidelityMetricsService()
public FidelityMetricsService(TimeProvider? timeProvider = null)
{
_bitwiseCalculator = new BitwiseFidelityCalculator();
_semanticCalculator = new SemanticFidelityCalculator();
_policyCalculator = new PolicyFidelityCalculator();
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
@@ -67,7 +69,7 @@ public sealed class FidelityMetricsService
IdenticalOutputs = bfIdentical,
SemanticMatches = sfMatches,
PolicyMatches = pfMatches,
ComputedAt = DateTimeOffset.UtcNow,
ComputedAt = _timeProvider.GetUtcNow(),
Mismatches = allMismatches.Count > 0 ? allMismatches : null
};
}
@@ -108,7 +110,7 @@ public sealed class FidelityMetricsService
Passed = failures.Count == 0,
ShouldBlockRelease = shouldBlock,
FailureReasons = failures,
EvaluatedAt = DateTimeOffset.UtcNow
EvaluatedAt = _timeProvider.GetUtcNow()
};
}

View File

@@ -18,17 +18,20 @@ public class PoEOrchestrator
private readonly IProofEmitter _emitter;
private readonly IPoECasStore _casStore;
private readonly ILogger<PoEOrchestrator> _logger;
private readonly TimeProvider _timeProvider;
public PoEOrchestrator(
IReachabilityResolver resolver,
IProofEmitter emitter,
IPoECasStore casStore,
ILogger<PoEOrchestrator> logger)
ILogger<PoEOrchestrator> logger,
TimeProvider? timeProvider = null)
{
_resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
_emitter = emitter ?? throw new ArgumentNullException(nameof(emitter));
_casStore = casStore ?? throw new ArgumentNullException(nameof(casStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
@@ -135,7 +138,7 @@ public class PoEOrchestrator
{
// Build metadata
var metadata = new ProofMetadata(
GeneratedAt: DateTime.UtcNow,
GeneratedAt: _timeProvider.GetUtcNow().UtcDateTime,
Analyzer: new AnalyzerInfo(
Name: "stellaops-scanner",
Version: context.ScannerVersion,
@@ -144,7 +147,7 @@ public class PoEOrchestrator
Policy: new PolicyInfo(
PolicyId: context.PolicyId,
PolicyDigest: context.PolicyDigest,
EvaluatedAt: DateTime.UtcNow
EvaluatedAt: _timeProvider.GetUtcNow().UtcDateTime
),
ReproSteps: GenerateReproSteps(context, subgraph)
);

View File

@@ -22,13 +22,16 @@ public sealed class BinaryFindingMapper
{
private readonly IBinaryVulnerabilityService _binaryVulnService;
private readonly ILogger<BinaryFindingMapper> _logger;
private readonly TimeProvider _timeProvider;
public BinaryFindingMapper(
IBinaryVulnerabilityService binaryVulnService,
ILogger<BinaryFindingMapper> logger)
ILogger<BinaryFindingMapper> logger,
TimeProvider? timeProvider = null)
{
_binaryVulnService = binaryVulnService ?? throw new ArgumentNullException(nameof(binaryVulnService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
@@ -62,7 +65,7 @@ public sealed class BinaryFindingMapper
},
Remediation = GenerateRemediation(finding),
ScanId = finding.ScanId,
DetectedAt = DateTimeOffset.UtcNow
DetectedAt = _timeProvider.GetUtcNow()
};
}

View File

@@ -15,6 +15,7 @@
<PackageReference Include="OpenTelemetry.Instrumentation.Process" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj" />

View File

@@ -9,18 +9,20 @@ internal sealed class DenoRuntimeTraceRecorder
{
private readonly List<DenoRuntimeEvent> _events = new();
private readonly string _rootPath;
private readonly TimeProvider _timeProvider;
public DenoRuntimeTraceRecorder(string rootPath)
public DenoRuntimeTraceRecorder(string rootPath, TimeProvider? timeProvider = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
_rootPath = Path.GetFullPath(rootPath);
_timeProvider = timeProvider ?? TimeProvider.System;
}
public void AddModuleLoad(string absoluteModulePath, string reason, IEnumerable<string> permissions, string? origin = null, DateTimeOffset? timestamp = null)
{
var identity = DenoRuntimePathHasher.Create(_rootPath, absoluteModulePath);
var evt = new DenoModuleLoadEvent(
Ts: timestamp ?? DateTimeOffset.UtcNow,
Ts: timestamp ?? _timeProvider.GetUtcNow(),
Module: identity,
Reason: reason ?? string.Empty,
Permissions: NormalizePermissions(permissions),
@@ -32,7 +34,7 @@ internal sealed class DenoRuntimeTraceRecorder
{
var identity = DenoRuntimePathHasher.Create(_rootPath, absoluteModulePath);
var evt = new DenoPermissionUseEvent(
Ts: timestamp ?? DateTimeOffset.UtcNow,
Ts: timestamp ?? _timeProvider.GetUtcNow(),
Permission: permission ?? string.Empty,
Module: identity,
Details: details ?? string.Empty);
@@ -42,7 +44,7 @@ internal sealed class DenoRuntimeTraceRecorder
public void AddNpmResolution(string specifier, string package, string version, string resolved, bool exists, DateTimeOffset? timestamp = null)
{
_events.Add(new DenoNpmResolutionEvent(
Ts: timestamp ?? DateTimeOffset.UtcNow,
Ts: timestamp ?? _timeProvider.GetUtcNow(),
Specifier: specifier ?? string.Empty,
Package: package ?? string.Empty,
Version: version ?? string.Empty,
@@ -54,7 +56,7 @@ internal sealed class DenoRuntimeTraceRecorder
{
var identity = DenoRuntimePathHasher.Create(_rootPath, absoluteModulePath);
_events.Add(new DenoWasmLoadEvent(
Ts: timestamp ?? DateTimeOffset.UtcNow,
Ts: timestamp ?? _timeProvider.GetUtcNow(),
Module: identity,
Importer: importerRelativePath ?? string.Empty,
Reason: reason ?? string.Empty));

View File

@@ -19,12 +19,14 @@ internal sealed class DotNetCallgraphBuilder
private readonly Dictionary<string, string> _typeToAssemblyPath = new();
private readonly Dictionary<string, string?> _assemblyToPurl = new();
private readonly string _contextDigest;
private readonly TimeProvider _timeProvider;
private int _assemblyCount;
private int _typeCount;
public DotNetCallgraphBuilder(string contextDigest)
public DotNetCallgraphBuilder(string contextDigest, TimeProvider? timeProvider = null)
{
_contextDigest = contextDigest;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
@@ -114,7 +116,7 @@ internal sealed class DotNetCallgraphBuilder
var contentHash = DotNetGraphIdentifiers.ComputeGraphHash(methods, edges, roots);
var metadata = new DotNetGraphMetadata(
GeneratedAt: DateTimeOffset.UtcNow,
GeneratedAt: _timeProvider.GetUtcNow(),
GeneratorVersion: DotNetGraphIdentifiers.GetGeneratorVersion(),
ContextDigest: _contextDigest,
AssemblyCount: _assemblyCount,

View File

@@ -16,12 +16,14 @@ internal sealed class JavaCallgraphBuilder
private readonly List<JavaUnknown> _unknowns = new();
private readonly Dictionary<string, string> _classToJarPath = new();
private readonly string _contextDigest;
private readonly TimeProvider _timeProvider;
private int _jarCount;
private int _classCount;
public JavaCallgraphBuilder(string contextDigest)
public JavaCallgraphBuilder(string contextDigest, TimeProvider? timeProvider = null)
{
_contextDigest = contextDigest;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
@@ -177,7 +179,7 @@ internal sealed class JavaCallgraphBuilder
var contentHash = JavaGraphIdentifiers.ComputeGraphHash(methods, edges, roots);
var metadata = new JavaGraphMetadata(
GeneratedAt: DateTimeOffset.UtcNow,
GeneratedAt: _timeProvider.GetUtcNow(),
GeneratorVersion: JavaGraphIdentifiers.GetGeneratorVersion(),
ContextDigest: _contextDigest,
JarCount: _jarCount,

View File

@@ -28,13 +28,14 @@ internal static class JavaEntrypointAocWriter
string tenantId,
string scanId,
Stream outputStream,
CancellationToken cancellationToken)
TimeProvider? timeProvider = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(resolution);
ArgumentNullException.ThrowIfNull(outputStream);
using var writer = new StreamWriter(outputStream, Encoding.UTF8, leaveOpen: true);
var timestamp = DateTimeOffset.UtcNow;
var timestamp = (timeProvider ?? TimeProvider.System).GetUtcNow();
// Write header record
var header = new AocHeader

View File

@@ -15,11 +15,13 @@ internal sealed class NativeCallgraphBuilder
private readonly List<NativeUnknown> _unknowns = new();
private readonly Dictionary<ulong, string> _addressToSymbolId = new();
private readonly string _layerDigest;
private readonly TimeProvider _timeProvider;
private int _binaryCount;
public NativeCallgraphBuilder(string layerDigest)
public NativeCallgraphBuilder(string layerDigest, TimeProvider? timeProvider = null)
{
_layerDigest = layerDigest;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
@@ -80,7 +82,7 @@ internal sealed class NativeCallgraphBuilder
var contentHash = NativeGraphIdentifiers.ComputeGraphHash(functions, edges, roots);
var metadata = new NativeGraphMetadata(
GeneratedAt: DateTimeOffset.UtcNow,
GeneratedAt: _timeProvider.GetUtcNow(),
GeneratorVersion: NativeGraphIdentifiers.GetGeneratorVersion(),
LayerDigest: _layerDigest,
BinaryCount: _binaryCount,

View File

@@ -192,6 +192,17 @@ public enum ClaimStatus
/// </summary>
public sealed class BattlecardGenerator
{
private readonly TimeProvider _timeProvider;
/// <summary>
/// Creates a new battlecard generator.
/// </summary>
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
public BattlecardGenerator(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Generates a markdown battlecard from claims and metrics.
/// </summary>
@@ -201,7 +212,7 @@ public sealed class BattlecardGenerator
sb.AppendLine("# Stella Ops Scanner - Competitive Battlecard");
sb.AppendLine();
sb.AppendLine($"*Generated: {DateTimeOffset.UtcNow:yyyy-MM-dd HH:mm:ss} UTC*");
sb.AppendLine($"*Generated: {_timeProvider.GetUtcNow():yyyy-MM-dd HH:mm:ss} UTC*");
sb.AppendLine();
// Key Differentiators

View File

@@ -8,6 +8,17 @@ namespace StellaOps.Scanner.Benchmark.Metrics;
/// </summary>
public sealed class MetricsCalculator
{
private readonly TimeProvider _timeProvider;
/// <summary>
/// Creates a new metrics calculator.
/// </summary>
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
public MetricsCalculator(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Calculates metrics for a single image.
/// </summary>
@@ -49,7 +60,7 @@ public sealed class MetricsCalculator
FalsePositives = fp,
TrueNegatives = tn,
FalseNegatives = fn,
Timestamp = DateTimeOffset.UtcNow
Timestamp = _timeProvider.GetUtcNow()
};
}
@@ -74,7 +85,7 @@ public sealed class MetricsCalculator
TotalTrueNegatives = totalTn,
TotalFalseNegatives = totalFn,
PerImageMetrics = perImageMetrics,
Timestamp = DateTimeOffset.UtcNow
Timestamp = _timeProvider.GetUtcNow()
};
}

View File

@@ -513,8 +513,10 @@ public sealed class BinaryCallGraphExtractor : ICallGraphExtractor
var shStrTab = reader.ReadBytes((int)shStrTabSize);
// Find symbol and string tables for resolving names
// Note: symtab/strtab values are captured for future use with static symbols
long symtabOffset = 0, strtabOffset = 0;
long symtabSize = 0;
_ = (symtabOffset, strtabOffset, symtabSize); // Suppress unused warnings
int symtabEntrySize = is64Bit ? 24 : 16;
// Find .dynsym and .dynstr for dynamic relocations

View File

@@ -70,7 +70,8 @@ public sealed record EpssEvidence
double percentile,
DateOnly modelDate,
string? source = null,
bool fromCache = false)
bool fromCache = false,
TimeProvider? timeProvider = null)
{
return new EpssEvidence
{
@@ -78,7 +79,7 @@ public sealed record EpssEvidence
Score = score,
Percentile = percentile,
ModelDate = modelDate,
CapturedAt = DateTimeOffset.UtcNow,
CapturedAt = (timeProvider ?? TimeProvider.System).GetUtcNow(),
Source = source,
FromCache = fromCache
};

View File

@@ -334,6 +334,13 @@ public sealed record FindingContext
/// </summary>
public sealed class DefaultFalsificationConditionGenerator : IFalsificationConditionGenerator
{
private readonly TimeProvider _timeProvider;
public DefaultFalsificationConditionGenerator(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public FalsificationConditions Generate(FindingContext context)
{
var conditions = new List<FalsificationCondition>();
@@ -425,7 +432,7 @@ public sealed class DefaultFalsificationConditionGenerator : IFalsificationCondi
ComponentPurl = context.ComponentPurl,
Conditions = conditions.ToImmutableArray(),
Operator = FalsificationOperator.Any,
GeneratedAt = DateTimeOffset.UtcNow,
GeneratedAt = _timeProvider.GetUtcNow(),
Generator = "StellaOps.DefaultFalsificationGenerator/1.0"
};
}

View File

@@ -298,6 +298,13 @@ public interface IZeroDayWindowTracker
/// </summary>
public sealed class ZeroDayWindowCalculator
{
private readonly TimeProvider _timeProvider;
public ZeroDayWindowCalculator(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Computes the risk score for a window.
/// </summary>
@@ -326,7 +333,7 @@ public sealed class ZeroDayWindowCalculator
{
// Patch available but not applied
var hoursSincePatch = window.PatchAvailableAt.HasValue
? (DateTimeOffset.UtcNow - window.PatchAvailableAt.Value).TotalHours
? (_timeProvider.GetUtcNow() - window.PatchAvailableAt.Value).TotalHours
: 0;
score = hoursSincePatch switch
@@ -359,7 +366,7 @@ public sealed class ZeroDayWindowCalculator
return new ZeroDayWindowStats
{
ArtifactDigest = artifactDigest,
ComputedAt = DateTimeOffset.UtcNow,
ComputedAt = _timeProvider.GetUtcNow(),
TotalWindows = 0,
AggregateRiskScore = 0
};
@@ -390,7 +397,7 @@ public sealed class ZeroDayWindowCalculator
return new ZeroDayWindowStats
{
ArtifactDigest = artifactDigest,
ComputedAt = DateTimeOffset.UtcNow,
ComputedAt = _timeProvider.GetUtcNow(),
TotalWindows = windowList.Count,
ActiveWindows = windowList.Count(w =>
w.Status == ZeroDayWindowStatus.ActiveNoPatch ||
@@ -415,7 +422,7 @@ public sealed class ZeroDayWindowCalculator
DateTimeOffset? patchAvailableAt = null,
DateTimeOffset? remediatedAt = null)
{
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
var timeline = new List<WindowTimelineEvent>();
if (disclosedAt.HasValue)

View File

@@ -111,6 +111,7 @@ public sealed class ProofBundleWriterOptions
public sealed class ProofBundleWriter : IProofBundleWriter
{
private readonly ProofBundleWriterOptions _options;
private readonly TimeProvider _timeProvider;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
@@ -119,9 +120,10 @@ public sealed class ProofBundleWriter : IProofBundleWriter
PropertyNameCaseInsensitive = true
};
public ProofBundleWriter(ProofBundleWriterOptions? options = null)
public ProofBundleWriter(ProofBundleWriterOptions? options = null, TimeProvider? timeProvider = null)
{
_options = options ?? new ProofBundleWriterOptions();
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
@@ -134,7 +136,7 @@ public sealed class ProofBundleWriter : IProofBundleWriter
ArgumentNullException.ThrowIfNull(ledger);
var rootHash = ledger.RootHash();
var createdAt = DateTimeOffset.UtcNow;
var createdAt = _timeProvider.GetUtcNow();
// Ensure storage directory exists
Directory.CreateDirectory(_options.StorageBasePath);

View File

@@ -55,8 +55,8 @@ public sealed record ScanManifest(
/// <summary>
/// Create a manifest builder with required fields.
/// </summary>
public static ScanManifestBuilder CreateBuilder(string scanId, string artifactDigest) =>
new(scanId, artifactDigest);
public static ScanManifestBuilder CreateBuilder(string scanId, string artifactDigest, TimeProvider? timeProvider = null) =>
new(scanId, artifactDigest, timeProvider);
/// <summary>
/// Serialize to canonical JSON (for hashing).
@@ -99,7 +99,8 @@ public sealed class ScanManifestBuilder
{
private readonly string _scanId;
private readonly string _artifactDigest;
private DateTimeOffset _createdAtUtc = DateTimeOffset.UtcNow;
private readonly TimeProvider _timeProvider;
private DateTimeOffset? _createdAtUtc;
private string? _artifactPurl;
private string _scannerVersion = "1.0.0";
private string _workerVersion = "1.0.0";
@@ -110,10 +111,11 @@ public sealed class ScanManifestBuilder
private byte[] _seed = new byte[32];
private readonly Dictionary<string, string> _knobs = [];
internal ScanManifestBuilder(string scanId, string artifactDigest)
internal ScanManifestBuilder(string scanId, string artifactDigest, TimeProvider? timeProvider = null)
{
_scanId = scanId ?? throw new ArgumentNullException(nameof(scanId));
_artifactDigest = artifactDigest ?? throw new ArgumentNullException(nameof(artifactDigest));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public ScanManifestBuilder WithCreatedAt(DateTimeOffset createdAtUtc)
@@ -187,7 +189,7 @@ public sealed class ScanManifestBuilder
public ScanManifest Build() => new(
ScanId: _scanId,
CreatedAtUtc: _createdAtUtc,
CreatedAtUtc: _createdAtUtc ?? _timeProvider.GetUtcNow(),
ArtifactDigest: _artifactDigest,
ArtifactPurl: _artifactPurl,
ScannerVersion: _scannerVersion,

View File

@@ -77,11 +77,11 @@ public sealed record ManifestVerificationResult(
string? ErrorMessage = null,
string? KeyId = null)
{
public static ManifestVerificationResult Success(ScanManifest manifest, string? keyId = null) =>
new(true, manifest, DateTimeOffset.UtcNow, null, keyId);
public static ManifestVerificationResult Success(ScanManifest manifest, string? keyId = null, TimeProvider? timeProvider = null) =>
new(true, manifest, (timeProvider ?? TimeProvider.System).GetUtcNow(), null, keyId);
public static ManifestVerificationResult Failure(string error) =>
new(false, null, DateTimeOffset.UtcNow, error);
public static ManifestVerificationResult Failure(string error, TimeProvider? timeProvider = null) =>
new(false, null, (timeProvider ?? TimeProvider.System).GetUtcNow(), error);
}
/// <summary>

View File

@@ -0,0 +1,77 @@
// -----------------------------------------------------------------------------
// ISecretAlertDeduplicator.cs
// Sprint: SPRINT_20260104_007_BE (Secret Detection Alert Integration)
// Task: SDA-006 - Implement rate limiting / deduplication
// Description: Interface for deduplicating and rate-limiting secret alerts.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Core.Secrets.Alerts;
/// <summary>
/// Handles deduplication and rate limiting for secret alerts.
/// </summary>
/// <remarks>
/// Uses distributed cache (Valkey) to track:
/// - Recent alerts by deduplication key (prevents duplicate alerts)
/// - Alert count per scan (enforces per-scan rate limits)
/// Per SPRINT_20260104_007_BE task SDA-006.
/// </remarks>
public interface ISecretAlertDeduplicator
{
/// <summary>
/// Checks if an alert should be sent or is a duplicate.
/// </summary>
/// <param name="deduplicationKey">The deduplication key (from SecretFindingAlertEvent).</param>
/// <param name="window">Deduplication window (don't alert same key within this period).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if alert should be sent, false if duplicate.</returns>
Task<bool> ShouldAlertAsync(
string deduplicationKey,
TimeSpan window,
CancellationToken cancellationToken = default);
/// <summary>
/// Records that an alert was sent, for future deduplication.
/// </summary>
/// <param name="deduplicationKey">The deduplication key.</param>
/// <param name="window">How long to remember this alert.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task RecordAlertSentAsync(
string deduplicationKey,
TimeSpan window,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if scan has exceeded alert rate limit.
/// </summary>
/// <param name="scanId">Scan identifier.</param>
/// <param name="maxAlerts">Maximum alerts allowed for this scan.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if under limit, false if exceeded.</returns>
Task<bool> IsUnderRateLimitAsync(
Guid scanId,
int maxAlerts,
CancellationToken cancellationToken = default);
/// <summary>
/// Increments the alert count for a scan.
/// </summary>
/// <param name="scanId">Scan identifier.</param>
/// <param name="ttl">How long to keep the counter (should outlive scan duration).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>New alert count for the scan.</returns>
Task<int> IncrementScanAlertCountAsync(
Guid scanId,
TimeSpan ttl,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets current alert count for a scan.
/// </summary>
/// <param name="scanId">Scan identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Current alert count, 0 if not tracked.</returns>
Task<int> GetScanAlertCountAsync(
Guid scanId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,146 @@
// -----------------------------------------------------------------------------
// ISecretAlertEmitter.cs
// Sprint: SPRINT_20260104_007_BE (Secret Detection Alert Integration)
// Task: SDA-005 - Add alert emission to SecretsAnalyzerHost
// Description: Interface for emitting secret finding alerts to notification channels.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Core.Secrets.Alerts;
/// <summary>
/// Emits secret finding alerts to configured notification channels.
/// </summary>
/// <remarks>
/// Implementations handle routing to Slack, Teams, Email, Webhook, PagerDuty, etc.
/// Deduplication and rate limiting are handled by <see cref="ISecretAlertDeduplicator"/>.
/// Per SPRINT_20260104_007_BE task SDA-005.
/// </remarks>
public interface ISecretAlertEmitter
{
/// <summary>
/// Emits an alert for a secret finding.
/// </summary>
/// <param name="alert">The alert event to emit.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if alert was emitted to at least one channel, false if skipped.</returns>
/// <remarks>
/// Alert may be skipped due to:
/// - Alerting disabled for tenant
/// - Severity below threshold
/// - No matching destinations
/// - Rate limit exceeded
/// - Deduplicated (same secret alerted recently)
/// </remarks>
Task<AlertEmissionResult> EmitAsync(
SecretFindingAlertEvent alert,
CancellationToken cancellationToken = default);
/// <summary>
/// Emits alerts for multiple findings in a batch.
/// </summary>
/// <param name="alerts">The alert events to emit.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Results for each alert.</returns>
/// <remarks>
/// Batch processing respects per-scan rate limits.
/// </remarks>
Task<IReadOnlyList<AlertEmissionResult>> EmitBatchAsync(
IReadOnlyList<SecretFindingAlertEvent> alerts,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of an alert emission attempt.
/// </summary>
public sealed record AlertEmissionResult
{
/// <summary>
/// The alert event that was processed.
/// </summary>
public required Guid EventId { get; init; }
/// <summary>
/// Whether the alert was emitted to at least one channel.
/// </summary>
public required bool WasEmitted { get; init; }
/// <summary>
/// Channels the alert was sent to.
/// </summary>
public required IReadOnlyList<string> Channels { get; init; }
/// <summary>
/// Reason if alert was skipped.
/// </summary>
public AlertSkipReason? SkipReason { get; init; }
/// <summary>
/// Additional context about skip reason.
/// </summary>
public string? SkipDetails { get; init; }
/// <summary>
/// Creates a successful emission result.
/// </summary>
public static AlertEmissionResult Success(Guid eventId, IReadOnlyList<string> channels) => new()
{
EventId = eventId,
WasEmitted = true,
Channels = channels,
SkipReason = null,
SkipDetails = null
};
/// <summary>
/// Creates a skipped emission result.
/// </summary>
public static AlertEmissionResult Skipped(Guid eventId, AlertSkipReason reason, string? details = null) => new()
{
EventId = eventId,
WasEmitted = false,
Channels = [],
SkipReason = reason,
SkipDetails = details
};
}
/// <summary>
/// Reason why an alert was not emitted.
/// </summary>
public enum AlertSkipReason
{
/// <summary>
/// Alerting is disabled for the tenant.
/// </summary>
AlertingDisabled,
/// <summary>
/// Finding severity below minimum threshold.
/// </summary>
BelowSeverityThreshold,
/// <summary>
/// No alert destinations configured.
/// </summary>
NoDestinations,
/// <summary>
/// No destinations match the finding (by severity/category filters).
/// </summary>
NoMatchingDestinations,
/// <summary>
/// Rate limit exceeded for this scan.
/// </summary>
RateLimitExceeded,
/// <summary>
/// Same finding was alerted within deduplication window.
/// </summary>
Deduplicated,
/// <summary>
/// Alert emission failed.
/// </summary>
EmissionFailed
}

View File

@@ -0,0 +1,167 @@
// -----------------------------------------------------------------------------
// ISecretAlertRouter.cs
// Sprint: SPRINT_20260104_007_BE (Secret Detection Alert Integration)
// Task: SDA-007 - Add severity-based routing
// Description: Routes secret alerts to appropriate channels based on severity and filters.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Core.Secrets.Configuration;
namespace StellaOps.Scanner.Core.Secrets.Alerts;
/// <summary>
/// Routes secret alerts to appropriate notification channels based on severity and filters.
/// </summary>
/// <remarks>
/// Per SPRINT_20260104_007_BE task SDA-007.
/// Routing logic:
/// - Critical: Always alert, page on-call
/// - High: Alert to security channel
/// - Medium: Alert if configured
/// - Low: No alert by default
/// </remarks>
public interface ISecretAlertRouter
{
/// <summary>
/// Determines which destinations should receive an alert.
/// </summary>
/// <param name="alert">The alert event.</param>
/// <param name="settings">Tenant's alert settings.</param>
/// <returns>List of destinations that should receive the alert.</returns>
IReadOnlyList<SecretAlertDestination> RouteAlert(
SecretFindingAlertEvent alert,
SecretAlertSettings settings);
/// <summary>
/// Checks if an alert should be sent based on severity threshold.
/// </summary>
/// <param name="findingSeverity">Severity of the finding.</param>
/// <param name="minimumSeverity">Minimum severity required for alerting.</param>
/// <returns>True if finding meets severity threshold.</returns>
bool MeetsSeverityThreshold(SecretSeverity findingSeverity, SecretSeverity minimumSeverity);
}
/// <summary>
/// Default implementation of secret alert router.
/// </summary>
public sealed class SecretAlertRouter : ISecretAlertRouter
{
/// <inheritdoc />
public IReadOnlyList<SecretAlertDestination> RouteAlert(
SecretFindingAlertEvent alert,
SecretAlertSettings settings)
{
ArgumentNullException.ThrowIfNull(alert);
ArgumentNullException.ThrowIfNull(settings);
if (!settings.Enabled)
{
return [];
}
if (!MeetsSeverityThreshold(alert.Severity, settings.MinimumAlertSeverity))
{
return [];
}
var matchingDestinations = new List<SecretAlertDestination>();
foreach (var destination in settings.Destinations)
{
if (DestinationMatchesAlert(destination, alert))
{
matchingDestinations.Add(destination);
}
}
return matchingDestinations;
}
/// <inheritdoc />
public bool MeetsSeverityThreshold(SecretSeverity findingSeverity, SecretSeverity minimumSeverity)
{
// Higher severity value = more severe
return findingSeverity >= minimumSeverity;
}
/// <summary>
/// Checks if a destination matches the alert based on its filters.
/// </summary>
private static bool DestinationMatchesAlert(SecretAlertDestination destination, SecretFindingAlertEvent alert)
{
// Check severity filter if specified
if (destination.SeverityFilter is { Count: > 0 })
{
if (!destination.SeverityFilter.Contains(alert.Severity))
{
return false;
}
}
// Check category filter if specified
if (destination.RuleCategoryFilter is { Count: > 0 })
{
if (!destination.RuleCategoryFilter.Contains(alert.RuleCategory, StringComparer.OrdinalIgnoreCase))
{
return false;
}
}
return true;
}
}
/// <summary>
/// Extension methods for alert routing.
/// </summary>
public static class AlertRoutingExtensions
{
/// <summary>
/// Gets the default priority level for a severity.
/// </summary>
public static AlertPriority GetDefaultPriority(this SecretSeverity severity)
{
return severity switch
{
SecretSeverity.Critical => AlertPriority.P1Immediate,
SecretSeverity.High => AlertPriority.P2Urgent,
SecretSeverity.Medium => AlertPriority.P3Normal,
SecretSeverity.Low => AlertPriority.P4Info,
_ => AlertPriority.P4Info
};
}
/// <summary>
/// Determines if this severity should page on-call.
/// </summary>
public static bool ShouldPage(this SecretSeverity severity)
{
return severity == SecretSeverity.Critical;
}
}
/// <summary>
/// Alert priority levels for incident management integration.
/// </summary>
public enum AlertPriority
{
/// <summary>
/// P1: Immediate attention required, page on-call.
/// </summary>
P1Immediate = 1,
/// <summary>
/// P2: Urgent, requires prompt attention.
/// </summary>
P2Urgent = 2,
/// <summary>
/// P3: Normal priority, address in timely manner.
/// </summary>
P3Normal = 3,
/// <summary>
/// P4: Informational, for awareness only.
/// </summary>
P4Info = 4
}

View File

@@ -0,0 +1,222 @@
// -----------------------------------------------------------------------------
// SecretAlertEmitter.cs
// Sprint: SPRINT_20260104_007_BE (Secret Detection Alert Integration)
// Task: SDA-005 - Add alert emission to SecretsAnalyzerHost
// Description: Main implementation of secret alert emission with routing, deduplication, and rate limiting.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Core.Secrets.Configuration;
namespace StellaOps.Scanner.Core.Secrets.Alerts;
/// <summary>
/// Emits secret finding alerts with routing, deduplication, and rate limiting.
/// </summary>
/// <remarks>
/// Per SPRINT_20260104_007_BE task SDA-005.
/// Flow:
/// 1. Check if alerting is enabled for tenant
/// 2. Check severity threshold
/// 3. Route to matching destinations
/// 4. Check rate limit
/// 5. Check deduplication
/// 6. Emit to notification channels
/// </remarks>
public sealed class SecretAlertEmitter : ISecretAlertEmitter
{
private readonly ISecretAlertRouter _router;
private readonly ISecretAlertDeduplicator _deduplicator;
private readonly ISecretAlertChannelSender _channelSender;
private readonly ISecretAlertSettingsProvider _settingsProvider;
private readonly ILogger<SecretAlertEmitter> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="SecretAlertEmitter"/> class.
/// </summary>
public SecretAlertEmitter(
ISecretAlertRouter router,
ISecretAlertDeduplicator deduplicator,
ISecretAlertChannelSender channelSender,
ISecretAlertSettingsProvider settingsProvider,
ILogger<SecretAlertEmitter> logger)
{
_router = router ?? throw new ArgumentNullException(nameof(router));
_deduplicator = deduplicator ?? throw new ArgumentNullException(nameof(deduplicator));
_channelSender = channelSender ?? throw new ArgumentNullException(nameof(channelSender));
_settingsProvider = settingsProvider ?? throw new ArgumentNullException(nameof(settingsProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<AlertEmissionResult> EmitAsync(
SecretFindingAlertEvent alert,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(alert);
try
{
// Get tenant settings
var settings = await _settingsProvider.GetAlertSettingsAsync(alert.TenantId, cancellationToken);
if (settings is null || !settings.Enabled)
{
_logger.LogDebug(
"Alert skipped: alerting disabled for tenant {TenantId}",
alert.TenantId);
return AlertEmissionResult.Skipped(alert.EventId, AlertSkipReason.AlertingDisabled);
}
// Check severity threshold
if (!_router.MeetsSeverityThreshold(alert.Severity, settings.MinimumAlertSeverity))
{
_logger.LogDebug(
"Alert skipped: severity {Severity} below threshold {Threshold}",
alert.Severity,
settings.MinimumAlertSeverity);
return AlertEmissionResult.Skipped(
alert.EventId,
AlertSkipReason.BelowSeverityThreshold,
$"Finding severity {alert.Severity} is below minimum {settings.MinimumAlertSeverity}");
}
// Route to matching destinations
var destinations = _router.RouteAlert(alert, settings);
if (destinations.Count == 0)
{
_logger.LogDebug(
"Alert skipped: no matching destinations for {RuleCategory}/{Severity}",
alert.RuleCategory,
alert.Severity);
return AlertEmissionResult.Skipped(alert.EventId, AlertSkipReason.NoMatchingDestinations);
}
// Check rate limit
if (!await _deduplicator.IsUnderRateLimitAsync(alert.ScanId, settings.MaxAlertsPerScan, cancellationToken))
{
_logger.LogWarning(
"Alert skipped: rate limit exceeded for scan {ScanId} (max {MaxAlerts})",
alert.ScanId,
settings.MaxAlertsPerScan);
return AlertEmissionResult.Skipped(
alert.EventId,
AlertSkipReason.RateLimitExceeded,
$"Scan {alert.ScanId} exceeded {settings.MaxAlertsPerScan} alerts");
}
// Check deduplication
if (!await _deduplicator.ShouldAlertAsync(alert.DeduplicationKey, settings.DeduplicationWindow, cancellationToken))
{
_logger.LogDebug(
"Alert skipped: duplicate within {Window} for key {Key}",
settings.DeduplicationWindow,
alert.DeduplicationKey);
return AlertEmissionResult.Skipped(
alert.EventId,
AlertSkipReason.Deduplicated,
$"Same finding alerted within {settings.DeduplicationWindow}");
}
// Send to channels
var sentChannels = new List<string>();
foreach (var destination in destinations)
{
try
{
await _channelSender.SendAsync(alert, destination, settings, cancellationToken);
sentChannels.Add($"{destination.ChannelType}:{destination.Name}");
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Failed to send alert to {ChannelType}:{ChannelId}",
destination.ChannelType,
destination.ChannelId);
// Continue with other destinations
}
}
if (sentChannels.Count == 0)
{
return AlertEmissionResult.Skipped(alert.EventId, AlertSkipReason.EmissionFailed, "All channel sends failed");
}
// Record alert sent for deduplication and rate limiting
await _deduplicator.RecordAlertSentAsync(alert.DeduplicationKey, settings.DeduplicationWindow, cancellationToken);
await _deduplicator.IncrementScanAlertCountAsync(alert.ScanId, TimeSpan.FromHours(4), cancellationToken);
_logger.LogInformation(
"Alert emitted for {RuleId} ({Severity}) in {ImageRef} to {ChannelCount} channels",
alert.RuleId,
alert.Severity,
alert.ImageRef,
sentChannels.Count);
return AlertEmissionResult.Success(alert.EventId, sentChannels);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error emitting alert {EventId}", alert.EventId);
return AlertEmissionResult.Skipped(alert.EventId, AlertSkipReason.EmissionFailed, ex.Message);
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<AlertEmissionResult>> EmitBatchAsync(
IReadOnlyList<SecretFindingAlertEvent> alerts,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(alerts);
var results = new List<AlertEmissionResult>(alerts.Count);
foreach (var alert in alerts)
{
var result = await EmitAsync(alert, cancellationToken);
results.Add(result);
// Stop if rate limit hit
if (result.SkipReason == AlertSkipReason.RateLimitExceeded)
{
// Mark remaining as rate limited
foreach (var remaining in alerts.Skip(results.Count))
{
results.Add(AlertEmissionResult.Skipped(
remaining.EventId,
AlertSkipReason.RateLimitExceeded,
"Batch rate limit exceeded"));
}
break;
}
}
return results;
}
}
/// <summary>
/// Provides alert settings for a tenant.
/// </summary>
public interface ISecretAlertSettingsProvider
{
/// <summary>
/// Gets alert settings for a tenant.
/// </summary>
Task<SecretAlertSettings?> GetAlertSettingsAsync(Guid tenantId, CancellationToken cancellationToken = default);
}
/// <summary>
/// Sends alerts to notification channels.
/// </summary>
public interface ISecretAlertChannelSender
{
/// <summary>
/// Sends an alert to a specific channel.
/// </summary>
Task SendAsync(
SecretFindingAlertEvent alert,
SecretAlertDestination destination,
SecretAlertSettings settings,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,226 @@
// -----------------------------------------------------------------------------
// SecretFindingAlertEvent.cs
// Sprint: SPRINT_20260104_007_BE (Secret Detection Alert Integration)
// Task: SDA-002 - Create SecretFindingAlertEvent
// Description: Alert event for secret findings to be routed to notification channels.
// -----------------------------------------------------------------------------
using System.Globalization;
using StellaOps.Scanner.Core.Secrets.Configuration;
namespace StellaOps.Scanner.Core.Secrets.Alerts;
/// <summary>
/// Alert event emitted when a secret is detected in a scan.
/// Routed to configured notification channels based on severity and settings.
/// </summary>
/// <remarks>
/// Implements deterministic deduplication key for rate limiting.
/// Per SPRINT_20260104_007_BE task SDA-002.
/// </remarks>
public sealed record SecretFindingAlertEvent
{
/// <summary>
/// Unique identifier for this alert event.
/// </summary>
public required Guid EventId { get; init; }
/// <summary>
/// Tenant that owns the scanned image.
/// </summary>
public required Guid TenantId { get; init; }
/// <summary>
/// Scan job identifier.
/// </summary>
public required Guid ScanId { get; init; }
/// <summary>
/// Container image reference where secret was found.
/// Example: "registry.example.com/app:v1.2.3"
/// </summary>
public required string ImageRef { get; init; }
/// <summary>
/// Severity level of the finding.
/// </summary>
public required SecretSeverity Severity { get; init; }
/// <summary>
/// Detection rule identifier.
/// </summary>
public required string RuleId { get; init; }
/// <summary>
/// Human-readable rule name.
/// </summary>
public required string RuleName { get; init; }
/// <summary>
/// Rule category (e.g., "cloud_credentials", "api_keys").
/// </summary>
public required string RuleCategory { get; init; }
/// <summary>
/// File path within the image where secret was found.
/// </summary>
public required string FilePath { get; init; }
/// <summary>
/// Line number where secret was found (1-based).
/// </summary>
public required int LineNumber { get; init; }
/// <summary>
/// Masked representation of the detected secret value.
/// Always masked based on tenant's revelation policy.
/// </summary>
public required string MaskedValue { get; init; }
/// <summary>
/// When the secret was detected (UTC).
/// </summary>
public required DateTimeOffset DetectedAt { get; init; }
/// <summary>
/// Identity or source that triggered the scan.
/// </summary>
public required string ScanTriggeredBy { get; init; }
/// <summary>
/// Image digest for provenance.
/// </summary>
public string? ImageDigest { get; init; }
/// <summary>
/// Optional remediation guidance text.
/// </summary>
public string? RemediationGuidance { get; init; }
/// <summary>
/// Deep link URL to view the finding in StellaOps UI.
/// </summary>
public string? FindingUrl { get; init; }
/// <summary>
/// Deterministic deduplication key for rate limiting.
/// Based on tenant, rule, file, and line - ensures same finding doesn't alert twice.
/// </summary>
/// <remarks>
/// Format: "{TenantId}:{RuleId}:{FilePath}:{LineNumber}"
/// </remarks>
public string DeduplicationKey => string.Format(
CultureInfo.InvariantCulture,
"{0}:{1}:{2}:{3}",
TenantId,
RuleId,
FilePath,
LineNumber);
/// <summary>
/// Alternative deduplication key including image reference.
/// Use this when the same secret in different images should trigger separate alerts.
/// </summary>
public string DeduplicationKeyWithImage => string.Format(
CultureInfo.InvariantCulture,
"{0}:{1}:{2}:{3}:{4}",
TenantId,
ImageRef,
RuleId,
FilePath,
LineNumber);
/// <summary>
/// Creates an alert event from a secret finding.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="scanId">Scan job identifier.</param>
/// <param name="imageRef">Container image reference.</param>
/// <param name="finding">The secret finding details.</param>
/// <param name="maskedValue">Pre-masked value based on tenant policy.</param>
/// <param name="scanTriggeredBy">Identity that triggered the scan.</param>
/// <param name="eventId">Event identifier.</param>
/// <param name="detectedAt">Detection timestamp.</param>
/// <returns>A new alert event.</returns>
public static SecretFindingAlertEvent Create(
Guid tenantId,
Guid scanId,
string imageRef,
SecretFindingInfo finding,
string maskedValue,
string scanTriggeredBy,
Guid eventId,
DateTimeOffset detectedAt)
{
ArgumentException.ThrowIfNullOrWhiteSpace(imageRef);
ArgumentNullException.ThrowIfNull(finding);
ArgumentException.ThrowIfNullOrWhiteSpace(maskedValue);
ArgumentException.ThrowIfNullOrWhiteSpace(scanTriggeredBy);
return new SecretFindingAlertEvent
{
EventId = eventId,
TenantId = tenantId,
ScanId = scanId,
ImageRef = imageRef,
Severity = finding.Severity,
RuleId = finding.RuleId,
RuleName = finding.RuleName,
RuleCategory = finding.RuleCategory,
FilePath = finding.FilePath,
LineNumber = finding.LineNumber,
MaskedValue = maskedValue,
DetectedAt = detectedAt,
ScanTriggeredBy = scanTriggeredBy,
ImageDigest = finding.ImageDigest,
RemediationGuidance = finding.RemediationGuidance,
FindingUrl = null // Set by caller with appropriate URL
};
}
}
/// <summary>
/// Minimal finding info needed to create an alert event.
/// </summary>
public sealed record SecretFindingInfo
{
/// <summary>
/// Severity of the finding.
/// </summary>
public required SecretSeverity Severity { get; init; }
/// <summary>
/// Rule identifier.
/// </summary>
public required string RuleId { get; init; }
/// <summary>
/// Human-readable rule name.
/// </summary>
public required string RuleName { get; init; }
/// <summary>
/// Rule category.
/// </summary>
public required string RuleCategory { get; init; }
/// <summary>
/// File path where secret was found.
/// </summary>
public required string FilePath { get; init; }
/// <summary>
/// Line number (1-based).
/// </summary>
public required int LineNumber { get; init; }
/// <summary>
/// Image digest for provenance.
/// </summary>
public string? ImageDigest { get; init; }
/// <summary>
/// Remediation guidance.
/// </summary>
public string? RemediationGuidance { get; init; }
}

View File

@@ -0,0 +1,239 @@
// -----------------------------------------------------------------------------
// SecretAlertSettings.cs
// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
// Task: SDC-001 - Define SecretDetectionSettings domain model (alert portion)
// Description: Configuration for secret detection alerting.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Core.Secrets.Configuration;
/// <summary>
/// Severity levels for secret detection rules.
/// </summary>
public enum SecretSeverity
{
/// <summary>
/// Informational finding, lowest priority.
/// </summary>
Low = 0,
/// <summary>
/// Moderate risk, should be reviewed.
/// </summary>
Medium = 1,
/// <summary>
/// Significant risk, should be addressed promptly.
/// </summary>
High = 2,
/// <summary>
/// Critical risk, requires immediate attention.
/// </summary>
Critical = 3
}
/// <summary>
/// Alert channel types supported for secret notifications.
/// </summary>
public enum AlertChannelType
{
/// <summary>
/// Slack workspace channel.
/// </summary>
Slack = 0,
/// <summary>
/// Microsoft Teams channel.
/// </summary>
Teams = 1,
/// <summary>
/// Email notification.
/// </summary>
Email = 2,
/// <summary>
/// Generic webhook endpoint.
/// </summary>
Webhook = 3,
/// <summary>
/// PagerDuty incident.
/// </summary>
PagerDuty = 4
}
/// <summary>
/// Configuration for secret detection alerting.
/// </summary>
public sealed record SecretAlertSettings
{
/// <summary>
/// Enable/disable alerting for this tenant.
/// </summary>
public bool Enabled { get; init; } = false;
/// <summary>
/// Minimum severity to trigger an alert.
/// </summary>
public SecretSeverity MinimumAlertSeverity { get; init; } = SecretSeverity.High;
/// <summary>
/// Alert destinations by channel type.
/// </summary>
public IReadOnlyList<SecretAlertDestination> Destinations { get; init; } = [];
/// <summary>
/// Maximum alerts to send per scan (rate limiting).
/// </summary>
public int MaxAlertsPerScan { get; init; } = 10;
/// <summary>
/// Don't re-alert for same secret within this window.
/// </summary>
public TimeSpan DeduplicationWindow { get; init; } = TimeSpan.FromHours(24);
/// <summary>
/// Include file path in alert message.
/// </summary>
public bool IncludeFilePath { get; init; } = true;
/// <summary>
/// Include masked secret value in alert message.
/// </summary>
public bool IncludeMaskedValue { get; init; } = true;
/// <summary>
/// Include image reference in alert message.
/// </summary>
public bool IncludeImageRef { get; init; } = true;
/// <summary>
/// Custom message prefix for alerts.
/// </summary>
public string? AlertMessagePrefix { get; init; }
/// <summary>
/// Validates the alert settings.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (MaxAlertsPerScan < 1 || MaxAlertsPerScan > 100)
{
errors.Add("MaxAlertsPerScan must be between 1 and 100");
}
if (DeduplicationWindow < TimeSpan.FromMinutes(5))
{
errors.Add("DeduplicationWindow must be at least 5 minutes");
}
if (DeduplicationWindow > TimeSpan.FromDays(7))
{
errors.Add("DeduplicationWindow must be 7 days or less");
}
if (Enabled && Destinations.Count == 0)
{
errors.Add("At least one destination is required when alerting is enabled");
}
foreach (var dest in Destinations)
{
errors.AddRange(dest.Validate().Select(e => $"Destination '{dest.Name}': {e}"));
}
return errors;
}
/// <summary>
/// Creates default alert settings (disabled).
/// </summary>
public static SecretAlertSettings Default => new();
}
/// <summary>
/// Defines an alert destination for secret findings.
/// </summary>
public sealed record SecretAlertDestination
{
/// <summary>
/// Unique identifier for this destination.
/// </summary>
public required Guid Id { get; init; }
/// <summary>
/// Human-readable name for the destination.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Channel type (Slack, Teams, Email, etc.).
/// </summary>
public required AlertChannelType ChannelType { get; init; }
/// <summary>
/// Channel identifier (Slack channel ID, email address, webhook URL).
/// </summary>
public required string ChannelId { get; init; }
/// <summary>
/// Optional: Only alert for these severities.
/// If empty, respects MinimumAlertSeverity from parent settings.
/// </summary>
public IReadOnlyList<SecretSeverity>? SeverityFilter { get; init; }
/// <summary>
/// Optional: Only alert for these rule categories.
/// If empty, alerts for all categories.
/// </summary>
public IReadOnlyList<string>? RuleCategoryFilter { get; init; }
/// <summary>
/// Whether this destination is currently active.
/// </summary>
public bool IsActive { get; init; } = true;
/// <summary>
/// Validates the destination configuration.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(Name))
{
errors.Add("Name is required");
}
if (string.IsNullOrWhiteSpace(ChannelId))
{
errors.Add("ChannelId is required");
}
else
{
// Validate channel ID format based on type
switch (ChannelType)
{
case AlertChannelType.Email:
if (!ChannelId.Contains('@'))
{
errors.Add("Email address must contain @");
}
break;
case AlertChannelType.Webhook:
if (!Uri.TryCreate(ChannelId, UriKind.Absolute, out var uri) ||
(uri.Scheme != "https" && uri.Scheme != "http"))
{
errors.Add("Webhook must be a valid HTTP(S) URL");
}
break;
}
}
return errors;
}
}

View File

@@ -0,0 +1,194 @@
// -----------------------------------------------------------------------------
// SecretDetectionSettings.cs
// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
// Task: SDC-001 - Define SecretDetectionSettings domain model
// Description: Per-tenant configuration for secret detection behavior.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Core.Secrets.Configuration;
/// <summary>
/// Per-tenant configuration for secret detection.
/// Controls all aspects of secret leak detection including revelation policy,
/// enabled rules, exceptions, and alerting.
/// </summary>
public sealed record SecretDetectionSettings
{
/// <summary>
/// Tenant this configuration belongs to.
/// </summary>
public required Guid TenantId { get; init; }
/// <summary>
/// Whether secret detection is enabled for this tenant.
/// </summary>
public bool Enabled { get; init; } = false;
/// <summary>
/// Revelation policy configuration controlling how secrets are masked/shown.
/// </summary>
public RevelationPolicyConfig RevelationPolicy { get; init; } = RevelationPolicyConfig.Default;
/// <summary>
/// Enabled rule categories. Empty means all categories enabled.
/// Examples: "aws", "gcp", "azure", "generic", "private-keys", "database"
/// </summary>
public IReadOnlyList<string> EnabledRuleCategories { get; init; } = [];
/// <summary>
/// Disabled rule IDs (overrides category enablement).
/// </summary>
public IReadOnlyList<string> DisabledRuleIds { get; init; } = [];
/// <summary>
/// Exception patterns for suppressing false positives.
/// </summary>
public IReadOnlyList<SecretExceptionPattern> Exceptions { get; init; } = [];
/// <summary>
/// Alert configuration for secret findings.
/// </summary>
public SecretAlertSettings AlertSettings { get; init; } = SecretAlertSettings.Default;
/// <summary>
/// Maximum file size to scan for secrets (bytes).
/// Files larger than this are skipped.
/// </summary>
public long MaxFileSizeBytes { get; init; } = 10 * 1024 * 1024; // 10 MB
/// <summary>
/// File extensions to exclude from scanning.
/// </summary>
public IReadOnlyList<string> ExcludedFileExtensions { get; init; } = [".exe", ".dll", ".so", ".dylib", ".bin", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".woff", ".woff2", ".ttf", ".eot"];
/// <summary>
/// Path patterns to exclude from scanning (glob patterns).
/// </summary>
public IReadOnlyList<string> ExcludedPaths { get; init; } = ["**/node_modules/**", "**/vendor/**", "**/.git/**"];
/// <summary>
/// Whether to scan binary files (slower, may have false positives).
/// </summary>
public bool ScanBinaryFiles { get; init; } = false;
/// <summary>
/// Whether to require signature verification for rule bundles.
/// </summary>
public bool RequireSignedRuleBundles { get; init; } = true;
/// <summary>
/// When this configuration was last updated.
/// </summary>
public required DateTimeOffset UpdatedAt { get; init; }
/// <summary>
/// Who last updated this configuration.
/// </summary>
public required string UpdatedBy { get; init; }
/// <summary>
/// Version number for optimistic concurrency.
/// </summary>
public int Version { get; init; } = 1;
/// <summary>
/// Validates the entire configuration.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
// Validate revelation policy
errors.AddRange(RevelationPolicy.Validate().Select(e => $"RevelationPolicy: {e}"));
// Validate alert settings
errors.AddRange(AlertSettings.Validate().Select(e => $"AlertSettings: {e}"));
// Validate exceptions
var exceptionNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var exception in Exceptions)
{
if (!exceptionNames.Add(exception.Name))
{
errors.Add($"Duplicate exception name: {exception.Name}");
}
errors.AddRange(exception.Validate().Select(e => $"Exception '{exception.Name}': {e}"));
}
// Validate file size limit
if (MaxFileSizeBytes < 1024) // 1 KB minimum
{
errors.Add("MaxFileSizeBytes must be at least 1024 bytes");
}
if (MaxFileSizeBytes > 100 * 1024 * 1024) // 100 MB maximum
{
errors.Add("MaxFileSizeBytes must be 100 MB or less");
}
return errors;
}
/// <summary>
/// Creates default settings for a new tenant.
/// </summary>
public static SecretDetectionSettings CreateDefault(Guid tenantId, string createdBy, TimeProvider? timeProvider = null) => new()
{
TenantId = tenantId,
Enabled = false,
RevelationPolicy = RevelationPolicyConfig.Default,
AlertSettings = SecretAlertSettings.Default,
UpdatedAt = (timeProvider ?? TimeProvider.System).GetUtcNow(),
UpdatedBy = createdBy
};
/// <summary>
/// Creates a copy with updated timestamp and user.
/// </summary>
public SecretDetectionSettings WithUpdate(string updatedBy, TimeProvider? timeProvider = null) => this with
{
UpdatedAt = (timeProvider ?? TimeProvider.System).GetUtcNow(),
UpdatedBy = updatedBy,
Version = Version + 1
};
}
/// <summary>
/// Available rule categories for secret detection.
/// </summary>
public static class SecretRuleCategories
{
public const string Aws = "aws";
public const string Gcp = "gcp";
public const string Azure = "azure";
public const string Generic = "generic";
public const string PrivateKeys = "private-keys";
public const string Database = "database";
public const string Messaging = "messaging";
public const string Payment = "payment";
public const string SocialMedia = "social-media";
public const string Internal = "internal";
public static readonly IReadOnlyList<string> All =
[
Aws,
Gcp,
Azure,
Generic,
PrivateKeys,
Database,
Messaging,
Payment,
SocialMedia,
Internal
];
public static readonly IReadOnlyList<string> DefaultEnabled =
[
Aws,
Gcp,
Azure,
Generic,
PrivateKeys,
Database
];
}

View File

@@ -0,0 +1,196 @@
// -----------------------------------------------------------------------------
// SecretExceptionMatcher.cs
// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
// Task: SDC-007 - Integrate exception patterns into SecretsAnalyzerHost
// Description: Service for matching secret findings against exception patterns.
// -----------------------------------------------------------------------------
using System.Text.RegularExpressions;
using Microsoft.Extensions.FileSystemGlobbing;
namespace StellaOps.Scanner.Core.Secrets.Configuration;
/// <summary>
/// Service for matching secret findings against exception patterns.
/// Determines whether a finding should be suppressed based on configured exceptions.
/// </summary>
public sealed class SecretExceptionMatcher
{
private readonly IReadOnlyList<CompiledExceptionPattern> _compiledPatterns;
private readonly TimeProvider _timeProvider;
public SecretExceptionMatcher(
IEnumerable<SecretExceptionPattern> patterns,
TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_compiledPatterns = CompilePatterns(patterns);
}
/// <summary>
/// Checks if a finding matches any exception pattern.
/// </summary>
/// <param name="secretValue">The detected secret value.</param>
/// <param name="ruleId">The rule ID that triggered the finding.</param>
/// <param name="filePath">The file path where the secret was found.</param>
/// <returns>Match result indicating if the finding is excepted.</returns>
public ExceptionMatchResult Match(string secretValue, string ruleId, string filePath)
{
var now = _timeProvider.GetUtcNow();
foreach (var compiled in _compiledPatterns)
{
if (!compiled.Pattern.IsEffective(now))
{
continue;
}
// Check rule ID filter
if (compiled.Pattern.ApplicableRuleIds.Count > 0 &&
!compiled.Pattern.ApplicableRuleIds.Contains(ruleId, StringComparer.OrdinalIgnoreCase))
{
continue;
}
// Check file path glob
if (!string.IsNullOrEmpty(compiled.Pattern.FilePathGlob))
{
if (!MatchesGlob(filePath, compiled.Pattern.FilePathGlob))
{
continue;
}
}
// Check value pattern
try
{
if (compiled.ValueRegex.IsMatch(secretValue))
{
return ExceptionMatchResult.Excepted(compiled.Pattern);
}
}
catch (RegexMatchTimeoutException)
{
// Pattern timed out - treat as non-match for safety
continue;
}
}
return ExceptionMatchResult.NotExcepted("No exception pattern matched");
}
/// <summary>
/// Creates an empty matcher with no patterns.
/// </summary>
public static SecretExceptionMatcher Empty => new([]);
private static IReadOnlyList<CompiledExceptionPattern> CompilePatterns(
IEnumerable<SecretExceptionPattern> patterns)
{
var compiled = new List<CompiledExceptionPattern>();
foreach (var pattern in patterns)
{
try
{
var regex = new Regex(
pattern.ValuePattern,
RegexOptions.Compiled | RegexOptions.Singleline,
TimeSpan.FromSeconds(1));
compiled.Add(new CompiledExceptionPattern(pattern, regex));
}
catch (RegexParseException)
{
// Invalid pattern - skip it
// In production, this should be logged
}
}
return compiled;
}
private static bool MatchesGlob(string filePath, string globPattern)
{
try
{
var matcher = new Matcher();
matcher.AddInclude(globPattern);
// Normalize path separators
var normalizedPath = filePath.Replace('\\', '/');
// Match against the file name and relative path components
var result = matcher.Match(normalizedPath);
return result.HasMatches;
}
catch
{
// Invalid glob - treat as non-match
return false;
}
}
private sealed record CompiledExceptionPattern(
SecretExceptionPattern Pattern,
Regex ValueRegex);
}
/// <summary>
/// Provider interface for loading exception patterns for a tenant.
/// </summary>
public interface ISecretExceptionProvider
{
/// <summary>
/// Gets the active exception patterns for a tenant.
/// </summary>
Task<IReadOnlyList<SecretExceptionPattern>> GetExceptionsAsync(
Guid tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// Records that an exception pattern matched a finding.
/// </summary>
Task RecordMatchAsync(
Guid tenantId,
Guid exceptionId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// In-memory implementation of exception provider for testing.
/// </summary>
public sealed class InMemorySecretExceptionProvider : ISecretExceptionProvider
{
private readonly Dictionary<Guid, List<SecretExceptionPattern>> _exceptions = [];
public void AddException(Guid tenantId, SecretExceptionPattern exception)
{
if (!_exceptions.TryGetValue(tenantId, out var list))
{
list = [];
_exceptions[tenantId] = list;
}
list.Add(exception);
}
public Task<IReadOnlyList<SecretExceptionPattern>> GetExceptionsAsync(
Guid tenantId,
CancellationToken cancellationToken = default)
{
if (_exceptions.TryGetValue(tenantId, out var list))
{
return Task.FromResult<IReadOnlyList<SecretExceptionPattern>>(list);
}
return Task.FromResult<IReadOnlyList<SecretExceptionPattern>>([]);
}
public Task RecordMatchAsync(
Guid tenantId,
Guid exceptionId,
CancellationToken cancellationToken = default)
{
// No-op for in-memory implementation
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,183 @@
// -----------------------------------------------------------------------------
// SecretExceptionPattern.cs
// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
// Task: SDC-003 - Create SecretExceptionPattern model for allowlists
// Description: Defines patterns for excluding false positive secret detections.
// -----------------------------------------------------------------------------
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Core.Secrets.Configuration;
/// <summary>
/// Defines a pattern for excluding detected secrets from findings (allowlist).
/// Used to suppress false positives or known-safe patterns.
/// </summary>
public sealed record SecretExceptionPattern
{
/// <summary>
/// Unique identifier for this exception.
/// </summary>
public required Guid Id { get; init; }
/// <summary>
/// Human-readable name for the exception.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Detailed description of why this exception exists.
/// </summary>
public required string Description { get; init; }
/// <summary>
/// Regex pattern to match against detected secret value.
/// Use anchors (^ $) for exact matches.
/// </summary>
public required string ValuePattern { get; init; }
/// <summary>
/// Optional: Only apply to specific rule IDs.
/// If empty, applies to all rules.
/// </summary>
public IReadOnlyList<string> ApplicableRuleIds { get; init; } = [];
/// <summary>
/// Optional: Only apply to files matching this glob pattern.
/// Example: "**/test/**", "*.test.ts"
/// </summary>
public string? FilePathGlob { get; init; }
/// <summary>
/// Business justification for this exception (required for audit).
/// </summary>
public required string Justification { get; init; }
/// <summary>
/// Expiration date. Null means permanent (requires periodic review).
/// </summary>
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// Whether this exception is currently active.
/// </summary>
public bool IsActive { get; init; } = true;
/// <summary>
/// When this exception was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Who created this exception.
/// </summary>
public required string CreatedBy { get; init; }
/// <summary>
/// When this exception was last modified.
/// </summary>
public DateTimeOffset? UpdatedAt { get; init; }
/// <summary>
/// Who last modified this exception.
/// </summary>
public string? UpdatedBy { get; init; }
/// <summary>
/// Number of times this exception has matched a finding.
/// </summary>
public long MatchCount { get; init; }
/// <summary>
/// Last time this exception matched a finding.
/// </summary>
public DateTimeOffset? LastMatchedAt { get; init; }
/// <summary>
/// Checks if this exception has expired.
/// </summary>
public bool IsExpired(DateTimeOffset now) =>
ExpiresAt.HasValue && now > ExpiresAt.Value;
/// <summary>
/// Checks if this exception is currently effective.
/// </summary>
public bool IsEffective(DateTimeOffset now) =>
IsActive && !IsExpired(now);
/// <summary>
/// Validates the exception pattern.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(Name))
{
errors.Add("Name is required");
}
else if (Name.Length > 100)
{
errors.Add("Name must be 100 characters or less");
}
if (string.IsNullOrWhiteSpace(ValuePattern))
{
errors.Add("ValuePattern is required");
}
else
{
try
{
_ = new Regex(ValuePattern, RegexOptions.None, TimeSpan.FromSeconds(1));
}
catch (RegexParseException ex)
{
errors.Add($"ValuePattern is not a valid regex: {ex.Message}");
}
}
if (string.IsNullOrWhiteSpace(Justification))
{
errors.Add("Justification is required");
}
else if (Justification.Length < 20)
{
errors.Add("Justification must be at least 20 characters");
}
if (ExpiresAt.HasValue && ExpiresAt.Value < CreatedAt)
{
errors.Add("ExpiresAt must be after CreatedAt");
}
return errors;
}
}
/// <summary>
/// Result of matching an exception pattern against a finding.
/// </summary>
public sealed record ExceptionMatchResult
{
/// <summary>
/// Whether any exception matched.
/// </summary>
public required bool IsExcepted { get; init; }
/// <summary>
/// The exception that matched, if any.
/// </summary>
public SecretExceptionPattern? MatchedException { get; init; }
/// <summary>
/// Reason for the match or non-match.
/// </summary>
public string? Reason { get; init; }
public static ExceptionMatchResult NotExcepted(string reason) =>
new() { IsExcepted = false, Reason = reason };
public static ExceptionMatchResult Excepted(SecretExceptionPattern exception) =>
new() { IsExcepted = true, MatchedException = exception, Reason = $"Matched exception: {exception.Name}" };
}

View File

@@ -0,0 +1,114 @@
// -----------------------------------------------------------------------------
// SecretRevelationPolicy.cs
// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
// Task: SDC-002 - Create SecretRevelationPolicy enum and config
// Description: Controls how detected secrets are displayed/masked in different contexts.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Core.Secrets.Configuration;
/// <summary>
/// Defines how detected secret values are revealed or masked.
/// </summary>
public enum SecretRevelationPolicy
{
/// <summary>
/// Show only that a secret was detected, no value shown.
/// Example: [REDACTED]
/// </summary>
FullMask = 0,
/// <summary>
/// Show first and last N characters (configurable).
/// Example: AKIA****WXYZ
/// </summary>
PartialReveal = 1,
/// <summary>
/// Show full value. Requires elevated permissions and is audit-logged.
/// Use only for debugging/incident response.
/// </summary>
FullReveal = 2
}
/// <summary>
/// Configuration for secret revelation across different contexts.
/// </summary>
public sealed record RevelationPolicyConfig
{
/// <summary>
/// Default policy for UI/API responses.
/// </summary>
public SecretRevelationPolicy DefaultPolicy { get; init; } = SecretRevelationPolicy.PartialReveal;
/// <summary>
/// Policy for exported reports (PDF, JSON, SARIF).
/// </summary>
public SecretRevelationPolicy ExportPolicy { get; init; } = SecretRevelationPolicy.FullMask;
/// <summary>
/// Policy for logs and telemetry. Always enforced as FullMask regardless of setting.
/// </summary>
public SecretRevelationPolicy LogPolicy { get; init; } = SecretRevelationPolicy.FullMask;
/// <summary>
/// Roles allowed to use FullReveal policy.
/// </summary>
public IReadOnlyList<string> FullRevealRoles { get; init; } = ["security-admin", "incident-responder"];
/// <summary>
/// Number of characters to show at start and end for PartialReveal.
/// </summary>
public int PartialRevealChars { get; init; } = 4;
/// <summary>
/// Maximum characters to show in masked portion for PartialReveal.
/// </summary>
public int MaxMaskChars { get; init; } = 8;
/// <summary>
/// Whether to require explicit user action to reveal (even partial).
/// </summary>
public bool RequireExplicitReveal { get; init; } = false;
/// <summary>
/// Validates the configuration.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (PartialRevealChars < 1 || PartialRevealChars > 10)
{
errors.Add("PartialRevealChars must be between 1 and 10");
}
if (MaxMaskChars < 1 || MaxMaskChars > 20)
{
errors.Add("MaxMaskChars must be between 1 and 20");
}
if (FullRevealRoles.Count == 0 && DefaultPolicy == SecretRevelationPolicy.FullReveal)
{
errors.Add("FullRevealRoles must not be empty when DefaultPolicy is FullReveal");
}
return errors;
}
/// <summary>
/// Creates a default secure configuration.
/// </summary>
public static RevelationPolicyConfig Default => new();
/// <summary>
/// Creates a strict configuration with maximum masking.
/// </summary>
public static RevelationPolicyConfig Strict => new()
{
DefaultPolicy = SecretRevelationPolicy.FullMask,
ExportPolicy = SecretRevelationPolicy.FullMask,
LogPolicy = SecretRevelationPolicy.FullMask,
RequireExplicitReveal = true
};
}

View File

@@ -0,0 +1,164 @@
// -----------------------------------------------------------------------------
// SecretMasker.cs
// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
// Task: SDC-008 - Implement revelation policy in findings output
// Description: Utility for masking secret values based on revelation policy.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Core.Secrets.Configuration;
namespace StellaOps.Scanner.Core.Secrets.Masking;
/// <summary>
/// Utility for masking secret values based on revelation policy.
/// Thread-safe and stateless.
/// </summary>
public static class SecretMasker
{
/// <summary>
/// Default mask character.
/// </summary>
public const char MaskChar = '*';
/// <summary>
/// Placeholder for fully masked secrets.
/// </summary>
public const string RedactedPlaceholder = "[REDACTED]";
/// <summary>
/// Masks a secret value according to the specified policy.
/// </summary>
/// <param name="secretValue">The secret value to mask.</param>
/// <param name="policy">The revelation policy to apply.</param>
/// <param name="partialChars">Number of characters to reveal at start/end for partial reveal.</param>
/// <param name="maxMaskChars">Maximum number of mask characters for partial reveal.</param>
/// <returns>The masked value.</returns>
public static string Mask(
string secretValue,
SecretRevelationPolicy policy,
int partialChars = 4,
int maxMaskChars = 8)
{
if (string.IsNullOrEmpty(secretValue))
{
return RedactedPlaceholder;
}
return policy switch
{
SecretRevelationPolicy.FullMask => RedactedPlaceholder,
SecretRevelationPolicy.PartialReveal => MaskPartial(secretValue, partialChars, maxMaskChars),
SecretRevelationPolicy.FullReveal => secretValue,
_ => RedactedPlaceholder
};
}
/// <summary>
/// Masks a secret value using the provided policy configuration.
/// </summary>
/// <param name="secretValue">The secret value to mask.</param>
/// <param name="config">The revelation policy configuration.</param>
/// <param name="context">The context (default, export, log) to use.</param>
/// <returns>The masked value.</returns>
public static string Mask(
string secretValue,
RevelationPolicyConfig config,
MaskingContext context = MaskingContext.Default)
{
var policy = context switch
{
MaskingContext.Default => config.DefaultPolicy,
MaskingContext.Export => config.ExportPolicy,
MaskingContext.Log => SecretRevelationPolicy.FullMask, // Always enforce full mask for logs
_ => config.DefaultPolicy
};
return Mask(secretValue, policy, config.PartialRevealChars, config.MaxMaskChars);
}
/// <summary>
/// Partially masks a value, showing first and last N characters.
/// </summary>
private static string MaskPartial(string value, int revealChars, int maxMaskChars)
{
if (value.Length <= revealChars * 2)
{
// Value too short - mask entirely
return new string(MaskChar, value.Length);
}
var prefix = value[..revealChars];
var suffix = value[^revealChars..];
var hiddenLength = value.Length - (revealChars * 2);
var maskLength = Math.Min(hiddenLength, maxMaskChars);
var masked = new string(MaskChar, maskLength);
return $"{prefix}{masked}{suffix}";
}
/// <summary>
/// Creates a safe string representation for logging.
/// Never reveals more than type information.
/// </summary>
/// <param name="secretType">The type of secret detected.</param>
/// <param name="valueLength">Length of the original value.</param>
/// <returns>Safe log message.</returns>
public static string ForLog(string secretType, int valueLength)
{
return $"[SECRET_DETECTED: {secretType}, length={valueLength}]";
}
/// <summary>
/// Checks if a string appears to be already masked.
/// </summary>
public static bool IsMasked(string value)
{
if (string.IsNullOrEmpty(value))
{
return false;
}
return value == RedactedPlaceholder ||
value.Contains(MaskChar) ||
value.StartsWith("[SECRET_DETECTED:", StringComparison.Ordinal);
}
/// <summary>
/// Masks all occurrences of a secret in a larger text.
/// </summary>
/// <param name="text">The text containing secrets.</param>
/// <param name="secretValue">The secret value to mask.</param>
/// <param name="policy">The revelation policy to apply.</param>
/// <returns>Text with secrets masked.</returns>
public static string MaskInText(string text, string secretValue, SecretRevelationPolicy policy)
{
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(secretValue))
{
return text;
}
var masked = Mask(secretValue, policy);
return text.Replace(secretValue, masked, StringComparison.Ordinal);
}
}
/// <summary>
/// Context for determining which masking policy to apply.
/// </summary>
public enum MaskingContext
{
/// <summary>
/// Default context (UI/API responses).
/// </summary>
Default = 0,
/// <summary>
/// Export context (reports, SARIF, JSON exports).
/// </summary>
Export = 1,
/// <summary>
/// Log context (always fully masked).
/// </summary>
Log = 2
}

View File

@@ -21,7 +21,7 @@ public sealed record ComponentDiffRequest
public SbomView View { get; init; } = SbomView.Inventory;
public DateTimeOffset GeneratedAt { get; init; } = DateTimeOffset.UtcNow;
public required DateTimeOffset GeneratedAt { get; init; }
public string? OldImageDigest { get; init; }
= null;

View File

@@ -105,13 +105,16 @@ public sealed class CbomAggregationService : ICbomAggregationService
{
private readonly IEnumerable<ICryptoAssetExtractor> _extractors;
private readonly ILogger<CbomAggregationService> _logger;
private readonly TimeProvider _timeProvider;
public CbomAggregationService(
IEnumerable<ICryptoAssetExtractor> extractors,
ILogger<CbomAggregationService> logger)
ILogger<CbomAggregationService> logger,
TimeProvider? timeProvider = null)
{
_extractors = extractors;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<CbomAggregationResult> AggregateAsync(
@@ -167,7 +170,7 @@ public sealed class CbomAggregationService : ICbomAggregationService
ByComponent = byComponentImmutable,
UniqueAlgorithms = uniqueAlgorithms,
RiskAssessment = AssessRisk(assetsArray),
GeneratedAt = DateTimeOffset.UtcNow.ToString("o")
GeneratedAt = _timeProvider.GetUtcNow().ToString("o")
};
}

View File

@@ -17,7 +17,7 @@ public sealed record BomIndexBuildRequest
public required ComponentGraph Graph { get; init; }
public DateTimeOffset GeneratedAt { get; init; } = DateTimeOffset.UtcNow;
public required DateTimeOffset GeneratedAt { get; init; }
}
public sealed record BomIndexArtifact

View File

@@ -10,6 +10,13 @@ namespace StellaOps.Scanner.Emit.Lineage;
/// </summary>
public sealed class SbomDiffEngine
{
private readonly TimeProvider _timeProvider;
public SbomDiffEngine(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Computes the semantic diff between two SBOMs.
/// </summary>
@@ -115,7 +122,7 @@ public sealed class SbomDiffEngine
Unchanged = unchanged,
IsBreaking = isBreaking
},
ComputedAt = DateTimeOffset.UtcNow
ComputedAt = _timeProvider.GetUtcNow()
};
}

View File

@@ -57,11 +57,13 @@ public interface IBaselineAnalyzer
public sealed class BaselineAnalyzer : IBaselineAnalyzer
{
private readonly ILogger<BaselineAnalyzer> _logger;
private readonly TimeProvider _timeProvider;
private readonly Dictionary<string, Regex> _compiledPatterns = new();
public BaselineAnalyzer(ILogger<BaselineAnalyzer> logger)
public BaselineAnalyzer(ILogger<BaselineAnalyzer> logger, TimeProvider? timeProvider = null)
{
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<BaselineReport> AnalyzeAsync(
@@ -97,7 +99,7 @@ public sealed class BaselineAnalyzer : IBaselineAnalyzer
{
ReportId = Guid.NewGuid(),
ScanId = context.ScanId,
GeneratedAt = DateTimeOffset.UtcNow,
GeneratedAt = _timeProvider.GetUtcNow(),
ConfigUsed = context.Config.ConfigId,
EntryPoints = entryPoints.ToImmutableArray(),
Statistics = statistics,

View File

@@ -56,7 +56,8 @@ public sealed record BinaryAnalysisResult(
string binaryPath,
string binaryHash,
BinaryArchitecture architecture = BinaryArchitecture.Unknown,
BinaryFormat format = BinaryFormat.Unknown) => new(
BinaryFormat format = BinaryFormat.Unknown,
TimeProvider? timeProvider = null) => new(
binaryPath,
binaryHash,
architecture,
@@ -66,7 +67,7 @@ public sealed record BinaryAnalysisResult(
ImmutableArray<SourceCorrelation>.Empty,
ImmutableArray<VulnerableFunctionMatch>.Empty,
BinaryAnalysisMetrics.Empty,
DateTimeOffset.UtcNow);
(timeProvider ?? TimeProvider.System).GetUtcNow());
/// <summary>
/// Gets functions at high-confidence correlation.
@@ -324,18 +325,22 @@ public sealed class BinaryAnalysisResultBuilder
private readonly Dictionary<long, SymbolInfo> _symbols = new();
private readonly List<SourceCorrelation> _correlations = new();
private readonly List<VulnerableFunctionMatch> _vulnerableMatches = new();
private readonly DateTimeOffset _startTime = DateTimeOffset.UtcNow;
private readonly TimeProvider _timeProvider;
private readonly DateTimeOffset _startTime;
public BinaryAnalysisResultBuilder(
string binaryPath,
string binaryHash,
BinaryArchitecture architecture = BinaryArchitecture.Unknown,
BinaryFormat format = BinaryFormat.Unknown)
BinaryFormat format = BinaryFormat.Unknown,
TimeProvider? timeProvider = null)
{
_binaryPath = binaryPath;
_binaryHash = binaryHash;
_architecture = architecture;
_format = format;
_timeProvider = timeProvider ?? TimeProvider.System;
_startTime = _timeProvider.GetUtcNow();
}
/// <summary>
@@ -379,7 +384,8 @@ public sealed class BinaryAnalysisResultBuilder
/// </summary>
public BinaryAnalysisResult Build()
{
var duration = DateTimeOffset.UtcNow - _startTime;
var now = _timeProvider.GetUtcNow();
var duration = now - _startTime;
var metrics = new BinaryAnalysisMetrics(
TotalFunctions: _functions.Count,
@@ -401,6 +407,6 @@ public sealed class BinaryAnalysisResultBuilder
_correlations.OrderBy(c => c.BinaryOffset).ToImmutableArray(),
_vulnerableMatches.OrderByDescending(m => m.Severity).ToImmutableArray(),
metrics,
DateTimeOffset.UtcNow);
now);
}
}

View File

@@ -16,6 +16,7 @@ public sealed class BinaryIntelligenceAnalyzer
private readonly ISymbolRecovery _symbolRecovery;
private readonly VulnerableFunctionMatcher _vulnerabilityMatcher;
private readonly BinaryIntelligenceOptions _options;
private readonly TimeProvider _timeProvider;
/// <summary>
/// Creates a new binary intelligence analyzer.
@@ -25,12 +26,14 @@ public sealed class BinaryIntelligenceAnalyzer
IFingerprintIndex? fingerprintIndex = null,
ISymbolRecovery? symbolRecovery = null,
VulnerableFunctionMatcher? vulnerabilityMatcher = null,
BinaryIntelligenceOptions? options = null)
BinaryIntelligenceOptions? options = null,
TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_fingerprintGenerator = fingerprintGenerator ?? new CombinedFingerprintGenerator();
_fingerprintIndex = fingerprintIndex ?? new InMemoryFingerprintIndex();
_fingerprintIndex = fingerprintIndex ?? new InMemoryFingerprintIndex(_timeProvider);
_symbolRecovery = symbolRecovery ?? new PatternBasedSymbolRecovery();
_vulnerabilityMatcher = vulnerabilityMatcher ?? new VulnerableFunctionMatcher(_fingerprintIndex);
_vulnerabilityMatcher = vulnerabilityMatcher ?? new VulnerableFunctionMatcher(_fingerprintIndex, timeProvider: _timeProvider);
_options = options ?? BinaryIntelligenceOptions.Default;
}
@@ -53,7 +56,7 @@ public sealed class BinaryIntelligenceAnalyzer
CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
var builder = new BinaryAnalysisResultBuilder(binaryPath, binaryHash, architecture, format);
var builder = new BinaryAnalysisResultBuilder(binaryPath, binaryHash, architecture, format, _timeProvider);
// Phase 1: Generate fingerprints for all functions
var fingerprints = new Dictionary<long, CodeFingerprint>();
@@ -186,7 +189,7 @@ public sealed class BinaryIntelligenceAnalyzer
SourceLine: null,
VulnerabilityIds: vulnerabilityIds?.ToImmutableArray() ?? ImmutableArray<string>.Empty,
Similarity: 1.0f,
MatchedAt: DateTimeOffset.UtcNow);
MatchedAt: _timeProvider.GetUtcNow());
if (await _fingerprintIndex.AddAsync(entry, cancellationToken))
{

View File

@@ -14,6 +14,7 @@ public sealed class FingerprintCorpusBuilder
private readonly IFingerprintGenerator _fingerprintGenerator;
private readonly IFingerprintIndex _targetIndex;
private readonly FingerprintCorpusOptions _options;
private readonly TimeProvider _timeProvider;
private readonly List<CorpusBuildRecord> _buildHistory = new();
/// <summary>
@@ -22,11 +23,13 @@ public sealed class FingerprintCorpusBuilder
public FingerprintCorpusBuilder(
IFingerprintIndex targetIndex,
IFingerprintGenerator? fingerprintGenerator = null,
FingerprintCorpusOptions? options = null)
FingerprintCorpusOptions? options = null,
TimeProvider? timeProvider = null)
{
_targetIndex = targetIndex;
_fingerprintGenerator = fingerprintGenerator ?? new CombinedFingerprintGenerator();
_options = options ?? FingerprintCorpusOptions.Default;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
@@ -41,7 +44,7 @@ public sealed class FingerprintCorpusBuilder
IReadOnlyList<FunctionSignature> functions,
CancellationToken cancellationToken = default)
{
var startTime = DateTimeOffset.UtcNow;
var startTime = _timeProvider.GetUtcNow();
var indexed = 0;
var skipped = 0;
var duplicates = 0;
@@ -93,7 +96,7 @@ public sealed class FingerprintCorpusBuilder
SourceLine: null,
VulnerabilityIds: package.VulnerabilityIds,
Similarity: 1.0f,
MatchedAt: DateTimeOffset.UtcNow);
MatchedAt: _timeProvider.GetUtcNow());
var added = await _targetIndex.AddAsync(entry, cancellationToken);
@@ -119,9 +122,9 @@ public sealed class FingerprintCorpusBuilder
Skipped: skipped,
Duplicates: duplicates,
Errors: errors.ToImmutableArray(),
Duration: DateTimeOffset.UtcNow - startTime);
Duration: _timeProvider.GetUtcNow() - startTime);
_buildHistory.Add(new CorpusBuildRecord(package.Purl, package.Version, result, DateTimeOffset.UtcNow));
_buildHistory.Add(new CorpusBuildRecord(package.Purl, package.Version, result, _timeProvider.GetUtcNow()));
return result;
}
@@ -207,7 +210,7 @@ public sealed class FingerprintCorpusBuilder
// For now, export build history as a summary
var data = new CorpusExportData
{
ExportedAt = DateTimeOffset.UtcNow,
ExportedAt = _timeProvider.GetUtcNow(),
Statistics = _targetIndex.GetStatistics(),
Entries = Array.Empty<CorpusEntryData>() // Full export would need index enumeration
};

View File

@@ -140,7 +140,18 @@ public sealed class InMemoryFingerprintIndex : IFingerprintIndex
private readonly ConcurrentDictionary<FingerprintAlgorithm, List<FingerprintMatch>> _algorithmIndex = new();
private readonly HashSet<string> _packages = new();
private readonly object _packagesLock = new();
private DateTimeOffset _lastUpdated = DateTimeOffset.UtcNow;
private readonly TimeProvider _timeProvider;
private DateTimeOffset _lastUpdated;
/// <summary>
/// Creates a new in-memory fingerprint index.
/// </summary>
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
public InMemoryFingerprintIndex(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_lastUpdated = _timeProvider.GetUtcNow();
}
/// <inheritdoc/>
public int Count => _exactIndex.Count;
@@ -182,7 +193,7 @@ public sealed class InMemoryFingerprintIndex : IFingerprintIndex
_packages.Add(match.SourcePackage);
}
_lastUpdated = DateTimeOffset.UtcNow;
_lastUpdated = _timeProvider.GetUtcNow();
}
return Task.FromResult(added);
@@ -302,7 +313,7 @@ public sealed class InMemoryFingerprintIndex : IFingerprintIndex
SourceLine: null,
VulnerabilityIds: ImmutableArray<string>.Empty,
Similarity: 1.0f,
MatchedAt: DateTimeOffset.UtcNow);
MatchedAt: _timeProvider.GetUtcNow());
return AddAsync(match, cancellationToken).ContinueWith(_ => { }, cancellationToken);
}
@@ -313,9 +324,20 @@ public sealed class InMemoryFingerprintIndex : IFingerprintIndex
/// </summary>
public sealed class VulnerableFingerprintIndex : IFingerprintIndex
{
private readonly InMemoryFingerprintIndex _baseIndex = new();
private readonly InMemoryFingerprintIndex _baseIndex;
private readonly TimeProvider _timeProvider;
private readonly ConcurrentDictionary<string, VulnerabilityInfo> _vulnerabilities = new();
/// <summary>
/// Creates a new vulnerability-aware fingerprint index.
/// </summary>
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
public VulnerableFingerprintIndex(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_baseIndex = new InMemoryFingerprintIndex(_timeProvider);
}
/// <inheritdoc/>
public int Count => _baseIndex.Count;
@@ -344,7 +366,7 @@ public sealed class VulnerableFingerprintIndex : IFingerprintIndex
SourceLine: null,
VulnerabilityIds: ImmutableArray.Create(vulnerabilityId),
Similarity: 1.0f,
MatchedAt: DateTimeOffset.UtcNow);
MatchedAt: _timeProvider.GetUtcNow());
var added = await _baseIndex.AddAsync(match, cancellationToken);

View File

@@ -11,16 +11,19 @@ public sealed class VulnerableFunctionMatcher
{
private readonly IFingerprintIndex _index;
private readonly VulnerableMatcherOptions _options;
private readonly TimeProvider _timeProvider;
/// <summary>
/// Creates a new vulnerable function matcher.
/// </summary>
public VulnerableFunctionMatcher(
IFingerprintIndex index,
VulnerableMatcherOptions? options = null)
VulnerableMatcherOptions? options = null,
TimeProvider? timeProvider = null)
{
_index = index;
_options = options ?? VulnerableMatcherOptions.Default;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
@@ -165,7 +168,7 @@ public sealed class VulnerableFunctionMatcher
SourceLine: null,
VulnerabilityIds: ImmutableArray.Create(vulnerabilityId),
Similarity: 1.0f,
MatchedAt: DateTimeOffset.UtcNow);
MatchedAt: _timeProvider.GetUtcNow());
return await _index.AddAsync(entry, cancellationToken);
}

View File

@@ -11,12 +11,13 @@ public sealed class CompositeRiskScorer : IRiskScorer
{
private readonly ImmutableArray<IRiskContributor> _contributors;
private readonly CompositeRiskScorerOptions _options;
private readonly TimeProvider _timeProvider;
/// <summary>
/// Creates a composite scorer with default contributors.
/// </summary>
public CompositeRiskScorer(CompositeRiskScorerOptions? options = null)
: this(GetDefaultContributors(), options)
public CompositeRiskScorer(CompositeRiskScorerOptions? options = null, TimeProvider? timeProvider = null)
: this(GetDefaultContributors(), options, timeProvider)
{
}
@@ -25,10 +26,12 @@ public sealed class CompositeRiskScorer : IRiskScorer
/// </summary>
public CompositeRiskScorer(
IEnumerable<IRiskContributor> contributors,
CompositeRiskScorerOptions? options = null)
CompositeRiskScorerOptions? options = null,
TimeProvider? timeProvider = null)
{
_contributors = contributors.ToImmutableArray();
_options = options ?? CompositeRiskScorerOptions.Default;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc/>
@@ -66,7 +69,7 @@ public sealed class CompositeRiskScorer : IRiskScorer
Factors: allFactors.ToImmutableArray(),
BusinessContext: businessContext,
Recommendations: recommendations,
AssessedAt: DateTimeOffset.UtcNow);
AssessedAt: _timeProvider.GetUtcNow());
}
private RiskScore ComputeOverallScore(
@@ -75,7 +78,7 @@ public sealed class CompositeRiskScorer : IRiskScorer
{
if (factors.Count == 0)
{
return RiskScore.Zero;
return RiskScore.Zero(_timeProvider);
}
// Weighted average of factor contributions
@@ -106,7 +109,7 @@ public sealed class CompositeRiskScorer : IRiskScorer
OverallScore: baseScore,
Category: primaryCategory,
Confidence: confidence,
ComputedAt: DateTimeOffset.UtcNow);
ComputedAt: _timeProvider.GetUtcNow());
}
private float ComputeConfidence(IReadOnlyList<RiskFactor> factors)
@@ -217,6 +220,17 @@ public sealed record CompositeRiskScorerOptions(
/// </summary>
public sealed class RiskExplainer
{
private readonly TimeProvider _timeProvider;
/// <summary>
/// Creates a new risk explainer.
/// </summary>
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
public RiskExplainer(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Generates a summary explanation for a risk assessment.
/// </summary>
@@ -268,7 +282,7 @@ public sealed class RiskExplainer
Confidence: assessment.OverallScore.Confidence,
TopFactors: ExplainFactors(assessment),
Recommendations: assessment.Recommendations,
GeneratedAt: DateTimeOffset.UtcNow);
GeneratedAt: _timeProvider.GetUtcNow());
}
private static string CategoryToString(RiskCategory category) => category switch
@@ -313,6 +327,17 @@ public sealed record RiskReport(
/// </summary>
public sealed class RiskAggregator
{
private readonly TimeProvider _timeProvider;
/// <summary>
/// Creates a new risk aggregator.
/// </summary>
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
public RiskAggregator(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Aggregates assessments for a fleet-level view.
/// </summary>
@@ -322,7 +347,7 @@ public sealed class RiskAggregator
if (assessmentList.Count == 0)
{
return FleetRiskSummary.Empty;
return FleetRiskSummary.CreateEmpty(_timeProvider);
}
var distribution = assessmentList
@@ -349,7 +374,7 @@ public sealed class RiskAggregator
Distribution: distribution.ToImmutableDictionary(),
CategoryBreakdown: categoryBreakdown.ToImmutableDictionary(),
TopRisks: topRisks,
AggregatedAt: DateTimeOffset.UtcNow);
AggregatedAt: _timeProvider.GetUtcNow());
}
}
@@ -373,16 +398,22 @@ public sealed record FleetRiskSummary(
DateTimeOffset AggregatedAt)
{
/// <summary>
/// Empty summary.
/// Empty summary with specified timestamp.
/// </summary>
public static FleetRiskSummary Empty => new(
public static FleetRiskSummary CreateEmpty(TimeProvider? timeProvider = null) => new(
TotalSubjects: 0,
AverageScore: 0,
AverageConfidence: 0,
Distribution: ImmutableDictionary<RiskLevel, int>.Empty,
CategoryBreakdown: ImmutableDictionary<RiskCategory, int>.Empty,
TopRisks: ImmutableArray<RiskSummaryItem>.Empty,
AggregatedAt: DateTimeOffset.UtcNow);
AggregatedAt: (timeProvider ?? TimeProvider.System).GetUtcNow());
/// <summary>
/// Empty summary (uses current time).
/// </summary>
[Obsolete("Use CreateEmpty(TimeProvider) for deterministic timestamps")]
public static FleetRiskSummary Empty => CreateEmpty();
/// <summary>
/// Count of critical/high risk subjects.

View File

@@ -20,31 +20,32 @@ public sealed record RiskScore(
/// <summary>
/// Creates a zero risk score.
/// </summary>
public static RiskScore Zero => new(0.0f, RiskCategory.Unknown, 1.0f, DateTimeOffset.UtcNow);
public static RiskScore Zero(TimeProvider? timeProvider = null)
=> new(0.0f, RiskCategory.Unknown, 1.0f, (timeProvider ?? TimeProvider.System).GetUtcNow());
/// <summary>
/// Creates a critical risk score.
/// </summary>
public static RiskScore Critical(RiskCategory category, float confidence = 0.9f)
=> new(1.0f, category, confidence, DateTimeOffset.UtcNow);
public static RiskScore Critical(RiskCategory category, float confidence = 0.9f, TimeProvider? timeProvider = null)
=> new(1.0f, category, confidence, (timeProvider ?? TimeProvider.System).GetUtcNow());
/// <summary>
/// Creates a high risk score.
/// </summary>
public static RiskScore High(RiskCategory category, float confidence = 0.85f)
=> new(0.85f, category, confidence, DateTimeOffset.UtcNow);
public static RiskScore High(RiskCategory category, float confidence = 0.85f, TimeProvider? timeProvider = null)
=> new(0.85f, category, confidence, (timeProvider ?? TimeProvider.System).GetUtcNow());
/// <summary>
/// Creates a medium risk score.
/// </summary>
public static RiskScore Medium(RiskCategory category, float confidence = 0.8f)
=> new(0.5f, category, confidence, DateTimeOffset.UtcNow);
public static RiskScore Medium(RiskCategory category, float confidence = 0.8f, TimeProvider? timeProvider = null)
=> new(0.5f, category, confidence, (timeProvider ?? TimeProvider.System).GetUtcNow());
/// <summary>
/// Creates a low risk score.
/// </summary>
public static RiskScore Low(RiskCategory category, float confidence = 0.75f)
=> new(0.2f, category, confidence, DateTimeOffset.UtcNow);
public static RiskScore Low(RiskCategory category, float confidence = 0.75f, TimeProvider? timeProvider = null)
=> new(0.2f, category, confidence, (timeProvider ?? TimeProvider.System).GetUtcNow());
/// <summary>
/// Descriptive risk level based on score.
@@ -349,14 +350,18 @@ public sealed record RiskAssessment(
/// <summary>
/// Creates an empty assessment for a subject with no risk data.
/// </summary>
public static RiskAssessment Empty(string subjectId, SubjectType subjectType) => new(
SubjectId: subjectId,
SubjectType: subjectType,
OverallScore: RiskScore.Zero,
Factors: ImmutableArray<RiskFactor>.Empty,
BusinessContext: null,
Recommendations: ImmutableArray<string>.Empty,
AssessedAt: DateTimeOffset.UtcNow);
public static RiskAssessment Empty(string subjectId, SubjectType subjectType, TimeProvider? timeProvider = null)
{
var tp = timeProvider ?? TimeProvider.System;
return new(
SubjectId: subjectId,
SubjectType: subjectType,
OverallScore: RiskScore.Zero(tp),
Factors: ImmutableArray<RiskFactor>.Empty,
BusinessContext: null,
Recommendations: ImmutableArray<string>.Empty,
AssessedAt: tp.GetUtcNow());
}
}
/// <summary>

View File

@@ -16,21 +16,25 @@ public sealed class SemanticEntryTraceAnalyzer : ISemanticEntryTraceAnalyzer
private readonly IEntryTraceAnalyzer _baseAnalyzer;
private readonly SemanticEntrypointOrchestrator _orchestrator;
private readonly ILogger<SemanticEntryTraceAnalyzer> _logger;
private readonly TimeProvider _timeProvider;
public SemanticEntryTraceAnalyzer(
IEntryTraceAnalyzer baseAnalyzer,
SemanticEntrypointOrchestrator orchestrator,
ILogger<SemanticEntryTraceAnalyzer> logger)
ILogger<SemanticEntryTraceAnalyzer> logger,
TimeProvider? timeProvider = null)
{
_baseAnalyzer = baseAnalyzer ?? throw new ArgumentNullException(nameof(baseAnalyzer));
_orchestrator = orchestrator ?? throw new ArgumentNullException(nameof(orchestrator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public SemanticEntryTraceAnalyzer(
IEntryTraceAnalyzer baseAnalyzer,
ILogger<SemanticEntryTraceAnalyzer> logger)
: this(baseAnalyzer, new SemanticEntrypointOrchestrator(), logger)
ILogger<SemanticEntryTraceAnalyzer> logger,
TimeProvider? timeProvider = null)
: this(baseAnalyzer, new SemanticEntrypointOrchestrator(), logger, timeProvider)
{
}
@@ -52,7 +56,7 @@ public sealed class SemanticEntryTraceAnalyzer : ISemanticEntryTraceAnalyzer
var traceResult = new EntryTraceResult(
context.ScanId,
context.ImageDigest,
DateTimeOffset.UtcNow,
_timeProvider.GetUtcNow(),
graph,
SerializeToNdjson(graph));
@@ -98,7 +102,7 @@ public sealed class SemanticEntryTraceAnalyzer : ISemanticEntryTraceAnalyzer
TraceResult = traceResult,
SemanticEntrypoint = semanticResult,
AnalysisResult = analysisResult,
AnalyzedAt = DateTimeOffset.UtcNow
AnalyzedAt = _timeProvider.GetUtcNow()
};
}

View File

@@ -28,6 +28,7 @@ public sealed class FuncProofBuilder
private ICryptoHash? _cryptoHash;
private FuncProofGenerationOptions _options = new();
private TimeProvider _timeProvider = TimeProvider.System;
private string? _buildId;
private string? _buildIdType;
private string? _fileSha256;
@@ -50,6 +51,16 @@ public sealed class FuncProofBuilder
return this;
}
/// <summary>
/// Sets the TimeProvider for deterministic timestamp generation.
/// If not set, defaults to TimeProvider.System.
/// </summary>
public FuncProofBuilder WithTimeProvider(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
return this;
}
/// <summary>
/// Sets the generation options for configurable parameters.
/// </summary>
@@ -212,7 +223,7 @@ public sealed class FuncProofBuilder
Functions = functions,
Traces = traces,
Meta = _metadata,
GeneratedAt = DateTimeOffset.UtcNow,
GeneratedAt = _timeProvider.GetUtcNow(),
GeneratorVersion = _generatorVersion
};

View File

@@ -50,6 +50,12 @@ public interface IAssumptionCollector
public sealed class AssumptionCollector : IAssumptionCollector
{
private readonly Dictionary<(AssumptionCategory, string), Assumption> _assumptions = new();
private readonly TimeProvider _timeProvider;
public AssumptionCollector(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public void Record(
@@ -107,7 +113,7 @@ public sealed class AssumptionCollector : IAssumptionCollector
{
Id = Guid.NewGuid().ToString("N"),
Assumptions = [.. _assumptions.Values],
CreatedAt = DateTimeOffset.UtcNow,
CreatedAt = _timeProvider.GetUtcNow(),
ContextId = contextId
};
}

View File

@@ -14,13 +14,16 @@ public sealed class ProofAwareVexGenerator
{
private readonly ILogger<ProofAwareVexGenerator> _logger;
private readonly BackportProofService _proofService;
private readonly TimeProvider _timeProvider;
public ProofAwareVexGenerator(
ILogger<ProofAwareVexGenerator> logger,
BackportProofService proofService)
BackportProofService proofService,
TimeProvider? timeProvider = null)
{
_logger = logger;
_proofService = proofService;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
@@ -74,7 +77,7 @@ public sealed class ProofAwareVexGenerator
Statement = statement,
ProofPayload = proofPayload,
Proof = proof,
GeneratedAt = DateTimeOffset.UtcNow
GeneratedAt = _timeProvider.GetUtcNow()
};
}
@@ -135,7 +138,7 @@ public sealed class ProofAwareVexGenerator
Statement = statement,
ProofPayload = proofPayload,
Proof = unknownProof,
GeneratedAt = DateTimeOffset.UtcNow
GeneratedAt = _timeProvider.GetUtcNow()
};
}

View File

@@ -18,7 +18,17 @@ public sealed record BoundaryExtractionContext
/// <summary>
/// Empty context for simple extractions.
/// </summary>
public static readonly BoundaryExtractionContext Empty = new();
/// <remarks>Uses system time. For deterministic timestamps, use <see cref="CreateEmpty"/>.</remarks>
[Obsolete("Use CreateEmpty(TimeProvider) for deterministic timestamps")]
public static BoundaryExtractionContext Empty => CreateEmpty();
/// <summary>
/// Creates an empty context for simple extractions.
/// </summary>
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
/// <returns>An empty boundary extraction context.</returns>
public static BoundaryExtractionContext CreateEmpty(TimeProvider? timeProvider = null) =>
new() { Timestamp = (timeProvider ?? TimeProvider.System).GetUtcNow() };
/// <summary>
/// Environment identifier (e.g., "production", "staging").
@@ -61,7 +71,7 @@ public sealed record BoundaryExtractionContext
/// <summary>
/// Timestamp for the context (for cache invalidation).
/// </summary>
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
public required DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Source of this context (e.g., "k8s", "iac", "runtime").
@@ -71,20 +81,28 @@ public sealed record BoundaryExtractionContext
/// <summary>
/// Creates a context from detected gates.
/// </summary>
public static BoundaryExtractionContext FromGates(IReadOnlyList<DetectedGate> gates) =>
new() { DetectedGates = gates };
/// <param name="gates">The detected gates.</param>
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
public static BoundaryExtractionContext FromGates(IReadOnlyList<DetectedGate> gates, TimeProvider? timeProvider = null) =>
new() { DetectedGates = gates, Timestamp = (timeProvider ?? TimeProvider.System).GetUtcNow() };
/// <summary>
/// Creates a context with environment hints.
/// </summary>
/// <param name="environmentId">The environment identifier.</param>
/// <param name="isInternetFacing">Whether the service is internet-facing.</param>
/// <param name="networkZone">The network zone.</param>
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
public static BoundaryExtractionContext ForEnvironment(
string environmentId,
bool? isInternetFacing = null,
string? networkZone = null) =>
string? networkZone = null,
TimeProvider? timeProvider = null) =>
new()
{
EnvironmentId = environmentId,
IsInternetFacing = isInternetFacing,
NetworkZone = networkZone
NetworkZone = networkZone,
Timestamp = (timeProvider ?? TimeProvider.System).GetUtcNow()
};
}

View File

@@ -123,19 +123,22 @@ public sealed class IncrementalReachabilityService : IIncrementalReachabilitySer
private readonly IImpactSetCalculator _impactCalculator;
private readonly IStateFlipDetector _stateFlipDetector;
private readonly ILogger<IncrementalReachabilityService> _logger;
private readonly TimeProvider _timeProvider;
public IncrementalReachabilityService(
IReachabilityCache cache,
IGraphDeltaComputer deltaComputer,
IImpactSetCalculator impactCalculator,
IStateFlipDetector stateFlipDetector,
ILogger<IncrementalReachabilityService> logger)
ILogger<IncrementalReachabilityService> logger,
TimeProvider? timeProvider = null)
{
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_deltaComputer = deltaComputer ?? throw new ArgumentNullException(nameof(deltaComputer));
_impactCalculator = impactCalculator ?? throw new ArgumentNullException(nameof(impactCalculator));
_stateFlipDetector = stateFlipDetector ?? throw new ArgumentNullException(nameof(stateFlipDetector));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
@@ -265,7 +268,7 @@ public sealed class IncrementalReachabilityService : IIncrementalReachabilitySer
private List<ReachablePairResult> ComputeFullReachability(IncrementalReachabilityRequest request)
{
var results = new List<ReachablePairResult>();
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
// Build forward adjacency for BFS
var adj = new Dictionary<string, List<string>>();
@@ -323,7 +326,7 @@ public sealed class IncrementalReachabilityService : IIncrementalReachabilitySer
CancellationToken cancellationToken)
{
var results = new Dictionary<(string, string), ReachablePairResult>();
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
// Copy unaffected results from previous
foreach (var prev in previousResults)

View File

@@ -21,13 +21,16 @@ public sealed class PostgresReachabilityCache : IReachabilityCache
{
private readonly string _connectionString;
private readonly ILogger<PostgresReachabilityCache> _logger;
private readonly TimeProvider _timeProvider;
public PostgresReachabilityCache(
string connectionString,
ILogger<PostgresReachabilityCache> logger)
ILogger<PostgresReachabilityCache> logger,
TimeProvider? timeProvider = null)
{
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
@@ -102,7 +105,7 @@ public sealed class PostgresReachabilityCache : IReachabilityCache
ServiceId = serviceId,
GraphHash = graphHash,
CachedAt = cachedAt,
TimeToLive = expiresAt.HasValue ? expiresAt.Value - DateTimeOffset.UtcNow : null,
TimeToLive = expiresAt.HasValue ? expiresAt.Value - _timeProvider.GetUtcNow() : null,
ReachablePairs = pairs,
EntryPointCount = entryPointCount,
SinkCount = sinkCount
@@ -143,7 +146,7 @@ public sealed class PostgresReachabilityCache : IReachabilityCache
}
var expiresAt = entry.TimeToLive.HasValue
? (object)DateTimeOffset.UtcNow.Add(entry.TimeToLive.Value)
? (object)_timeProvider.GetUtcNow().Add(entry.TimeToLive.Value)
: DBNull.Value;
const string insertEntrySql = """

View File

@@ -225,8 +225,9 @@ public sealed class EdgeBundleBuilder
return this;
}
public EdgeBundle Build()
public EdgeBundle Build(TimeProvider? timeProvider = null)
{
var tp = timeProvider ?? TimeProvider.System;
var canonical = _edges
.Select(e => e.Trimmed())
.OrderBy(e => e.From, StringComparer.Ordinal)
@@ -241,7 +242,7 @@ public sealed class EdgeBundleBuilder
GraphHash: _graphHash,
BundleReason: _bundleReason,
Edges: canonical,
GeneratedAt: DateTimeOffset.UtcNow,
GeneratedAt: tp.GetUtcNow(),
CustomReason: _customReason);
}

View File

@@ -322,5 +322,5 @@ public sealed record PathExplanationResult
/// When the explanation was generated.
/// </summary>
[JsonPropertyName("generated_at")]
public DateTimeOffset GeneratedAt { get; init; } = DateTimeOffset.UtcNow;
public required DateTimeOffset GeneratedAt { get; init; }
}

View File

@@ -24,7 +24,7 @@ public sealed class FileSystemCodeContentProvider : ICodeContentProvider
return Task.FromResult<string?>(null);
}
return File.ReadAllTextAsync(path, ct);
return File.ReadAllTextAsync(path, ct)!;
}
public async Task<IReadOnlyList<string>?> GetLinesAsync(

View File

@@ -8,7 +8,7 @@ namespace StellaOps.Scanner.Reachability.MiniMap;
public interface IMiniMapExtractor
{
ReachabilityMiniMap Extract(RichGraph graph, string vulnerableComponent, int maxPaths = 10);
ReachabilityMiniMap Extract(RichGraph graph, string vulnerableComponent, int maxPaths = 10, TimeProvider? timeProvider = null);
}
public sealed class MiniMapExtractor : IMiniMapExtractor
@@ -16,16 +16,19 @@ public sealed class MiniMapExtractor : IMiniMapExtractor
public ReachabilityMiniMap Extract(
RichGraph graph,
string vulnerableComponent,
int maxPaths = 10)
int maxPaths = 10,
TimeProvider? timeProvider = null)
{
// Find vulnerable component node
var vulnNode = graph.Nodes.FirstOrDefault(n =>
n.Purl == vulnerableComponent ||
n.SymbolId?.Contains(vulnerableComponent) == true);
var tp = timeProvider ?? TimeProvider.System;
if (vulnNode is null)
{
return CreateNotFoundMap(vulnerableComponent);
return CreateNotFoundMap(vulnerableComponent, tp);
}
// Find all entrypoints
@@ -75,11 +78,11 @@ public sealed class MiniMapExtractor : IMiniMapExtractor
State = state,
Confidence = confidence,
GraphDigest = ComputeGraphDigest(graph),
AnalyzedAt = DateTimeOffset.UtcNow
AnalyzedAt = tp.GetUtcNow()
};
}
private static ReachabilityMiniMap CreateNotFoundMap(string vulnerableComponent)
private static ReachabilityMiniMap CreateNotFoundMap(string vulnerableComponent, TimeProvider timeProvider)
{
return new ReachabilityMiniMap
{
@@ -96,7 +99,7 @@ public sealed class MiniMapExtractor : IMiniMapExtractor
State = ReachabilityState.Unknown,
Confidence = 0m,
GraphDigest = string.Empty,
AnalyzedAt = DateTimeOffset.UtcNow
AnalyzedAt = timeProvider.GetUtcNow()
};
}

View File

@@ -17,6 +17,8 @@ namespace StellaOps.Scanner.Reachability;
/// </summary>
public sealed class ReachabilityUnionWriter
{
private readonly TimeProvider _timeProvider;
private static readonly JsonWriterOptions JsonOptions = new()
{
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
@@ -24,6 +26,11 @@ public sealed class ReachabilityUnionWriter
SkipValidation = false
};
public ReachabilityUnionWriter(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<ReachabilityUnionWriteResult> WriteAsync(
ReachabilityUnionGraph graph,
string outputRoot,
@@ -57,7 +64,7 @@ public sealed class ReachabilityUnionWriter
File.Delete(factsPath);
}
await WriteMetaAsync(metaPath, nodesInfo, edgesInfo, factsInfo, cancellationToken).ConfigureAwait(false);
await WriteMetaAsync(metaPath, nodesInfo, edgesInfo, factsInfo, _timeProvider, cancellationToken).ConfigureAwait(false);
return new ReachabilityUnionWriteResult(nodesInfo.ToPublic(), edgesInfo.ToPublic(), factsInfo?.ToPublic(), metaPath);
}
@@ -387,6 +394,7 @@ public sealed class ReachabilityUnionWriter
FileHashInfo nodes,
FileHashInfo edges,
FileHashInfo? facts,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
await using var stream = File.Create(path);
@@ -394,7 +402,7 @@ public sealed class ReachabilityUnionWriter
writer.WriteStartObject();
writer.WriteString("schema", "reachability-union@0.1");
writer.WriteString("generated_at", DateTimeOffset.UtcNow.ToString("O"));
writer.WriteString("generated_at", timeProvider.GetUtcNow().ToString("O"));
writer.WritePropertyName("files");
writer.WriteStartArray();
WriteMetaFile(writer, nodes);

View File

@@ -30,15 +30,17 @@ public sealed class SliceCacheOptions
public sealed class SliceCache : ISliceCache, IDisposable
{
private readonly SliceCacheOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ConcurrentDictionary<string, CacheItem> _cache = new(StringComparer.Ordinal);
private readonly Timer _evictionTimer;
private long _hitCount;
private long _missCount;
private bool _disposed;
public SliceCache(IOptions<SliceCacheOptions> options)
public SliceCache(IOptions<SliceCacheOptions> options, TimeProvider? timeProvider = null)
{
_options = options?.Value ?? new SliceCacheOptions();
_timeProvider = timeProvider ?? TimeProvider.System;
_evictionTimer = new Timer(EvictExpired, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
}
@@ -53,9 +55,10 @@ public sealed class SliceCache : ISliceCache, IDisposable
if (_cache.TryGetValue(cacheKey, out var item))
{
if (item.ExpiresAt > DateTimeOffset.UtcNow)
var now = _timeProvider.GetUtcNow();
if (item.ExpiresAt > now)
{
item.LastAccessed = DateTimeOffset.UtcNow;
item.LastAccessed = now;
Interlocked.Increment(ref _hitCount);
var result = new CachedSliceResult
{
@@ -89,7 +92,7 @@ public sealed class SliceCache : ISliceCache, IDisposable
EvictLru();
}
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
var item = new CacheItem
{
Digest = result.SliceDigest,
@@ -132,7 +135,7 @@ public sealed class SliceCache : ISliceCache, IDisposable
{
if (_disposed) return;
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
var keysToRemove = _cache
.Where(kvp => kvp.Value.ExpiresAt <= now)
.Select(kvp => kvp.Key)

View File

@@ -19,7 +19,8 @@ public interface IReachabilityStackEvaluator
VulnerableSymbol symbol,
ReachabilityLayer1 layer1,
ReachabilityLayer2 layer2,
ReachabilityLayer3 layer3);
ReachabilityLayer3 layer3,
TimeProvider? timeProvider = null);
/// <summary>
/// Derives the verdict from three layers.
@@ -53,8 +54,10 @@ public sealed class ReachabilityStackEvaluator : IReachabilityStackEvaluator
VulnerableSymbol symbol,
ReachabilityLayer1 layer1,
ReachabilityLayer2 layer2,
ReachabilityLayer3 layer3)
ReachabilityLayer3 layer3,
TimeProvider? timeProvider = null)
{
var tp = timeProvider ?? TimeProvider.System;
var verdict = DeriveVerdict(layer1, layer2, layer3);
var explanation = GenerateExplanation(layer1, layer2, layer3, verdict);
@@ -67,7 +70,7 @@ public sealed class ReachabilityStackEvaluator : IReachabilityStackEvaluator
BinaryResolution = layer2,
RuntimeGating = layer3,
Verdict = verdict,
AnalyzedAt = DateTimeOffset.UtcNow,
AnalyzedAt = tp.GetUtcNow(),
Explanation = explanation
};
}

View File

@@ -125,7 +125,7 @@ public sealed class WitnessDsseSigner : IWitnessDsseSigner
return WitnessVerifyResult.Failure($"Signature verification failed: {verifyResult.Error?.Message}");
}
return WitnessVerifyResult.Success(witness, matchingSignature.KeyId);
return WitnessVerifyResult.Success(witness, matchingSignature.KeyId!);
}
catch (Exception ex) when (ex is JsonException or FormatException or InvalidOperationException)
{

View File

@@ -12,13 +12,7 @@ public sealed class EbpfTraceCollector : ITraceCollector
private readonly ISymbolResolver _symbolResolver;
private readonly TimeProvider _timeProvider;
private bool _isRunning;
private TraceCollectorStats _stats = new TraceCollectorStats
{
EventsCollected = 0,
EventsDropped = 0,
BytesProcessed = 0,
StartedAt = DateTimeOffset.UtcNow
};
private TraceCollectorStats _stats;
public EbpfTraceCollector(
ILogger<EbpfTraceCollector> logger,
@@ -28,6 +22,13 @@ public sealed class EbpfTraceCollector : ITraceCollector
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_symbolResolver = symbolResolver ?? throw new ArgumentNullException(nameof(symbolResolver));
_timeProvider = timeProvider ?? TimeProvider.System;
_stats = new TraceCollectorStats
{
EventsCollected = 0,
EventsDropped = 0,
BytesProcessed = 0,
StartedAt = _timeProvider.GetUtcNow()
};
}
public Task StartAsync(TraceCollectorConfig config, CancellationToken cancellationToken = default)

View File

@@ -11,13 +11,7 @@ public sealed class EtwTraceCollector : ITraceCollector
private readonly ILogger<EtwTraceCollector> _logger;
private readonly TimeProvider _timeProvider;
private bool _isRunning;
private TraceCollectorStats _stats = new TraceCollectorStats
{
EventsCollected = 0,
EventsDropped = 0,
BytesProcessed = 0,
StartedAt = DateTimeOffset.UtcNow
};
private TraceCollectorStats _stats;
public EtwTraceCollector(
ILogger<EtwTraceCollector> logger,
@@ -25,6 +19,13 @@ public sealed class EtwTraceCollector : ITraceCollector
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
_stats = new TraceCollectorStats
{
EventsCollected = 0,
EventsDropped = 0,
BytesProcessed = 0,
StartedAt = _timeProvider.GetUtcNow()
};
}
public Task StartAsync(TraceCollectorConfig config, CancellationToken cancellationToken = default)

View File

@@ -169,9 +169,9 @@ public sealed class TraceIngestionService : ITraceIngestionService
return Array.Empty<NormalizedTrace>();
}
private static string GenerateTraceId(string scanId, long eventCount)
private string GenerateTraceId(string scanId, long eventCount)
{
var input = $"{scanId}|{eventCount}|{DateTimeOffset.UtcNow.Ticks}";
var input = $"{scanId}|{eventCount}|{_timeProvider.GetUtcNow().Ticks}";
var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input));
return $"trace_{Convert.ToHexString(hash)[..16].ToLowerInvariant()}";
}

View File

@@ -10,14 +10,17 @@ namespace StellaOps.Scanner.Runtime.Slices;
public sealed class ObservedSliceGenerator
{
private readonly SliceExtractor _sliceExtractor;
private readonly TimeProvider _timeProvider;
private readonly ILogger<ObservedSliceGenerator> _logger;
public ObservedSliceGenerator(
SliceExtractor sliceExtractor,
ILogger<ObservedSliceGenerator> logger)
ILogger<ObservedSliceGenerator> logger,
TimeProvider? timeProvider = null)
{
_sliceExtractor = sliceExtractor ?? throw new ArgumentNullException(nameof(sliceExtractor));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
@@ -72,12 +75,13 @@ public sealed class ObservedSliceGenerator
if (enrichment.TryGetValue(key, out var enrich) && enrich.Observed)
{
var now = _timeProvider.GetUtcNow();
return edge with
{
Observed = new ObservedEdgeMetadata
{
FirstObserved = enrich.FirstObserved ?? DateTimeOffset.UtcNow,
LastObserved = enrich.LastObserved ?? DateTimeOffset.UtcNow,
FirstObserved = enrich.FirstObserved ?? now,
LastObserved = enrich.LastObserved ?? now,
ObservationCount = (int)enrich.ObservationCount,
TraceDigest = null
}

View File

@@ -12,11 +12,16 @@ public sealed class VexCandidateEmitter
{
private readonly VexCandidateEmitterOptions _options;
private readonly IVexCandidateStore? _store;
private readonly TimeProvider _timeProvider;
public VexCandidateEmitter(VexCandidateEmitterOptions? options = null, IVexCandidateStore? store = null)
public VexCandidateEmitter(
VexCandidateEmitterOptions? options = null,
IVexCandidateStore? store = null,
TimeProvider? timeProvider = null)
{
_options = options ?? VexCandidateEmitterOptions.Default;
_store = store;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
@@ -79,7 +84,7 @@ public sealed class VexCandidateEmitter
ImageDigest: context.TargetImageDigest,
CandidatesEmitted: candidates.Count,
Candidates: [.. candidates],
Timestamp: DateTimeOffset.UtcNow);
Timestamp: _timeProvider.GetUtcNow());
}
/// <summary>
@@ -163,16 +168,16 @@ public sealed class VexCandidateEmitter
EvidenceLinks: [.. evidenceLinks],
Confidence: confidence,
ImageDigest: context.TargetImageDigest,
GeneratedAt: DateTimeOffset.UtcNow,
ExpiresAt: DateTimeOffset.UtcNow.Add(_options.CandidateTtl),
GeneratedAt: _timeProvider.GetUtcNow(),
ExpiresAt: _timeProvider.GetUtcNow().Add(_options.CandidateTtl),
RequiresReview: true);
}
private static string GenerateCandidateId(
private string GenerateCandidateId(
FindingSnapshot finding,
VexCandidateEmissionContext context)
{
var input = $"{context.TargetImageDigest}:{finding.FindingKey}:{DateTimeOffset.UtcNow.Ticks}";
var input = $"{context.TargetImageDigest}:{finding.FindingKey}:{_timeProvider.GetUtcNow().Ticks}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return $"vexc-{Convert.ToHexString(hash).ToLowerInvariant()[..16]}";
}

View File

@@ -97,9 +97,17 @@ public sealed record VexEvidence
/// <summary>
/// Whether the VEX statement is still valid (not expired).
/// Uses system time for evaluation. For deterministic testing, use <see cref="IsValidAt"/>.
/// </summary>
[JsonIgnore]
public bool IsValid => ExpiresAt is null || ExpiresAt > DateTimeOffset.UtcNow;
public bool IsValid => IsValidAt(TimeProvider.System.GetUtcNow());
/// <summary>
/// Checks whether the VEX statement is valid at a specific point in time.
/// </summary>
/// <param name="now">The time to check validity against.</param>
/// <returns>True if the statement is valid (not expired), false otherwise.</returns>
public bool IsValidAt(DateTimeOffset now) => ExpiresAt is null || ExpiresAt > now;
/// <summary>
/// Whether this VEX statement indicates the vulnerability is not exploitable.

View File

@@ -15,6 +15,7 @@ namespace StellaOps.Scanner.Sources.ConnectionTesters;
public sealed class CliConnectionTester : ISourceTypeConnectionTester
{
private readonly ICredentialResolver _credentialResolver;
private readonly TimeProvider _timeProvider;
private readonly ILogger<CliConnectionTester> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
@@ -27,10 +28,12 @@ public sealed class CliConnectionTester : ISourceTypeConnectionTester
public CliConnectionTester(
ICredentialResolver credentialResolver,
ILogger<CliConnectionTester> logger)
ILogger<CliConnectionTester> logger,
TimeProvider? timeProvider = null)
{
_credentialResolver = credentialResolver;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<ConnectionTestResult> TestAsync(
@@ -45,7 +48,7 @@ public sealed class CliConnectionTester : ISourceTypeConnectionTester
{
Success = false,
Message = "Invalid configuration format",
TestedAt = DateTimeOffset.UtcNow
TestedAt = _timeProvider.GetUtcNow()
};
}
@@ -103,7 +106,7 @@ public sealed class CliConnectionTester : ISourceTypeConnectionTester
{
Success = false,
Message = $"Configuration issues: {string.Join("; ", validationIssues)}",
TestedAt = DateTimeOffset.UtcNow,
TestedAt = _timeProvider.GetUtcNow(),
Details = details
};
}
@@ -112,7 +115,7 @@ public sealed class CliConnectionTester : ISourceTypeConnectionTester
{
Success = true,
Message = "CLI source configuration is valid - ready to receive SBOMs",
TestedAt = DateTimeOffset.UtcNow,
TestedAt = _timeProvider.GetUtcNow(),
Details = details
};
}

View File

@@ -298,23 +298,23 @@ public sealed record ConnectionTestResult
public required bool Success { get; init; }
public string? Message { get; init; }
public string? ErrorCode { get; init; }
public DateTimeOffset TestedAt { get; init; } = DateTimeOffset.UtcNow;
public required DateTimeOffset TestedAt { get; init; }
public List<ConnectionTestCheck> Checks { get; init; } = [];
public Dictionary<string, object>? Details { get; init; }
public static ConnectionTestResult Succeeded(string? message = null) => new()
public static ConnectionTestResult Succeeded(TimeProvider timeProvider, string? message = null) => new()
{
Success = true,
Message = message ?? "Connection successful",
TestedAt = DateTimeOffset.UtcNow
TestedAt = timeProvider.GetUtcNow()
};
public static ConnectionTestResult Failed(string message, string? errorCode = null) => new()
public static ConnectionTestResult Failed(TimeProvider timeProvider, string message, string? errorCode = null) => new()
{
Success = false,
Message = message,
ErrorCode = errorCode,
TestedAt = DateTimeOffset.UtcNow
TestedAt = timeProvider.GetUtcNow()
};
}

View File

@@ -2,6 +2,8 @@ using System.Text.Json;
namespace StellaOps.Scanner.Sources.Domain;
#pragma warning disable CA1062 // Validate arguments of public methods - TimeProvider validated at DI boundary
/// <summary>
/// Represents a configured SBOM ingestion source.
/// Sources can be registry webhooks (Zastava), direct Docker image scans,
@@ -115,12 +117,13 @@ public sealed class SbomSource
SbomSourceType sourceType,
JsonDocument configuration,
string createdBy,
TimeProvider timeProvider,
string? description = null,
string? authRef = null,
string? cronSchedule = null,
string? cronTimezone = null)
{
var now = DateTimeOffset.UtcNow;
var now = timeProvider.GetUtcNow();
var source = new SbomSource
{
SourceId = Guid.NewGuid(),
@@ -148,7 +151,7 @@ public sealed class SbomSource
// Calculate next scheduled run
if (!string.IsNullOrEmpty(cronSchedule))
{
source.CalculateNextScheduledRun();
source.CalculateNextScheduledRun(timeProvider);
}
return source;
@@ -161,37 +164,38 @@ public sealed class SbomSource
/// <summary>
/// Activate the source (after successful validation).
/// </summary>
public void Activate(string updatedBy)
public void Activate(string updatedBy, TimeProvider timeProvider)
{
if (Status == SbomSourceStatus.Disabled)
throw new InvalidOperationException("Cannot activate a disabled source. Enable it first.");
Status = SbomSourceStatus.Active;
UpdatedAt = DateTimeOffset.UtcNow;
UpdatedAt = timeProvider.GetUtcNow();
UpdatedBy = updatedBy;
}
/// <summary>
/// Pause the source with a reason.
/// </summary>
public void Pause(string reason, string? ticket, string pausedBy)
public void Pause(string reason, string? ticket, string pausedBy, TimeProvider timeProvider)
{
if (Paused) return;
var now = timeProvider.GetUtcNow();
Paused = true;
PauseReason = reason;
PauseTicket = ticket;
PausedAt = DateTimeOffset.UtcNow;
PausedAt = now;
PausedBy = pausedBy;
Status = SbomSourceStatus.Paused;
UpdatedAt = DateTimeOffset.UtcNow;
UpdatedAt = now;
UpdatedBy = pausedBy;
}
/// <summary>
/// Resume a paused source.
/// </summary>
public void Resume(string resumedBy)
public void Resume(string resumedBy, TimeProvider timeProvider)
{
if (!Paused) return;
@@ -201,30 +205,30 @@ public sealed class SbomSource
PausedAt = null;
PausedBy = null;
Status = ConsecutiveFailures > 0 ? SbomSourceStatus.Error : SbomSourceStatus.Active;
UpdatedAt = DateTimeOffset.UtcNow;
UpdatedAt = timeProvider.GetUtcNow();
UpdatedBy = resumedBy;
}
/// <summary>
/// Disable the source administratively.
/// </summary>
public void Disable(string disabledBy)
public void Disable(string disabledBy, TimeProvider timeProvider)
{
Status = SbomSourceStatus.Disabled;
UpdatedAt = DateTimeOffset.UtcNow;
UpdatedAt = timeProvider.GetUtcNow();
UpdatedBy = disabledBy;
}
/// <summary>
/// Enable a disabled source.
/// </summary>
public void Enable(string enabledBy)
public void Enable(string enabledBy, TimeProvider timeProvider)
{
if (Status != SbomSourceStatus.Disabled)
throw new InvalidOperationException("Source is not disabled.");
Status = SbomSourceStatus.Pending;
UpdatedAt = DateTimeOffset.UtcNow;
UpdatedAt = timeProvider.GetUtcNow();
UpdatedBy = enabledBy;
}
@@ -235,7 +239,7 @@ public sealed class SbomSource
/// <summary>
/// Record a successful run.
/// </summary>
public void RecordSuccessfulRun(DateTimeOffset runAt)
public void RecordSuccessfulRun(DateTimeOffset runAt, TimeProvider timeProvider)
{
LastRunAt = runAt;
LastRunStatus = SbomSourceRunStatus.Succeeded;
@@ -247,14 +251,14 @@ public sealed class SbomSource
Status = SbomSourceStatus.Active;
}
IncrementHourScans();
CalculateNextScheduledRun();
IncrementHourScans(timeProvider);
CalculateNextScheduledRun(timeProvider);
}
/// <summary>
/// Record a failed run.
/// </summary>
public void RecordFailedRun(DateTimeOffset runAt, string error)
public void RecordFailedRun(DateTimeOffset runAt, string error, TimeProvider timeProvider)
{
LastRunAt = runAt;
LastRunStatus = SbomSourceRunStatus.Failed;
@@ -266,22 +270,22 @@ public sealed class SbomSource
Status = SbomSourceStatus.Error;
}
IncrementHourScans();
CalculateNextScheduledRun();
IncrementHourScans(timeProvider);
CalculateNextScheduledRun(timeProvider);
}
/// <summary>
/// Record a partial success run.
/// </summary>
public void RecordPartialRun(DateTimeOffset runAt, string? warning = null)
public void RecordPartialRun(DateTimeOffset runAt, TimeProvider timeProvider, string? warning = null)
{
LastRunAt = runAt;
LastRunStatus = SbomSourceRunStatus.PartialSuccess;
LastRunError = warning;
// Don't reset consecutive failures for partial success
IncrementHourScans();
CalculateNextScheduledRun();
IncrementHourScans(timeProvider);
CalculateNextScheduledRun(timeProvider);
}
// -------------------------------------------------------------------------
@@ -291,12 +295,12 @@ public sealed class SbomSource
/// <summary>
/// Check if the source is rate limited.
/// </summary>
public bool IsRateLimited()
public bool IsRateLimited(TimeProvider timeProvider)
{
if (!MaxScansPerHour.HasValue) return false;
// Check if we're in a new hour window
var now = DateTimeOffset.UtcNow;
var now = timeProvider.GetUtcNow();
if (!HourWindowStart.HasValue || now - HourWindowStart.Value >= TimeSpan.FromHours(1))
{
return false; // New window, not rate limited
@@ -305,9 +309,9 @@ public sealed class SbomSource
return CurrentHourScans >= MaxScansPerHour.Value;
}
private void IncrementHourScans()
private void IncrementHourScans(TimeProvider timeProvider)
{
var now = DateTimeOffset.UtcNow;
var now = timeProvider.GetUtcNow();
if (!HourWindowStart.HasValue || now - HourWindowStart.Value >= TimeSpan.FromHours(1))
{
@@ -343,14 +347,14 @@ public sealed class SbomSource
/// <summary>
/// Regenerate webhook secret (for rotation).
/// </summary>
public void RotateWebhookSecret(string updatedBy)
public void RotateWebhookSecret(string updatedBy, TimeProvider timeProvider)
{
if (WebhookEndpoint == null)
throw new InvalidOperationException("Source does not have a webhook endpoint.");
// The actual secret rotation happens in the credential store
// This just updates the audit trail
UpdatedAt = DateTimeOffset.UtcNow;
UpdatedAt = timeProvider.GetUtcNow();
UpdatedBy = updatedBy;
}
@@ -361,7 +365,7 @@ public sealed class SbomSource
/// <summary>
/// Calculate the next scheduled run time.
/// </summary>
public void CalculateNextScheduledRun()
public void CalculateNextScheduledRun(TimeProvider timeProvider)
{
if (string.IsNullOrEmpty(CronSchedule))
{
@@ -373,7 +377,7 @@ public sealed class SbomSource
{
var cron = Cronos.CronExpression.Parse(CronSchedule);
var timezone = TimeZoneInfo.FindSystemTimeZoneById(CronTimezone ?? "UTC");
NextScheduledRun = cron.GetNextOccurrence(DateTimeOffset.UtcNow, timezone);
NextScheduledRun = cron.GetNextOccurrence(timeProvider.GetUtcNow(), timezone);
}
catch
{
@@ -397,10 +401,10 @@ public sealed class SbomSource
/// <summary>
/// Update the configuration.
/// </summary>
public void UpdateConfiguration(JsonDocument newConfiguration, string updatedBy)
public void UpdateConfiguration(JsonDocument newConfiguration, string updatedBy, TimeProvider timeProvider)
{
Configuration = newConfiguration;
UpdatedAt = DateTimeOffset.UtcNow;
UpdatedAt = timeProvider.GetUtcNow();
UpdatedBy = updatedBy;
}
}

View File

@@ -1,5 +1,7 @@
namespace StellaOps.Scanner.Sources.Domain;
#pragma warning disable CA1062 // Validate arguments of public methods - TimeProvider validated at DI boundary
/// <summary>
/// Represents a single execution run of an SBOM source.
/// Tracks status, timing, item counts, and any errors.
@@ -30,10 +32,17 @@ public sealed class SbomSourceRun
/// <summary>When the run completed (if finished).</summary>
public DateTimeOffset? CompletedAt { get; private set; }
/// <summary>Duration in milliseconds.</summary>
public long DurationMs => CompletedAt.HasValue
? (long)(CompletedAt.Value - StartedAt).TotalMilliseconds
: (long)(DateTimeOffset.UtcNow - StartedAt).TotalMilliseconds;
/// <summary>
/// Duration in milliseconds. Pass a TimeProvider to get the live duration for in-progress runs.
/// </summary>
public long GetDurationMs(TimeProvider? timeProvider = null)
{
if (CompletedAt.HasValue)
return (long)(CompletedAt.Value - StartedAt).TotalMilliseconds;
var now = timeProvider?.GetUtcNow() ?? DateTimeOffset.UtcNow;
return (long)(now - StartedAt).TotalMilliseconds;
}
/// <summary>Number of items discovered to scan.</summary>
public int ItemsDiscovered { get; private set; }
@@ -74,6 +83,7 @@ public sealed class SbomSourceRun
string tenantId,
SbomSourceRunTrigger trigger,
string correlationId,
TimeProvider timeProvider,
string? triggerDetails = null)
{
return new SbomSourceRun
@@ -84,7 +94,7 @@ public sealed class SbomSourceRun
Trigger = trigger,
TriggerDetails = triggerDetails,
Status = SbomSourceRunStatus.Running,
StartedAt = DateTimeOffset.UtcNow,
StartedAt = timeProvider.GetUtcNow(),
CorrelationId = correlationId
};
}
@@ -135,7 +145,7 @@ public sealed class SbomSourceRun
/// <summary>
/// Complete the run successfully.
/// </summary>
public void Complete()
public void Complete(TimeProvider timeProvider)
{
Status = ItemsFailed > 0
? SbomSourceRunStatus.PartialSuccess
@@ -143,27 +153,27 @@ public sealed class SbomSourceRun
? SbomSourceRunStatus.Succeeded
: SbomSourceRunStatus.Skipped;
CompletedAt = DateTimeOffset.UtcNow;
CompletedAt = timeProvider.GetUtcNow();
}
/// <summary>
/// Fail the run with an error.
/// </summary>
public void Fail(string message, string? stackTrace = null)
public void Fail(string message, TimeProvider timeProvider, string? stackTrace = null)
{
Status = SbomSourceRunStatus.Failed;
ErrorMessage = message;
ErrorStackTrace = stackTrace;
CompletedAt = DateTimeOffset.UtcNow;
CompletedAt = timeProvider.GetUtcNow();
}
/// <summary>
/// Cancel the run.
/// </summary>
public void Cancel(string reason)
public void Cancel(string reason, TimeProvider timeProvider)
{
Status = SbomSourceRunStatus.Cancelled;
ErrorMessage = reason;
CompletedAt = DateTimeOffset.UtcNow;
CompletedAt = timeProvider.GetUtcNow();
}
}

View File

@@ -106,7 +106,7 @@ public sealed record WebhookPayloadInfo
public string? Actor { get; init; }
/// <summary>Timestamp of the event.</summary>
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
public required DateTimeOffset Timestamp { get; init; }
/// <summary>Additional metadata from the payload.</summary>
public Dictionary<string, string> Metadata { get; init; } = [];

View File

@@ -101,6 +101,7 @@ public sealed class SbomSourceService : ISbomSourceService
request.SourceType,
request.Configuration,
createdBy,
_timeProvider,
request.Description,
request.AuthRef,
request.CronSchedule,
@@ -158,7 +159,7 @@ public sealed class SbomSourceService : ISbomSourceService
throw new ArgumentException($"Invalid configuration: {string.Join(", ", validationResult.Errors)}");
}
source.UpdateConfiguration(request.Configuration, updatedBy);
source.UpdateConfiguration(request.Configuration, updatedBy, _timeProvider);
}
// Validate cron schedule if provided
@@ -177,7 +178,7 @@ public sealed class SbomSourceService : ISbomSourceService
}
source.CronSchedule = request.CronSchedule;
source.CalculateNextScheduledRun();
source.CalculateNextScheduledRun(_timeProvider);
}
// Update simple fields via reflection (maintaining encapsulation)
@@ -199,7 +200,7 @@ public sealed class SbomSourceService : ISbomSourceService
if (request.CronTimezone != null)
{
source.CronTimezone = request.CronTimezone;
source.CalculateNextScheduledRun();
source.CalculateNextScheduledRun(_timeProvider);
}
if (request.MaxScansPerHour.HasValue)
@@ -265,6 +266,7 @@ public sealed class SbomSourceService : ISbomSourceService
request.SourceType,
request.Configuration,
"__test__",
_timeProvider,
authRef: request.AuthRef);
return await _connectionTester.TestAsync(tempSource, request.TestCredentials, ct);
@@ -280,7 +282,7 @@ public sealed class SbomSourceService : ISbomSourceService
var source = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct)
?? throw new KeyNotFoundException($"Source {sourceId} not found");
source.Pause(request.Reason, request.Ticket, pausedBy);
source.Pause(request.Reason, request.Ticket, pausedBy, _timeProvider);
await _sourceRepository.UpdateAsync(source, ct);
_logger.LogInformation(
@@ -299,7 +301,7 @@ public sealed class SbomSourceService : ISbomSourceService
var source = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct)
?? throw new KeyNotFoundException($"Source {sourceId} not found");
source.Resume(resumedBy);
source.Resume(resumedBy, _timeProvider);
await _sourceRepository.UpdateAsync(source, ct);
_logger.LogInformation(
@@ -330,7 +332,7 @@ public sealed class SbomSourceService : ISbomSourceService
throw new InvalidOperationException($"Source is paused: {source.PauseReason}");
}
if (source.IsRateLimited() && request?.Force != true)
if (source.IsRateLimited(_timeProvider) && request?.Force != true)
{
throw new InvalidOperationException("Source is rate limited. Use force=true to override.");
}
@@ -341,6 +343,7 @@ public sealed class SbomSourceService : ISbomSourceService
tenantId,
SbomSourceRunTrigger.Manual,
Guid.NewGuid().ToString("N"),
_timeProvider,
$"Triggered by {triggeredBy}");
await _runRepository.CreateAsync(run, ct);
@@ -407,7 +410,7 @@ public sealed class SbomSourceService : ISbomSourceService
var source = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct)
?? throw new KeyNotFoundException($"Source {sourceId} not found");
source.Activate(activatedBy);
source.Activate(activatedBy, _timeProvider);
await _sourceRepository.UpdateAsync(source, ct);
_logger.LogInformation(

View File

@@ -0,0 +1,146 @@
// -----------------------------------------------------------------------------
// SecretDetectionSettingsRow.cs
// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
// Task: SDC-004 - Add persistence
// Description: Entity mapping for secret_detection_settings table.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Storage.Entities;
/// <summary>
/// Entity mapping to scanner.secret_detection_settings table.
/// Per-tenant configuration for secret detection behavior.
/// </summary>
public sealed class SecretDetectionSettingsRow
{
/// <summary>Unique identifier for this settings record.</summary>
public Guid SettingsId { get; set; }
/// <summary>Tenant this configuration belongs to.</summary>
public Guid TenantId { get; set; }
/// <summary>Whether secret detection is enabled for this tenant.</summary>
public bool Enabled { get; set; }
/// <summary>Revelation policy configuration as JSON.</summary>
public string RevelationPolicy { get; set; } = default!;
/// <summary>Enabled rule categories.</summary>
public string[] EnabledRuleCategories { get; set; } = [];
/// <summary>Disabled rule IDs.</summary>
public string[] DisabledRuleIds { get; set; } = [];
/// <summary>Alert settings as JSON.</summary>
public string AlertSettings { get; set; } = default!;
/// <summary>Maximum file size to scan (bytes).</summary>
public long MaxFileSizeBytes { get; set; }
/// <summary>File extensions to exclude from scanning.</summary>
public string[] ExcludedFileExtensions { get; set; } = [];
/// <summary>Path patterns to exclude from scanning.</summary>
public string[] ExcludedPaths { get; set; } = [];
/// <summary>Whether to scan binary files.</summary>
public bool ScanBinaryFiles { get; set; }
/// <summary>Whether to require signature verification for rule bundles.</summary>
public bool RequireSignedRuleBundles { get; set; }
/// <summary>Version for optimistic concurrency.</summary>
public int Version { get; set; }
/// <summary>When this configuration was last updated.</summary>
public DateTimeOffset UpdatedAt { get; set; }
/// <summary>Who last updated this configuration.</summary>
public string UpdatedBy { get; set; } = default!;
/// <summary>When this row was created.</summary>
public DateTimeOffset CreatedAt { get; set; }
}
/// <summary>
/// Entity mapping to scanner.secret_exception_pattern table.
/// Allowlist patterns for false positive suppression.
/// </summary>
public sealed class SecretExceptionPatternRow
{
/// <summary>Unique identifier for this exception.</summary>
public Guid ExceptionId { get; set; }
/// <summary>Tenant this exception belongs to.</summary>
public Guid TenantId { get; set; }
/// <summary>Human-readable name for the exception.</summary>
public string Name { get; set; } = default!;
/// <summary>Detailed description of why this exception exists.</summary>
public string Description { get; set; } = default!;
/// <summary>Regex pattern to match against detected secret value.</summary>
public string ValuePattern { get; set; } = default!;
/// <summary>Rule IDs this exception applies to.</summary>
public string[] ApplicableRuleIds { get; set; } = [];
/// <summary>File path glob pattern.</summary>
public string? FilePathGlob { get; set; }
/// <summary>Business justification for this exception.</summary>
public string Justification { get; set; } = default!;
/// <summary>Expiration date (null means permanent).</summary>
public DateTimeOffset? ExpiresAt { get; set; }
/// <summary>Whether this exception is currently active.</summary>
public bool IsActive { get; set; }
/// <summary>Number of times this exception has matched a finding.</summary>
public long MatchCount { get; set; }
/// <summary>Last time this exception matched a finding.</summary>
public DateTimeOffset? LastMatchedAt { get; set; }
/// <summary>When this exception was created.</summary>
public DateTimeOffset CreatedAt { get; set; }
/// <summary>Who created this exception.</summary>
public string CreatedBy { get; set; } = default!;
/// <summary>When this exception was last modified.</summary>
public DateTimeOffset? UpdatedAt { get; set; }
/// <summary>Who last modified this exception.</summary>
public string? UpdatedBy { get; set; }
}
/// <summary>
/// Entity mapping to scanner.secret_exception_match_log table.
/// Audit log for exception matches.
/// </summary>
public sealed class SecretExceptionMatchLogRow
{
/// <summary>Unique identifier for this log entry.</summary>
public Guid LogId { get; set; }
/// <summary>Tenant this match belongs to.</summary>
public Guid TenantId { get; set; }
/// <summary>Exception that matched.</summary>
public Guid ExceptionId { get; set; }
/// <summary>Scan ID where the match occurred.</summary>
public Guid? ScanId { get; set; }
/// <summary>File path where the match occurred.</summary>
public string? FilePath { get; set; }
/// <summary>Rule ID that triggered the finding.</summary>
public string? RuleId { get; set; }
/// <summary>When the match occurred.</summary>
public DateTimeOffset MatchedAt { get; set; }
}

View File

@@ -242,8 +242,8 @@ public sealed class EpssReplayService
DateOnly? endDate = null,
CancellationToken cancellationToken = default)
{
var start = startDate ?? DateOnly.FromDateTime(DateTime.UtcNow.AddYears(-1));
var end = endDate ?? DateOnly.FromDateTime(DateTime.UtcNow);
var start = startDate ?? DateOnly.FromDateTime(_timeProvider.GetUtcNow().UtcDateTime.AddYears(-1));
var end = endDate ?? DateOnly.FromDateTime(_timeProvider.GetUtcNow().UtcDateTime);
var rawPayloads = await _rawRepository.GetByDateRangeAsync(start, end, cancellationToken)
.ConfigureAwait(false);

View File

@@ -116,6 +116,10 @@ public static class ServiceCollectionExtensions
// Witness storage (Sprint: SPRINT_3700_0001_0001)
services.AddScoped<IWitnessRepository, PostgresWitnessRepository>();
// Secret detection settings (Sprint: SPRINT_20260104_006_BE)
services.AddScoped<ISecretDetectionSettingsRepository, PostgresSecretDetectionSettingsRepository>();
services.AddScoped<ISecretExceptionPatternRepository, PostgresSecretExceptionPatternRepository>();
services.AddSingleton<IEntryTraceResultStore, EntryTraceResultStore>();
services.AddSingleton<IRubyPackageInventoryStore, RubyPackageInventoryStore>();
services.AddSingleton<IBunPackageInventoryStore, BunPackageInventoryStore>();

View File

@@ -33,7 +33,7 @@ public sealed record ClassificationChange
public IReadOnlyDictionary<string, string>? CauseDetail { get; init; }
// Timestamp
public DateTimeOffset ChangedAt { get; init; } = DateTimeOffset.UtcNow;
public required DateTimeOffset ChangedAt { get; init; }
}
/// <summary>

View File

@@ -58,7 +58,7 @@ public sealed record ScanMetrics
// Replay mode
public bool IsReplay { get; init; }
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
public required DateTimeOffset CreatedAt { get; init; }
}
/// <summary>

View File

@@ -10,12 +10,18 @@ public sealed class S3ArtifactObjectStore : IArtifactObjectStore
private readonly IAmazonS3 _s3;
private readonly ObjectStoreOptions _options;
private readonly ILogger<S3ArtifactObjectStore> _logger;
private readonly TimeProvider _timeProvider;
public S3ArtifactObjectStore(IAmazonS3 s3, IOptions<ScannerStorageOptions> options, ILogger<S3ArtifactObjectStore> logger)
public S3ArtifactObjectStore(
IAmazonS3 s3,
IOptions<ScannerStorageOptions> options,
ILogger<S3ArtifactObjectStore> logger,
TimeProvider? timeProvider = null)
{
_s3 = s3 ?? throw new ArgumentNullException(nameof(s3));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value.ObjectStore;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task PutAsync(ArtifactObjectDescriptor descriptor, Stream content, CancellationToken cancellationToken)
@@ -36,11 +42,11 @@ public sealed class S3ArtifactObjectStore : IArtifactObjectStore
request.ObjectLockMode = ObjectLockMode.Compliance;
if (descriptor.RetainFor is { } retention && retention > TimeSpan.Zero)
{
request.ObjectLockRetainUntilDate = DateTime.UtcNow + retention;
request.ObjectLockRetainUntilDate = _timeProvider.GetUtcNow().UtcDateTime + retention;
}
else if (_options.ComplianceRetention is { } defaultRetention && defaultRetention > TimeSpan.Zero)
{
request.ObjectLockRetainUntilDate = DateTime.UtcNow + defaultRetention;
request.ObjectLockRetainUntilDate = _timeProvider.GetUtcNow().UtcDateTime + defaultRetention;
}
}

View File

@@ -0,0 +1,143 @@
-- =============================================================================
-- Migration: 021_secret_detection_settings.sql
-- Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
-- Task: SDC-004 - Add persistence (EF Core migrations)
-- Description: Per-tenant configuration for secret detection behavior.
--
-- Note: migrations are executed with the module schema as the active search_path.
-- Keep objects unqualified so integration tests can run in isolated schemas.
-- =============================================================================
-- =============================================================================
-- SECRET_DETECTION_SETTINGS: Per-tenant secret detection configuration
-- =============================================================================
CREATE TABLE IF NOT EXISTS secret_detection_settings (
settings_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
-- Global toggle
enabled BOOLEAN NOT NULL DEFAULT FALSE,
-- Revelation policy configuration (stored as JSONB)
revelation_policy JSONB NOT NULL DEFAULT '{
"defaultPolicy": 1,
"exportPolicy": 0,
"partialRevealChars": 4,
"maxMaskChars": 8,
"fullRevealRoles": ["security-admin", "incident-responder"]
}'::jsonb,
-- Rule configuration
enabled_rule_categories TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
disabled_rule_ids TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
-- Alert settings (stored as JSONB)
alert_settings JSONB NOT NULL DEFAULT '{
"enabled": false,
"minimumAlertSeverity": 2,
"destinations": [],
"maxAlertsPerScan": 10,
"deduplicationWindowMinutes": 1440,
"includeFilePath": true,
"includeMaskedValue": true,
"includeImageRef": true
}'::jsonb,
-- Scanning limits
max_file_size_bytes BIGINT NOT NULL DEFAULT 10485760,
excluded_file_extensions TEXT[] NOT NULL DEFAULT ARRAY['.exe', '.dll', '.so', '.dylib', '.bin', '.png', '.jpg', '.jpeg', '.gif', '.ico', '.woff', '.woff2', '.ttf', '.eot']::TEXT[],
excluded_paths TEXT[] NOT NULL DEFAULT ARRAY['**/node_modules/**', '**/vendor/**', '**/.git/**']::TEXT[],
scan_binary_files BOOLEAN NOT NULL DEFAULT FALSE,
require_signed_rule_bundles BOOLEAN NOT NULL DEFAULT TRUE,
-- Audit fields
version INTEGER NOT NULL DEFAULT 1,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by VARCHAR(256) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_secret_detection_settings_tenant UNIQUE (tenant_id)
);
CREATE INDEX IF NOT EXISTS idx_secret_detection_settings_tenant ON secret_detection_settings(tenant_id);
CREATE INDEX IF NOT EXISTS idx_secret_detection_settings_enabled ON secret_detection_settings(enabled) WHERE enabled = TRUE;
COMMENT ON TABLE secret_detection_settings IS 'Per-tenant configuration for secret detection behavior.';
-- =============================================================================
-- SECRET_EXCEPTION_PATTERN: Allowlist patterns for false positive suppression
-- =============================================================================
CREATE TABLE IF NOT EXISTS secret_exception_pattern (
exception_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
-- Pattern definition
name VARCHAR(100) NOT NULL,
description TEXT NOT NULL,
value_pattern TEXT NOT NULL,
applicable_rule_ids TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
file_path_glob VARCHAR(512),
justification TEXT NOT NULL,
-- Validity
expires_at TIMESTAMPTZ,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
-- Usage tracking
match_count BIGINT NOT NULL DEFAULT 0,
last_matched_at TIMESTAMPTZ,
-- Audit fields
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by VARCHAR(256) NOT NULL,
updated_at TIMESTAMPTZ,
updated_by VARCHAR(256),
CONSTRAINT uq_secret_exception_pattern_name UNIQUE (tenant_id, name)
);
CREATE INDEX IF NOT EXISTS idx_secret_exception_pattern_tenant ON secret_exception_pattern(tenant_id);
CREATE INDEX IF NOT EXISTS idx_secret_exception_pattern_active ON secret_exception_pattern(tenant_id, is_active) WHERE is_active = TRUE;
CREATE INDEX IF NOT EXISTS idx_secret_exception_pattern_expires ON secret_exception_pattern(expires_at) WHERE expires_at IS NOT NULL;
COMMENT ON TABLE secret_exception_pattern IS 'Allowlist patterns for suppressing false positive secret detections.';
-- =============================================================================
-- SECRET_EXCEPTION_MATCH_LOG: Audit log for exception matches
-- =============================================================================
CREATE TABLE IF NOT EXISTS secret_exception_match_log (
log_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
exception_id UUID NOT NULL REFERENCES secret_exception_pattern(exception_id) ON DELETE CASCADE,
-- Match context
scan_id UUID,
file_path VARCHAR(1024),
rule_id VARCHAR(128),
matched_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_secret_exception_match_log_exception ON secret_exception_match_log(exception_id);
CREATE INDEX IF NOT EXISTS idx_secret_exception_match_log_tenant_date ON secret_exception_match_log(tenant_id, matched_at DESC);
COMMENT ON TABLE secret_exception_match_log IS 'Audit log tracking when exception patterns match findings.';
-- =============================================================================
-- Trigger to update match_count on exception patterns
-- =============================================================================
CREATE OR REPLACE FUNCTION update_exception_match_count()
RETURNS TRIGGER AS $$
BEGIN
UPDATE secret_exception_pattern
SET match_count = match_count + 1,
last_matched_at = NEW.matched_at
WHERE exception_id = NEW.exception_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_secret_exception_match_count ON secret_exception_match_log;
CREATE TRIGGER trg_secret_exception_match_count
AFTER INSERT ON secret_exception_match_log
FOR EACH ROW
EXECUTE FUNCTION update_exception_match_count();

View File

@@ -21,5 +21,9 @@ internal static class MigrationIds
public const string ReachCache = "016_reach_cache.sql";
public const string IdempotencyKeys = "017_idempotency_keys.sql";
public const string BinaryEvidence = "018_binary_evidence.sql";
public const string FuncProofTables = "019_func_proof_tables.sql";
public const string EnablePgTrgm = "019_enable_pg_trgm.sql";
public const string SbomSources = "020_sbom_sources.sql";
public const string SecretDetectionSettings = "021_secret_detection_settings.sql";
}

View File

@@ -0,0 +1,451 @@
// -----------------------------------------------------------------------------
// PostgresSecretDetectionSettingsRepository.cs
// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
// Task: SDC-004 - Add persistence
// Description: PostgreSQL implementation for secret detection settings.
// -----------------------------------------------------------------------------
using Dapper;
using StellaOps.Scanner.Storage.Entities;
using StellaOps.Scanner.Storage.Repositories;
namespace StellaOps.Scanner.Storage.Postgres;
/// <summary>
/// PostgreSQL implementation of secret detection settings repository.
/// </summary>
public sealed class PostgresSecretDetectionSettingsRepository : ISecretDetectionSettingsRepository
{
private readonly ScannerDataSource _dataSource;
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
private string TableName => $"{SchemaName}.secret_detection_settings";
public PostgresSecretDetectionSettingsRepository(ScannerDataSource dataSource)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
}
public async Task<SecretDetectionSettingsRow?> GetByTenantAsync(
Guid tenantId,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
settings_id AS SettingsId,
tenant_id AS TenantId,
enabled AS Enabled,
revelation_policy AS RevelationPolicy,
enabled_rule_categories AS EnabledRuleCategories,
disabled_rule_ids AS DisabledRuleIds,
alert_settings AS AlertSettings,
max_file_size_bytes AS MaxFileSizeBytes,
excluded_file_extensions AS ExcludedFileExtensions,
excluded_paths AS ExcludedPaths,
scan_binary_files AS ScanBinaryFiles,
require_signed_rule_bundles AS RequireSignedRuleBundles,
version AS Version,
updated_at AS UpdatedAt,
updated_by AS UpdatedBy,
created_at AS CreatedAt
FROM {TableName}
WHERE tenant_id = @TenantId
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
return await connection.QuerySingleOrDefaultAsync<SecretDetectionSettingsRow>(
new CommandDefinition(sql, new { TenantId = tenantId }, cancellationToken: cancellationToken))
.ConfigureAwait(false);
}
public async Task<SecretDetectionSettingsRow> CreateAsync(
SecretDetectionSettingsRow settings,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(settings);
var sql = $"""
INSERT INTO {TableName} (
tenant_id,
enabled,
revelation_policy,
enabled_rule_categories,
disabled_rule_ids,
alert_settings,
max_file_size_bytes,
excluded_file_extensions,
excluded_paths,
scan_binary_files,
require_signed_rule_bundles,
updated_by
) VALUES (
@TenantId,
@Enabled,
@RevelationPolicy::jsonb,
@EnabledRuleCategories,
@DisabledRuleIds,
@AlertSettings::jsonb,
@MaxFileSizeBytes,
@ExcludedFileExtensions,
@ExcludedPaths,
@ScanBinaryFiles,
@RequireSignedRuleBundles,
@UpdatedBy
)
RETURNING settings_id AS SettingsId, version AS Version, created_at AS CreatedAt, updated_at AS UpdatedAt
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var result = await connection.QuerySingleAsync<(Guid SettingsId, int Version, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt)>(
new CommandDefinition(sql, settings, cancellationToken: cancellationToken))
.ConfigureAwait(false);
settings.SettingsId = result.SettingsId;
settings.Version = result.Version;
settings.CreatedAt = result.CreatedAt;
settings.UpdatedAt = result.UpdatedAt;
return settings;
}
public async Task<bool> UpdateAsync(
SecretDetectionSettingsRow settings,
int expectedVersion,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(settings);
var sql = $"""
UPDATE {TableName}
SET
enabled = @Enabled,
revelation_policy = @RevelationPolicy::jsonb,
enabled_rule_categories = @EnabledRuleCategories,
disabled_rule_ids = @DisabledRuleIds,
alert_settings = @AlertSettings::jsonb,
max_file_size_bytes = @MaxFileSizeBytes,
excluded_file_extensions = @ExcludedFileExtensions,
excluded_paths = @ExcludedPaths,
scan_binary_files = @ScanBinaryFiles,
require_signed_rule_bundles = @RequireSignedRuleBundles,
version = version + 1,
updated_at = NOW(),
updated_by = @UpdatedBy
WHERE settings_id = @SettingsId AND version = @ExpectedVersion
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var rowsAffected = await connection.ExecuteAsync(
new CommandDefinition(sql, new
{
settings.SettingsId,
settings.Enabled,
settings.RevelationPolicy,
settings.EnabledRuleCategories,
settings.DisabledRuleIds,
settings.AlertSettings,
settings.MaxFileSizeBytes,
settings.ExcludedFileExtensions,
settings.ExcludedPaths,
settings.ScanBinaryFiles,
settings.RequireSignedRuleBundles,
settings.UpdatedBy,
ExpectedVersion = expectedVersion
}, cancellationToken: cancellationToken))
.ConfigureAwait(false);
return rowsAffected > 0;
}
public async Task<IReadOnlyList<Guid>> GetEnabledTenantsAsync(CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT tenant_id
FROM {TableName}
WHERE enabled = TRUE
ORDER BY tenant_id
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var result = await connection.QueryAsync<Guid>(
new CommandDefinition(sql, cancellationToken: cancellationToken))
.ConfigureAwait(false);
return result.ToList();
}
}
/// <summary>
/// PostgreSQL implementation of secret exception pattern repository.
/// </summary>
public sealed class PostgresSecretExceptionPatternRepository : ISecretExceptionPatternRepository
{
private readonly ScannerDataSource _dataSource;
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
private string PatternTableName => $"{SchemaName}.secret_exception_pattern";
private string MatchLogTableName => $"{SchemaName}.secret_exception_match_log";
public PostgresSecretExceptionPatternRepository(ScannerDataSource dataSource)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
}
public async Task<IReadOnlyList<SecretExceptionPatternRow>> GetActiveByTenantAsync(
Guid tenantId,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
exception_id AS ExceptionId,
tenant_id AS TenantId,
name AS Name,
description AS Description,
value_pattern AS ValuePattern,
applicable_rule_ids AS ApplicableRuleIds,
file_path_glob AS FilePathGlob,
justification AS Justification,
expires_at AS ExpiresAt,
is_active AS IsActive,
match_count AS MatchCount,
last_matched_at AS LastMatchedAt,
created_at AS CreatedAt,
created_by AS CreatedBy,
updated_at AS UpdatedAt,
updated_by AS UpdatedBy
FROM {PatternTableName}
WHERE tenant_id = @TenantId
AND is_active = TRUE
AND (expires_at IS NULL OR expires_at > NOW())
ORDER BY name
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var result = await connection.QueryAsync<SecretExceptionPatternRow>(
new CommandDefinition(sql, new { TenantId = tenantId }, cancellationToken: cancellationToken))
.ConfigureAwait(false);
return result.ToList();
}
public async Task<SecretExceptionPatternRow?> GetByIdAsync(
Guid exceptionId,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
exception_id AS ExceptionId,
tenant_id AS TenantId,
name AS Name,
description AS Description,
value_pattern AS ValuePattern,
applicable_rule_ids AS ApplicableRuleIds,
file_path_glob AS FilePathGlob,
justification AS Justification,
expires_at AS ExpiresAt,
is_active AS IsActive,
match_count AS MatchCount,
last_matched_at AS LastMatchedAt,
created_at AS CreatedAt,
created_by AS CreatedBy,
updated_at AS UpdatedAt,
updated_by AS UpdatedBy
FROM {PatternTableName}
WHERE exception_id = @ExceptionId
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
return await connection.QuerySingleOrDefaultAsync<SecretExceptionPatternRow>(
new CommandDefinition(sql, new { ExceptionId = exceptionId }, cancellationToken: cancellationToken))
.ConfigureAwait(false);
}
public async Task<SecretExceptionPatternRow> CreateAsync(
SecretExceptionPatternRow pattern,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(pattern);
var sql = $"""
INSERT INTO {PatternTableName} (
tenant_id,
name,
description,
value_pattern,
applicable_rule_ids,
file_path_glob,
justification,
expires_at,
is_active,
created_by
) VALUES (
@TenantId,
@Name,
@Description,
@ValuePattern,
@ApplicableRuleIds,
@FilePathGlob,
@Justification,
@ExpiresAt,
@IsActive,
@CreatedBy
)
RETURNING exception_id AS ExceptionId, created_at AS CreatedAt
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var result = await connection.QuerySingleAsync<(Guid ExceptionId, DateTimeOffset CreatedAt)>(
new CommandDefinition(sql, pattern, cancellationToken: cancellationToken))
.ConfigureAwait(false);
pattern.ExceptionId = result.ExceptionId;
pattern.CreatedAt = result.CreatedAt;
return pattern;
}
public async Task<bool> UpdateAsync(
SecretExceptionPatternRow pattern,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(pattern);
var sql = $"""
UPDATE {PatternTableName}
SET
name = @Name,
description = @Description,
value_pattern = @ValuePattern,
applicable_rule_ids = @ApplicableRuleIds,
file_path_glob = @FilePathGlob,
justification = @Justification,
expires_at = @ExpiresAt,
is_active = @IsActive,
updated_at = NOW(),
updated_by = @UpdatedBy
WHERE exception_id = @ExceptionId
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var rowsAffected = await connection.ExecuteAsync(
new CommandDefinition(sql, pattern, cancellationToken: cancellationToken))
.ConfigureAwait(false);
return rowsAffected > 0;
}
public async Task<bool> DeleteAsync(
Guid exceptionId,
CancellationToken cancellationToken = default)
{
var sql = $"""
DELETE FROM {PatternTableName}
WHERE exception_id = @ExceptionId
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var rowsAffected = await connection.ExecuteAsync(
new CommandDefinition(sql, new { ExceptionId = exceptionId }, cancellationToken: cancellationToken))
.ConfigureAwait(false);
return rowsAffected > 0;
}
public async Task<IReadOnlyList<SecretExceptionPatternRow>> GetAllByTenantAsync(
Guid tenantId,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
exception_id AS ExceptionId,
tenant_id AS TenantId,
name AS Name,
description AS Description,
value_pattern AS ValuePattern,
applicable_rule_ids AS ApplicableRuleIds,
file_path_glob AS FilePathGlob,
justification AS Justification,
expires_at AS ExpiresAt,
is_active AS IsActive,
match_count AS MatchCount,
last_matched_at AS LastMatchedAt,
created_at AS CreatedAt,
created_by AS CreatedBy,
updated_at AS UpdatedAt,
updated_by AS UpdatedBy
FROM {PatternTableName}
WHERE tenant_id = @TenantId
ORDER BY name
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var result = await connection.QueryAsync<SecretExceptionPatternRow>(
new CommandDefinition(sql, new { TenantId = tenantId }, cancellationToken: cancellationToken))
.ConfigureAwait(false);
return result.ToList();
}
public async Task RecordMatchAsync(
Guid tenantId,
Guid exceptionId,
Guid? scanId,
string? filePath,
string? ruleId,
CancellationToken cancellationToken = default)
{
var sql = $"""
INSERT INTO {MatchLogTableName} (
tenant_id,
exception_id,
scan_id,
file_path,
rule_id
) VALUES (
@TenantId,
@ExceptionId,
@ScanId,
@FilePath,
@RuleId
)
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await connection.ExecuteAsync(
new CommandDefinition(sql, new { TenantId = tenantId, ExceptionId = exceptionId, ScanId = scanId, FilePath = filePath, RuleId = ruleId }, cancellationToken: cancellationToken))
.ConfigureAwait(false);
}
public async Task<IReadOnlyList<SecretExceptionPatternRow>> GetExpiredAsync(
DateTimeOffset asOf,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
exception_id AS ExceptionId,
tenant_id AS TenantId,
name AS Name,
description AS Description,
value_pattern AS ValuePattern,
applicable_rule_ids AS ApplicableRuleIds,
file_path_glob AS FilePathGlob,
justification AS Justification,
expires_at AS ExpiresAt,
is_active AS IsActive,
match_count AS MatchCount,
last_matched_at AS LastMatchedAt,
created_at AS CreatedAt,
created_by AS CreatedBy,
updated_at AS UpdatedAt,
updated_by AS UpdatedBy
FROM {PatternTableName}
WHERE expires_at IS NOT NULL
AND expires_at <= @AsOf
AND is_active = TRUE
ORDER BY expires_at
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var result = await connection.QueryAsync<SecretExceptionPatternRow>(
new CommandDefinition(sql, new { AsOf = asOf }, cancellationToken: cancellationToken))
.ConfigureAwait(false);
return result.ToList();
}
}

View File

@@ -0,0 +1,111 @@
// -----------------------------------------------------------------------------
// ISecretDetectionSettingsRepository.cs
// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
// Task: SDC-004 - Add persistence
// Description: Repository interfaces for secret detection settings.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Storage.Entities;
namespace StellaOps.Scanner.Storage.Repositories;
/// <summary>
/// Repository interface for secret detection settings operations.
/// </summary>
public interface ISecretDetectionSettingsRepository
{
/// <summary>
/// Gets the settings for a tenant.
/// </summary>
Task<SecretDetectionSettingsRow?> GetByTenantAsync(
Guid tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates new settings for a tenant.
/// </summary>
Task<SecretDetectionSettingsRow> CreateAsync(
SecretDetectionSettingsRow settings,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates settings for a tenant with optimistic concurrency.
/// </summary>
/// <returns>True if update succeeded, false if version conflict.</returns>
Task<bool> UpdateAsync(
SecretDetectionSettingsRow settings,
int expectedVersion,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all tenants with secret detection enabled.
/// </summary>
Task<IReadOnlyList<Guid>> GetEnabledTenantsAsync(
CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for secret exception pattern operations.
/// </summary>
public interface ISecretExceptionPatternRepository
{
/// <summary>
/// Gets all active exception patterns for a tenant.
/// </summary>
Task<IReadOnlyList<SecretExceptionPatternRow>> GetActiveByTenantAsync(
Guid tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a specific exception pattern.
/// </summary>
Task<SecretExceptionPatternRow?> GetByIdAsync(
Guid exceptionId,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates a new exception pattern.
/// </summary>
Task<SecretExceptionPatternRow> CreateAsync(
SecretExceptionPatternRow pattern,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates an exception pattern.
/// </summary>
Task<bool> UpdateAsync(
SecretExceptionPatternRow pattern,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes an exception pattern.
/// </summary>
Task<bool> DeleteAsync(
Guid exceptionId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all exception patterns for a tenant (including inactive).
/// </summary>
Task<IReadOnlyList<SecretExceptionPatternRow>> GetAllByTenantAsync(
Guid tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// Records that an exception pattern matched a finding.
/// </summary>
Task RecordMatchAsync(
Guid tenantId,
Guid exceptionId,
Guid? scanId,
string? filePath,
string? ruleId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets expired exception patterns that need review.
/// </summary>
Task<IReadOnlyList<SecretExceptionPatternRow>> GetExpiredAsync(
DateTimeOffset asOf,
CancellationToken cancellationToken = default);
}

View File

@@ -22,11 +22,16 @@ public sealed class PostgresWitnessRepository : IWitnessRepository
private readonly ScannerDataSource _dataSource;
private readonly ILogger<PostgresWitnessRepository> _logger;
private readonly TimeProvider _timeProvider;
public PostgresWitnessRepository(ScannerDataSource dataSource, ILogger<PostgresWitnessRepository> logger)
public PostgresWitnessRepository(
ScannerDataSource dataSource,
ILogger<PostgresWitnessRepository> logger,
TimeProvider? timeProvider = null)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<Guid> StoreAsync(WitnessRecord witness, CancellationToken cancellationToken = default)
@@ -61,7 +66,7 @@ public sealed class PostgresWitnessRepository : IWitnessRepository
cmd.Parameters.AddWithValue("run_id", witness.RunId.HasValue ? witness.RunId.Value : DBNull.Value);
cmd.Parameters.AddWithValue("payload_json", witness.PayloadJson);
cmd.Parameters.AddWithValue("dsse_envelope", string.IsNullOrEmpty(witness.DsseEnvelope) ? DBNull.Value : witness.DsseEnvelope);
cmd.Parameters.AddWithValue("created_at", witness.CreatedAt == default ? DateTimeOffset.UtcNow : witness.CreatedAt);
cmd.Parameters.AddWithValue("created_at", witness.CreatedAt == default ? _timeProvider.GetUtcNow() : witness.CreatedAt);
cmd.Parameters.AddWithValue("signed_at", witness.SignedAt.HasValue ? witness.SignedAt.Value : DBNull.Value);
cmd.Parameters.AddWithValue("signer_key_id", string.IsNullOrEmpty(witness.SignerKeyId) ? DBNull.Value : witness.SignerKeyId);
cmd.Parameters.AddWithValue("entrypoint_fqn", string.IsNullOrEmpty(witness.EntrypointFqn) ? DBNull.Value : witness.EntrypointFqn);
@@ -217,7 +222,7 @@ public sealed class PostgresWitnessRepository : IWitnessRepository
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("witness_id", witnessId);
cmd.Parameters.AddWithValue("dsse_envelope", dsseEnvelopeJson);
cmd.Parameters.AddWithValue("signed_at", DateTimeOffset.UtcNow);
cmd.Parameters.AddWithValue("signed_at", _timeProvider.GetUtcNow());
cmd.Parameters.AddWithValue("signer_key_id", string.IsNullOrEmpty(signerKeyId) ? DBNull.Value : signerKeyId);
var affected = await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
@@ -244,7 +249,7 @@ public sealed class PostgresWitnessRepository : IWitnessRepository
await using var conn = await _dataSource.OpenConnectionAsync(TenantContext, cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("witness_id", verification.WitnessId);
cmd.Parameters.AddWithValue("verified_at", verification.VerifiedAt == default ? DateTimeOffset.UtcNow : verification.VerifiedAt);
cmd.Parameters.AddWithValue("verified_at", verification.VerifiedAt == default ? _timeProvider.GetUtcNow() : verification.VerifiedAt);
cmd.Parameters.AddWithValue("verified_by", string.IsNullOrEmpty(verification.VerifiedBy) ? DBNull.Value : verification.VerifiedBy);
cmd.Parameters.AddWithValue("verification_status", verification.VerificationStatus);
cmd.Parameters.AddWithValue("verification_error", string.IsNullOrEmpty(verification.VerificationError) ? DBNull.Value : verification.VerificationError);

View File

@@ -11,13 +11,16 @@ public sealed class FnDriftCalculator
{
private readonly IClassificationHistoryRepository _repository;
private readonly ILogger<FnDriftCalculator> _logger;
private readonly TimeProvider _timeProvider;
public FnDriftCalculator(
IClassificationHistoryRepository repository,
ILogger<FnDriftCalculator> logger)
ILogger<FnDriftCalculator> logger,
TimeProvider? timeProvider = null)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
@@ -32,7 +35,7 @@ public sealed class FnDriftCalculator
int windowDays = 30,
CancellationToken cancellationToken = default)
{
var since = DateTimeOffset.UtcNow.AddDays(-windowDays);
var since = _timeProvider.GetUtcNow().AddDays(-windowDays);
var changes = await _repository.GetChangesAsync(tenantId, since, cancellationToken);
var fnTransitions = changes.Where(c => c.IsFnTransition).ToList();
@@ -146,7 +149,7 @@ public sealed class FnDriftCalculator
NewStatus = newStatus,
Cause = cause,
CauseDetail = causeDetail,
ChangedAt = DateTimeOffset.UtcNow
ChangedAt = _timeProvider.GetUtcNow()
};
}

View File

@@ -18,18 +18,21 @@ public sealed class SurfaceEnvironmentBuilder
private readonly IConfiguration _configuration;
private readonly ILogger<SurfaceEnvironmentBuilder> _logger;
private readonly SurfaceEnvironmentOptions _options;
private readonly TimeProvider _timeProvider;
private readonly Dictionary<string, string> _raw = new(StringComparer.OrdinalIgnoreCase);
public SurfaceEnvironmentBuilder(
IServiceProvider services,
IConfiguration configuration,
ILogger<SurfaceEnvironmentBuilder> logger,
SurfaceEnvironmentOptions options)
SurfaceEnvironmentOptions options,
TimeProvider? timeProvider = null)
{
_services = services ?? throw new ArgumentNullException(nameof(services));
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? TimeProvider.System;
if (_options.Prefixes.Count == 0)
{
@@ -60,9 +63,12 @@ public sealed class SurfaceEnvironmentBuilder
featureFlags,
secrets,
tenant,
tls);
tls)
{
CreatedAtUtc = _timeProvider.GetUtcNow()
};
return settings with { CreatedAtUtc = DateTimeOffset.UtcNow };
return settings;
}
public IReadOnlyDictionary<string, string> GetRawVariables()

View File

@@ -21,5 +21,5 @@ public sealed record SurfaceEnvironmentSettings(
/// <summary>
/// Gets the timestamp (UTC) when the configuration snapshot was created.
/// </summary>
public DateTimeOffset CreatedAtUtc { get; init; } = DateTimeOffset.UtcNow;
public required DateTimeOffset CreatedAtUtc { get; init; }
}

Some files were not shown because too many files have changed in this diff Show More