finish secrets finding work and audit remarks work save
This commit is contained in:
@@ -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");
|
||||
}
|
||||
@@ -12,8 +12,7 @@ public sealed record BunPackagesResponse
|
||||
public string ImageDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public DateTimeOffset GeneratedAt { get; init; }
|
||||
= DateTimeOffset.UtcNow;
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("packages")]
|
||||
public IReadOnlyList<BunPackageArtifact> Packages { get; init; }
|
||||
|
||||
@@ -12,8 +12,7 @@ public sealed record RubyPackagesResponse
|
||||
public string ImageDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public DateTimeOffset GeneratedAt { get; init; }
|
||||
= DateTimeOffset.UtcNow;
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("packages")]
|
||||
public IReadOnlyList<RubyPackageArtifact> Packages { get; init; }
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SecretDetectionConfigContracts.cs
|
||||
// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
|
||||
// Task: SDC-005 - Create Settings CRUD API endpoints
|
||||
// Description: API contracts for secret detection configuration.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
// ============================================================================
|
||||
// Settings DTOs
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Request to get or update secret detection settings.
|
||||
/// </summary>
|
||||
public sealed record SecretDetectionSettingsDto
|
||||
{
|
||||
/// <summary>Whether secret detection is enabled.</summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>Revelation policy configuration.</summary>
|
||||
public required RevelationPolicyDto RevelationPolicy { get; init; }
|
||||
|
||||
/// <summary>Enabled rule categories.</summary>
|
||||
public IReadOnlyList<string> EnabledRuleCategories { get; init; } = [];
|
||||
|
||||
/// <summary>Disabled rule IDs.</summary>
|
||||
public IReadOnlyList<string> DisabledRuleIds { get; init; } = [];
|
||||
|
||||
/// <summary>Alert settings.</summary>
|
||||
public required SecretAlertSettingsDto AlertSettings { get; init; }
|
||||
|
||||
/// <summary>Maximum file size to scan (bytes).</summary>
|
||||
public long MaxFileSizeBytes { get; init; }
|
||||
|
||||
/// <summary>File extensions to exclude.</summary>
|
||||
public IReadOnlyList<string> ExcludedFileExtensions { get; init; } = [];
|
||||
|
||||
/// <summary>Path patterns to exclude (glob).</summary>
|
||||
public IReadOnlyList<string> ExcludedPaths { get; init; } = [];
|
||||
|
||||
/// <summary>Whether to scan binary files.</summary>
|
||||
public bool ScanBinaryFiles { get; init; }
|
||||
|
||||
/// <summary>Whether to require signed rule bundles.</summary>
|
||||
public bool RequireSignedRuleBundles { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing settings with metadata.
|
||||
/// </summary>
|
||||
public sealed record SecretDetectionSettingsResponseDto
|
||||
{
|
||||
/// <summary>Tenant ID.</summary>
|
||||
public Guid TenantId { get; init; }
|
||||
|
||||
/// <summary>Settings data.</summary>
|
||||
public required SecretDetectionSettingsDto Settings { get; init; }
|
||||
|
||||
/// <summary>Version for optimistic concurrency.</summary>
|
||||
public int Version { get; init; }
|
||||
|
||||
/// <summary>When settings were last updated.</summary>
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>Who last updated settings.</summary>
|
||||
public required string UpdatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Revelation policy configuration.
|
||||
/// </summary>
|
||||
public sealed record RevelationPolicyDto
|
||||
{
|
||||
/// <summary>Default masking policy.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public SecretRevelationPolicyType DefaultPolicy { get; init; }
|
||||
|
||||
/// <summary>Export masking policy.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public SecretRevelationPolicyType ExportPolicy { get; init; }
|
||||
|
||||
/// <summary>Roles allowed to see full secrets.</summary>
|
||||
public IReadOnlyList<string> FullRevealRoles { get; init; } = [];
|
||||
|
||||
/// <summary>Characters to reveal at start/end for partial.</summary>
|
||||
public int PartialRevealChars { get; init; }
|
||||
|
||||
/// <summary>Maximum mask characters.</summary>
|
||||
public int MaxMaskChars { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Revelation policy types.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum SecretRevelationPolicyType
|
||||
{
|
||||
/// <summary>Fully masked (e.g., [REDACTED]).</summary>
|
||||
FullMask = 0,
|
||||
|
||||
/// <summary>Partially revealed (e.g., AKIA****WXYZ).</summary>
|
||||
PartialReveal = 1,
|
||||
|
||||
/// <summary>Full value shown (audit logged).</summary>
|
||||
FullReveal = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alert settings configuration.
|
||||
/// </summary>
|
||||
public sealed record SecretAlertSettingsDto
|
||||
{
|
||||
/// <summary>Whether alerting is enabled.</summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>Minimum severity to trigger alerts.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public SecretSeverityType MinimumAlertSeverity { get; init; }
|
||||
|
||||
/// <summary>Alert destinations.</summary>
|
||||
public IReadOnlyList<SecretAlertDestinationDto> Destinations { get; init; } = [];
|
||||
|
||||
/// <summary>Maximum alerts per scan.</summary>
|
||||
public int MaxAlertsPerScan { get; init; }
|
||||
|
||||
/// <summary>Deduplication window in minutes.</summary>
|
||||
public int DeduplicationWindowMinutes { get; init; }
|
||||
|
||||
/// <summary>Include file path in alerts.</summary>
|
||||
public bool IncludeFilePath { get; init; }
|
||||
|
||||
/// <summary>Include masked value in alerts.</summary>
|
||||
public bool IncludeMaskedValue { get; init; }
|
||||
|
||||
/// <summary>Include image reference in alerts.</summary>
|
||||
public bool IncludeImageRef { get; init; }
|
||||
|
||||
/// <summary>Custom alert message prefix.</summary>
|
||||
public string? AlertMessagePrefix { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Secret severity levels.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum SecretSeverityType
|
||||
{
|
||||
Low = 0,
|
||||
Medium = 1,
|
||||
High = 2,
|
||||
Critical = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alert channel types.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum AlertChannelType
|
||||
{
|
||||
Slack = 0,
|
||||
Teams = 1,
|
||||
Email = 2,
|
||||
Webhook = 3,
|
||||
PagerDuty = 4
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alert destination configuration.
|
||||
/// </summary>
|
||||
public sealed record SecretAlertDestinationDto
|
||||
{
|
||||
/// <summary>Destination ID.</summary>
|
||||
public Guid Id { get; init; }
|
||||
|
||||
/// <summary>Destination name.</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>Channel type.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public AlertChannelType ChannelType { get; init; }
|
||||
|
||||
/// <summary>Channel identifier (webhook URL, email, channel ID).</summary>
|
||||
public required string ChannelId { get; init; }
|
||||
|
||||
/// <summary>Severity filter (if empty, uses MinimumAlertSeverity).</summary>
|
||||
public IReadOnlyList<SecretSeverityType>? SeverityFilter { get; init; }
|
||||
|
||||
/// <summary>Rule category filter (if empty, alerts for all).</summary>
|
||||
public IReadOnlyList<string>? RuleCategoryFilter { get; init; }
|
||||
|
||||
/// <summary>Whether this destination is active.</summary>
|
||||
public bool IsActive { get; init; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Exception Pattern DTOs
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Request to create or update an exception pattern.
|
||||
/// </summary>
|
||||
public sealed record SecretExceptionPatternDto
|
||||
{
|
||||
/// <summary>Human-readable name.</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>Description of why this exception exists.</summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>Regex pattern to match secret value.</summary>
|
||||
public required string ValuePattern { get; init; }
|
||||
|
||||
/// <summary>Rule IDs this applies to (empty = all).</summary>
|
||||
public IReadOnlyList<string> ApplicableRuleIds { get; init; } = [];
|
||||
|
||||
/// <summary>File path glob pattern.</summary>
|
||||
public string? FilePathGlob { get; init; }
|
||||
|
||||
/// <summary>Business justification (required).</summary>
|
||||
public required string Justification { get; init; }
|
||||
|
||||
/// <summary>Expiration date (null = permanent).</summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>Whether this exception is active.</summary>
|
||||
public bool IsActive { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing exception pattern with metadata.
|
||||
/// </summary>
|
||||
public sealed record SecretExceptionPatternResponseDto
|
||||
{
|
||||
/// <summary>Exception ID.</summary>
|
||||
public Guid Id { get; init; }
|
||||
|
||||
/// <summary>Tenant ID.</summary>
|
||||
public Guid TenantId { get; init; }
|
||||
|
||||
/// <summary>Exception data.</summary>
|
||||
public required SecretExceptionPatternDto Pattern { get; init; }
|
||||
|
||||
/// <summary>Number of times matched.</summary>
|
||||
public long MatchCount { get; init; }
|
||||
|
||||
/// <summary>Last match time.</summary>
|
||||
public DateTimeOffset? LastMatchedAt { get; init; }
|
||||
|
||||
/// <summary>Creation time.</summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Creator.</summary>
|
||||
public required string CreatedBy { get; init; }
|
||||
|
||||
/// <summary>Last update time.</summary>
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>Last updater.</summary>
|
||||
public string? UpdatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List response for exception patterns.
|
||||
/// </summary>
|
||||
public sealed record SecretExceptionPatternListResponseDto
|
||||
{
|
||||
/// <summary>Exception patterns.</summary>
|
||||
public required IReadOnlyList<SecretExceptionPatternResponseDto> Patterns { get; init; }
|
||||
|
||||
/// <summary>Total count.</summary>
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Update Request DTOs
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Request to update settings with optimistic concurrency.
|
||||
/// </summary>
|
||||
public sealed record UpdateSecretDetectionSettingsRequestDto
|
||||
{
|
||||
/// <summary>Settings to apply.</summary>
|
||||
public required SecretDetectionSettingsDto Settings { get; init; }
|
||||
|
||||
/// <summary>Expected version (for optimistic concurrency).</summary>
|
||||
public int ExpectedVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Available rule categories response.
|
||||
/// </summary>
|
||||
public sealed record RuleCategoriesResponseDto
|
||||
{
|
||||
/// <summary>All available categories.</summary>
|
||||
public required IReadOnlyList<RuleCategoryDto> Categories { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule category information.
|
||||
/// </summary>
|
||||
public sealed record RuleCategoryDto
|
||||
{
|
||||
/// <summary>Category ID.</summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>Display name.</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>Description.</summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>Number of rules in this category.</summary>
|
||||
public int RuleCount { get; init; }
|
||||
}
|
||||
@@ -12,8 +12,7 @@ public sealed record SurfacePointersDto
|
||||
|
||||
[JsonPropertyName("generatedAt")]
|
||||
[JsonPropertyOrder(1)]
|
||||
public DateTimeOffset GeneratedAt { get; init; }
|
||||
= DateTimeOffset.UtcNow;
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("manifestDigest")]
|
||||
[JsonPropertyOrder(2)]
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SecretDetectionSettingsEndpoints.cs
|
||||
// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
|
||||
// Task: SDC-005 - Create Settings CRUD API endpoints
|
||||
// Description: HTTP endpoints for secret detection configuration.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for secret detection configuration.
|
||||
/// Per SPRINT_20260104_006_BE.
|
||||
/// </summary>
|
||||
internal static class SecretDetectionSettingsEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps secret detection settings endpoints.
|
||||
/// </summary>
|
||||
public static void MapSecretDetectionSettingsEndpoints(this RouteGroupBuilder apiGroup, string prefix = "/secrets/config")
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(apiGroup);
|
||||
|
||||
var settings = apiGroup.MapGroup($"{prefix}/settings")
|
||||
.WithTags("Secret Detection Settings");
|
||||
|
||||
var exceptions = apiGroup.MapGroup($"{prefix}/exceptions")
|
||||
.WithTags("Secret Detection Exceptions");
|
||||
|
||||
var rules = apiGroup.MapGroup($"{prefix}/rules")
|
||||
.WithTags("Secret Detection Rules");
|
||||
|
||||
// ====================================================================
|
||||
// Settings Endpoints
|
||||
// ====================================================================
|
||||
|
||||
// GET /v1/secrets/config/settings/{tenantId} - Get settings
|
||||
settings.MapGet("/{tenantId:guid}", HandleGetSettingsAsync)
|
||||
.WithName("scanner.secrets.settings.get")
|
||||
.WithDescription("Get secret detection settings for a tenant.")
|
||||
.Produces<SecretDetectionSettingsResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.SecretSettingsRead);
|
||||
|
||||
// POST /v1/secrets/config/settings/{tenantId} - Create default settings
|
||||
settings.MapPost("/{tenantId:guid}", HandleCreateSettingsAsync)
|
||||
.WithName("scanner.secrets.settings.create")
|
||||
.WithDescription("Create default secret detection settings for a tenant.")
|
||||
.Produces<SecretDetectionSettingsResponseDto>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status409Conflict)
|
||||
.RequireAuthorization(ScannerPolicies.SecretSettingsWrite);
|
||||
|
||||
// PUT /v1/secrets/config/settings/{tenantId} - Update settings
|
||||
settings.MapPut("/{tenantId:guid}", HandleUpdateSettingsAsync)
|
||||
.WithName("scanner.secrets.settings.update")
|
||||
.WithDescription("Update secret detection settings for a tenant.")
|
||||
.Produces<SecretDetectionSettingsResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status409Conflict)
|
||||
.RequireAuthorization(ScannerPolicies.SecretSettingsWrite);
|
||||
|
||||
// ====================================================================
|
||||
// Exception Pattern Endpoints
|
||||
// ====================================================================
|
||||
|
||||
// GET /v1/secrets/config/exceptions/{tenantId} - List exception patterns
|
||||
exceptions.MapGet("/{tenantId:guid}", HandleListExceptionsAsync)
|
||||
.WithName("scanner.secrets.exceptions.list")
|
||||
.WithDescription("List secret exception patterns for a tenant.")
|
||||
.Produces<SecretExceptionPatternListResponseDto>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(ScannerPolicies.SecretExceptionsRead);
|
||||
|
||||
// GET /v1/secrets/config/exceptions/{tenantId}/{exceptionId} - Get exception pattern
|
||||
exceptions.MapGet("/{tenantId:guid}/{exceptionId:guid}", HandleGetExceptionAsync)
|
||||
.WithName("scanner.secrets.exceptions.get")
|
||||
.WithDescription("Get a specific secret exception pattern.")
|
||||
.Produces<SecretExceptionPatternResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.SecretExceptionsRead);
|
||||
|
||||
// POST /v1/secrets/config/exceptions/{tenantId} - Create exception pattern
|
||||
exceptions.MapPost("/{tenantId:guid}", HandleCreateExceptionAsync)
|
||||
.WithName("scanner.secrets.exceptions.create")
|
||||
.WithDescription("Create a new secret exception pattern.")
|
||||
.Produces<SecretExceptionPatternResponseDto>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(ScannerPolicies.SecretExceptionsWrite);
|
||||
|
||||
// PUT /v1/secrets/config/exceptions/{tenantId}/{exceptionId} - Update exception pattern
|
||||
exceptions.MapPut("/{tenantId:guid}/{exceptionId:guid}", HandleUpdateExceptionAsync)
|
||||
.WithName("scanner.secrets.exceptions.update")
|
||||
.WithDescription("Update a secret exception pattern.")
|
||||
.Produces<SecretExceptionPatternResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.SecretExceptionsWrite);
|
||||
|
||||
// DELETE /v1/secrets/config/exceptions/{tenantId}/{exceptionId} - Delete exception pattern
|
||||
exceptions.MapDelete("/{tenantId:guid}/{exceptionId:guid}", HandleDeleteExceptionAsync)
|
||||
.WithName("scanner.secrets.exceptions.delete")
|
||||
.WithDescription("Delete a secret exception pattern.")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.SecretExceptionsWrite);
|
||||
|
||||
// ====================================================================
|
||||
// Rule Catalog Endpoints
|
||||
// ====================================================================
|
||||
|
||||
// GET /v1/secrets/config/rules/categories - Get available rule categories
|
||||
rules.MapGet("/categories", HandleGetRuleCategoriesAsync)
|
||||
.WithName("scanner.secrets.rules.categories")
|
||||
.WithDescription("Get available secret detection rule categories.")
|
||||
.Produces<RuleCategoriesResponseDto>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(ScannerPolicies.SecretSettingsRead);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Settings Handlers
|
||||
// ========================================================================
|
||||
|
||||
private static async Task<IResult> HandleGetSettingsAsync(
|
||||
Guid tenantId,
|
||||
ISecretDetectionSettingsService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var settings = await service.GetSettingsAsync(tenantId, cancellationToken);
|
||||
|
||||
if (settings is null)
|
||||
{
|
||||
return Results.NotFound(new
|
||||
{
|
||||
type = "not-found",
|
||||
title = "Settings not found",
|
||||
detail = $"No secret detection settings found for tenant '{tenantId}'."
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(settings);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleCreateSettingsAsync(
|
||||
Guid tenantId,
|
||||
ISecretDetectionSettingsService service,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Check if settings already exist
|
||||
var existing = await service.GetSettingsAsync(tenantId, cancellationToken);
|
||||
if (existing is not null)
|
||||
{
|
||||
return Results.Conflict(new
|
||||
{
|
||||
type = "conflict",
|
||||
title = "Settings already exist",
|
||||
detail = $"Secret detection settings already exist for tenant '{tenantId}'."
|
||||
});
|
||||
}
|
||||
|
||||
var username = context.User.Identity?.Name ?? "system";
|
||||
var settings = await service.CreateSettingsAsync(tenantId, username, cancellationToken);
|
||||
|
||||
return Results.Created($"/v1/secrets/config/settings/{tenantId}", settings);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleUpdateSettingsAsync(
|
||||
Guid tenantId,
|
||||
UpdateSecretDetectionSettingsRequestDto request,
|
||||
ISecretDetectionSettingsService service,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var username = context.User.Identity?.Name ?? "system";
|
||||
var (success, settings, error) = await service.UpdateSettingsAsync(
|
||||
tenantId,
|
||||
request.Settings,
|
||||
request.ExpectedVersion,
|
||||
username,
|
||||
cancellationToken);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
if (error?.Contains("not found", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
return Results.NotFound(new
|
||||
{
|
||||
type = "not-found",
|
||||
title = "Settings not found",
|
||||
detail = error
|
||||
});
|
||||
}
|
||||
|
||||
if (error?.Contains("conflict", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
return Results.Conflict(new
|
||||
{
|
||||
type = "conflict",
|
||||
title = "Version conflict",
|
||||
detail = error
|
||||
});
|
||||
}
|
||||
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
type = "validation-error",
|
||||
title = "Validation failed",
|
||||
detail = error
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(settings);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Exception Pattern Handlers
|
||||
// ========================================================================
|
||||
|
||||
private static async Task<IResult> HandleListExceptionsAsync(
|
||||
Guid tenantId,
|
||||
ISecretExceptionPatternService service,
|
||||
bool includeInactive = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var patterns = await service.GetPatternsAsync(tenantId, includeInactive, cancellationToken);
|
||||
return Results.Ok(patterns);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleGetExceptionAsync(
|
||||
Guid tenantId,
|
||||
Guid exceptionId,
|
||||
ISecretExceptionPatternService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var pattern = await service.GetPatternAsync(exceptionId, cancellationToken);
|
||||
|
||||
if (pattern is null || pattern.TenantId != tenantId)
|
||||
{
|
||||
return Results.NotFound(new
|
||||
{
|
||||
type = "not-found",
|
||||
title = "Exception pattern not found",
|
||||
detail = $"No exception pattern found with ID '{exceptionId}'."
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(pattern);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleCreateExceptionAsync(
|
||||
Guid tenantId,
|
||||
SecretExceptionPatternDto request,
|
||||
ISecretExceptionPatternService service,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var username = context.User.Identity?.Name ?? "system";
|
||||
var (pattern, errors) = await service.CreatePatternAsync(tenantId, request, username, cancellationToken);
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
type = "validation-error",
|
||||
title = "Validation failed",
|
||||
detail = string.Join("; ", errors),
|
||||
errors
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Created($"/v1/secrets/config/exceptions/{tenantId}/{pattern!.Id}", pattern);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleUpdateExceptionAsync(
|
||||
Guid tenantId,
|
||||
Guid exceptionId,
|
||||
SecretExceptionPatternDto request,
|
||||
ISecretExceptionPatternService service,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Verify pattern belongs to tenant
|
||||
var existing = await service.GetPatternAsync(exceptionId, cancellationToken);
|
||||
if (existing is null || existing.TenantId != tenantId)
|
||||
{
|
||||
return Results.NotFound(new
|
||||
{
|
||||
type = "not-found",
|
||||
title = "Exception pattern not found",
|
||||
detail = $"No exception pattern found with ID '{exceptionId}'."
|
||||
});
|
||||
}
|
||||
|
||||
var username = context.User.Identity?.Name ?? "system";
|
||||
var (success, pattern, errors) = await service.UpdatePatternAsync(
|
||||
exceptionId,
|
||||
request,
|
||||
username,
|
||||
cancellationToken);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
if (errors.Count > 0 && errors[0].Contains("not found", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Results.NotFound(new
|
||||
{
|
||||
type = "not-found",
|
||||
title = "Exception pattern not found",
|
||||
detail = errors[0]
|
||||
});
|
||||
}
|
||||
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
type = "validation-error",
|
||||
title = "Validation failed",
|
||||
detail = string.Join("; ", errors),
|
||||
errors
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(pattern);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleDeleteExceptionAsync(
|
||||
Guid tenantId,
|
||||
Guid exceptionId,
|
||||
ISecretExceptionPatternService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Verify pattern belongs to tenant
|
||||
var existing = await service.GetPatternAsync(exceptionId, cancellationToken);
|
||||
if (existing is null || existing.TenantId != tenantId)
|
||||
{
|
||||
return Results.NotFound(new
|
||||
{
|
||||
type = "not-found",
|
||||
title = "Exception pattern not found",
|
||||
detail = $"No exception pattern found with ID '{exceptionId}'."
|
||||
});
|
||||
}
|
||||
|
||||
var deleted = await service.DeletePatternAsync(exceptionId, cancellationToken);
|
||||
if (!deleted)
|
||||
{
|
||||
return Results.NotFound(new
|
||||
{
|
||||
type = "not-found",
|
||||
title = "Exception pattern not found",
|
||||
detail = $"No exception pattern found with ID '{exceptionId}'."
|
||||
});
|
||||
}
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Rule Catalog Handlers
|
||||
// ========================================================================
|
||||
|
||||
private static async Task<IResult> HandleGetRuleCategoriesAsync(
|
||||
ISecretDetectionSettingsService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var categories = await service.GetRuleCategoriesAsync(cancellationToken);
|
||||
return Results.Ok(categories);
|
||||
}
|
||||
}
|
||||
@@ -25,13 +25,16 @@ public sealed class IdempotencyMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<IdempotencyMiddleware> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public IdempotencyMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<IdempotencyMiddleware> logger)
|
||||
ILogger<IdempotencyMiddleware> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_next = next ?? throw new ArgumentNullException(nameof(next));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(
|
||||
@@ -108,6 +111,7 @@ public sealed class IdempotencyMiddleware
|
||||
var responseBody = await new StreamReader(responseBuffer).ReadToEndAsync(context.RequestAborted)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var idempotencyKey = new IdempotencyKeyRow
|
||||
{
|
||||
TenantId = tenantId,
|
||||
@@ -116,8 +120,8 @@ public sealed class IdempotencyMiddleware
|
||||
ResponseStatus = context.Response.StatusCode,
|
||||
ResponseBody = responseBody,
|
||||
ResponseHeaders = SerializeHeaders(context.Response.Headers),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.Add(opts.Window)
|
||||
CreatedAt = now,
|
||||
ExpiresAt = now.Add(opts.Window)
|
||||
};
|
||||
|
||||
try
|
||||
|
||||
@@ -155,6 +155,11 @@ builder.Services.AddSingleton<IDeltaCompareService, DeltaCompareService>();
|
||||
builder.Services.AddSingleton<IBaselineService, BaselineService>();
|
||||
builder.Services.AddSingleton<IActionablesService, ActionablesService>();
|
||||
builder.Services.AddSingleton<ICounterfactualApiService, CounterfactualApiService>();
|
||||
|
||||
// Secret Detection Settings (Sprint: SPRINT_20260104_006_BE)
|
||||
builder.Services.AddScoped<ISecretDetectionSettingsService, SecretDetectionSettingsService>();
|
||||
builder.Services.AddScoped<ISecretExceptionPatternService, SecretExceptionPatternService>();
|
||||
|
||||
builder.Services.AddDbContext<TriageDbContext>(options =>
|
||||
options.UseNpgsql(bootstrapOptions.Storage.Dsn, npgsqlOptions =>
|
||||
{
|
||||
@@ -580,6 +585,7 @@ apiGroup.MapEpssEndpoints(); // Sprint: SPRINT_3410_0002_0001
|
||||
apiGroup.MapTriageStatusEndpoints();
|
||||
apiGroup.MapTriageInboxEndpoints();
|
||||
apiGroup.MapProofBundleEndpoints();
|
||||
apiGroup.MapSecretDetectionSettingsEndpoints(); // Sprint: SPRINT_20260104_006_BE
|
||||
|
||||
if (resolvedOptions.Features.EnablePolicyPreview)
|
||||
{
|
||||
|
||||
@@ -26,4 +26,10 @@ internal static class ScannerPolicies
|
||||
public const string SourcesRead = "scanner.sources.read";
|
||||
public const string SourcesWrite = "scanner.sources.write";
|
||||
public const string SourcesAdmin = "scanner.sources.admin";
|
||||
|
||||
// Secret detection settings policies
|
||||
public const string SecretSettingsRead = "scanner.secrets.settings.read";
|
||||
public const string SecretSettingsWrite = "scanner.secrets.settings.write";
|
||||
public const string SecretExceptionsRead = "scanner.secrets.exceptions.read";
|
||||
public const string SecretExceptionsWrite = "scanner.secrets.exceptions.write";
|
||||
}
|
||||
|
||||
@@ -16,12 +16,23 @@ namespace StellaOps.Scanner.WebService.Services;
|
||||
/// </summary>
|
||||
public sealed class EvidenceBundleExporter : IEvidenceBundleExporter
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EvidenceBundleExporter"/> class.
|
||||
/// </summary>
|
||||
/// <param name="timeProvider">The time provider for deterministic timestamps. Defaults to system time if null.</param>
|
||||
public EvidenceBundleExporter(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<EvidenceExportResult> ExportAsync(
|
||||
UnifiedEvidenceResponseDto evidence,
|
||||
@@ -43,7 +54,7 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter
|
||||
var manifest = new ArchiveManifestDto
|
||||
{
|
||||
FindingId = evidence.FindingId,
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
GeneratedAt = _timeProvider.GetUtcNow(),
|
||||
CacheKey = evidence.CacheKey ?? string.Empty,
|
||||
Files = fileEntries,
|
||||
ScannerVersion = null // Scanner version not directly available in manifests
|
||||
@@ -136,7 +147,7 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter
|
||||
var findingManifest = new ArchiveManifestDto
|
||||
{
|
||||
FindingId = evidence.FindingId,
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
GeneratedAt = _timeProvider.GetUtcNow(),
|
||||
CacheKey = evidence.CacheKey ?? string.Empty,
|
||||
Files = fileEntries,
|
||||
ScannerVersion = null
|
||||
@@ -155,7 +166,7 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter
|
||||
var runManifest = new RunArchiveManifestDto
|
||||
{
|
||||
ScanId = scanId,
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
GeneratedAt = _timeProvider.GetUtcNow(),
|
||||
Findings = findingManifests,
|
||||
TotalFiles = totalFiles,
|
||||
ScannerVersion = null
|
||||
@@ -221,7 +232,7 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateRunReadme(
|
||||
private string GenerateRunReadme(
|
||||
string scanId,
|
||||
IReadOnlyList<UnifiedEvidenceResponseDto> findings,
|
||||
IReadOnlyList<ArchiveManifestDto> manifests)
|
||||
@@ -233,7 +244,7 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"- **Scan ID:** `{scanId}`");
|
||||
sb.AppendLine($"- **Finding Count:** {findings.Count}");
|
||||
sb.AppendLine($"- **Generated:** {DateTimeOffset.UtcNow:O}");
|
||||
sb.AppendLine($"- **Generated:** {_timeProvider.GetUtcNow():O}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## Findings");
|
||||
sb.AppendLine();
|
||||
@@ -388,12 +399,12 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string GenerateBashReplayScript(UnifiedEvidenceResponseDto evidence)
|
||||
private string GenerateBashReplayScript(UnifiedEvidenceResponseDto evidence)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("#!/usr/bin/env bash");
|
||||
sb.AppendLine("# StellaOps Evidence Bundle Replay Script");
|
||||
sb.AppendLine($"# Generated: {DateTimeOffset.UtcNow:O}");
|
||||
sb.AppendLine($"# Generated: {_timeProvider.GetUtcNow():O}");
|
||||
sb.AppendLine($"# Finding: {evidence.FindingId}");
|
||||
sb.AppendLine($"# CVE: {evidence.CveId}");
|
||||
sb.AppendLine();
|
||||
@@ -425,11 +436,11 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string GeneratePowerShellReplayScript(UnifiedEvidenceResponseDto evidence)
|
||||
private string GeneratePowerShellReplayScript(UnifiedEvidenceResponseDto evidence)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("# StellaOps Evidence Bundle Replay Script");
|
||||
sb.AppendLine($"# Generated: {DateTimeOffset.UtcNow:O}");
|
||||
sb.AppendLine($"# Generated: {_timeProvider.GetUtcNow():O}");
|
||||
sb.AppendLine($"# Finding: {evidence.FindingId}");
|
||||
sb.AppendLine($"# CVE: {evidence.CveId}");
|
||||
sb.AppendLine();
|
||||
@@ -461,7 +472,7 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string GenerateReadme(UnifiedEvidenceResponseDto evidence, List<ArchiveFileEntry> entries)
|
||||
private string GenerateReadme(UnifiedEvidenceResponseDto evidence, List<ArchiveFileEntry> entries)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("# StellaOps Evidence Bundle");
|
||||
@@ -671,7 +682,7 @@ public sealed class EvidenceBundleExporter : IEvidenceBundleExporter
|
||||
Encoding.ASCII.GetBytes(sizeOctal).CopyTo(header, 124);
|
||||
|
||||
// Mtime (136-147) - current time in octal
|
||||
var mtime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
var mtime = _timeProvider.GetUtcNow().ToUnixTimeSeconds();
|
||||
var mtimeOctal = Convert.ToString(mtime, 8).PadLeft(11, '0');
|
||||
Encoding.ASCII.GetBytes(mtimeOctal).CopyTo(header, 136);
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ public sealed class FeedChangeRescoreJob : BackgroundService
|
||||
private readonly IScoreReplayService _replayService;
|
||||
private readonly IOptions<FeedChangeRescoreOptions> _options;
|
||||
private readonly ILogger<FeedChangeRescoreJob> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ActivitySource _activitySource = new("StellaOps.Scanner.FeedChangeRescore");
|
||||
|
||||
private string? _lastConcelierSnapshot;
|
||||
@@ -66,13 +67,15 @@ public sealed class FeedChangeRescoreJob : BackgroundService
|
||||
IScanManifestRepository manifestRepository,
|
||||
IScoreReplayService replayService,
|
||||
IOptions<FeedChangeRescoreOptions> options,
|
||||
ILogger<FeedChangeRescoreJob> logger)
|
||||
ILogger<FeedChangeRescoreJob> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_feedTracker = feedTracker ?? throw new ArgumentNullException(nameof(feedTracker));
|
||||
_manifestRepository = manifestRepository ?? throw new ArgumentNullException(nameof(manifestRepository));
|
||||
_replayService = replayService ?? throw new ArgumentNullException(nameof(replayService));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
@@ -221,7 +224,7 @@ public sealed class FeedChangeRescoreJob : BackgroundService
|
||||
FeedChangeRescoreOptions opts,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow - opts.ScanAgeLimit;
|
||||
var cutoff = _timeProvider.GetUtcNow() - opts.ScanAgeLimit;
|
||||
|
||||
// Find scans using the old snapshot hashes
|
||||
var query = new AffectedScansQuery
|
||||
|
||||
@@ -18,16 +18,19 @@ public sealed class GatingReasonService : IGatingReasonService
|
||||
{
|
||||
private readonly TriageDbContext _dbContext;
|
||||
private readonly ILogger<GatingReasonService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
// Default policy trust threshold (configurable in real implementation)
|
||||
private const double DefaultPolicyTrustThreshold = 0.7;
|
||||
|
||||
public GatingReasonService(
|
||||
TriageDbContext dbContext,
|
||||
ILogger<GatingReasonService> logger)
|
||||
ILogger<GatingReasonService> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -262,11 +265,11 @@ public sealed class GatingReasonService : IGatingReasonService
|
||||
};
|
||||
}
|
||||
|
||||
private static double GetRecencyTrust(DateTimeOffset? timestamp)
|
||||
private double GetRecencyTrust(DateTimeOffset? timestamp)
|
||||
{
|
||||
if (timestamp is null) return 0.3;
|
||||
|
||||
var age = DateTimeOffset.UtcNow - timestamp.Value;
|
||||
var age = _timeProvider.GetUtcNow() - timestamp.Value;
|
||||
return age.TotalDays switch
|
||||
{
|
||||
<= 7 => 1.0, // Within a week
|
||||
|
||||
@@ -89,9 +89,9 @@ public sealed record BundleVerifyResult(
|
||||
DateTimeOffset VerifiedAt,
|
||||
string? ErrorMessage = null)
|
||||
{
|
||||
public static BundleVerifyResult Success(string computedRootHash) =>
|
||||
new(true, computedRootHash, true, true, DateTimeOffset.UtcNow);
|
||||
public static BundleVerifyResult Success(string computedRootHash, TimeProvider? timeProvider = null) =>
|
||||
new(true, computedRootHash, true, true, (timeProvider ?? TimeProvider.System).GetUtcNow());
|
||||
|
||||
public static BundleVerifyResult Failure(string error, string computedRootHash = "") =>
|
||||
new(false, computedRootHash, false, false, DateTimeOffset.UtcNow, error);
|
||||
public static BundleVerifyResult Failure(string error, string computedRootHash = "", TimeProvider? timeProvider = null) =>
|
||||
new(false, computedRootHash, false, false, (timeProvider ?? TimeProvider.System).GetUtcNow(), error);
|
||||
}
|
||||
|
||||
@@ -19,13 +19,16 @@ internal sealed class OfflineKitManifestService
|
||||
|
||||
private readonly OfflineKitStateStore _stateStore;
|
||||
private readonly ILogger<OfflineKitManifestService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public OfflineKitManifestService(
|
||||
OfflineKitStateStore stateStore,
|
||||
ILogger<OfflineKitManifestService> logger)
|
||||
ILogger<OfflineKitManifestService> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -49,7 +52,7 @@ internal sealed class OfflineKitManifestService
|
||||
Version = status.Current.BundleId ?? "unknown",
|
||||
Assets = BuildAssetMap(status.Components),
|
||||
Signature = null, // Would be loaded from bundle signature file
|
||||
CreatedAt = status.Current.CapturedAt ?? DateTimeOffset.UtcNow,
|
||||
CreatedAt = status.Current.CapturedAt ?? _timeProvider.GetUtcNow(),
|
||||
ExpiresAt = status.Current.CapturedAt?.AddDays(30) // Default 30-day expiry
|
||||
};
|
||||
}
|
||||
@@ -155,7 +158,7 @@ internal sealed class OfflineKitManifestService
|
||||
|
||||
private void ValidateExpiration(OfflineKitManifestTransport manifest, OfflineKitValidationResult result)
|
||||
{
|
||||
if (manifest.ExpiresAt.HasValue && manifest.ExpiresAt.Value < DateTimeOffset.UtcNow)
|
||||
if (manifest.ExpiresAt.HasValue && manifest.ExpiresAt.Value < _timeProvider.GetUtcNow())
|
||||
{
|
||||
result.Warnings.Add(new OfflineKitValidationWarning
|
||||
{
|
||||
@@ -166,7 +169,7 @@ internal sealed class OfflineKitManifestService
|
||||
}
|
||||
|
||||
// Check freshness (warn if older than 7 days)
|
||||
var age = DateTimeOffset.UtcNow - manifest.CreatedAt;
|
||||
var age = _timeProvider.GetUtcNow() - manifest.CreatedAt;
|
||||
if (age.TotalDays > 30)
|
||||
{
|
||||
result.Warnings.Add(new OfflineKitValidationWarning
|
||||
@@ -218,7 +221,7 @@ internal sealed class OfflineKitManifestService
|
||||
Valid = true,
|
||||
Algorithm = "ECDSA-P256",
|
||||
KeyId = "authority-key-001",
|
||||
SignedAt = DateTimeOffset.UtcNow
|
||||
SignedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
catch (FormatException)
|
||||
|
||||
@@ -20,6 +20,7 @@ public sealed class ReplayCommandService : IReplayCommandService
|
||||
{
|
||||
private readonly TriageDbContext _dbContext;
|
||||
private readonly ILogger<ReplayCommandService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
// Configuration (would come from IOptions in real implementation)
|
||||
private const string DefaultBinary = "stellaops";
|
||||
@@ -27,10 +28,12 @@ public sealed class ReplayCommandService : IReplayCommandService
|
||||
|
||||
public ReplayCommandService(
|
||||
TriageDbContext dbContext,
|
||||
ILogger<ReplayCommandService> logger)
|
||||
ILogger<ReplayCommandService> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -92,7 +95,7 @@ public sealed class ReplayCommandService : IReplayCommandService
|
||||
OfflineCommand = offlineCommand,
|
||||
Snapshot = snapshotInfo,
|
||||
Bundle = bundleInfo,
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
GeneratedAt = _timeProvider.GetUtcNow(),
|
||||
ExpectedVerdictHash = verdictHash
|
||||
};
|
||||
}
|
||||
@@ -141,7 +144,7 @@ public sealed class ReplayCommandService : IReplayCommandService
|
||||
OfflineCommand = offlineCommand,
|
||||
Snapshot = snapshotInfo,
|
||||
Bundle = bundleInfo,
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
GeneratedAt = _timeProvider.GetUtcNow(),
|
||||
ExpectedFinalDigest = scan.FinalDigest ?? ComputeDigest($"scan:{scan.Id}")
|
||||
};
|
||||
}
|
||||
@@ -358,7 +361,7 @@ public sealed class ReplayCommandService : IReplayCommandService
|
||||
return new SnapshotInfoDto
|
||||
{
|
||||
Id = snapshotId,
|
||||
CreatedAt = scan?.SnapshotCreatedAt ?? DateTimeOffset.UtcNow,
|
||||
CreatedAt = scan?.SnapshotCreatedAt ?? _timeProvider.GetUtcNow(),
|
||||
FeedVersions = scan?.FeedVersions ?? new Dictionary<string, string>
|
||||
{
|
||||
["nvd"] = "latest",
|
||||
@@ -381,7 +384,7 @@ public sealed class ReplayCommandService : IReplayCommandService
|
||||
SizeBytes = null, // Would be computed when bundle is generated
|
||||
ContentHash = contentHash,
|
||||
Format = "tar.gz",
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(7),
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddDays(7),
|
||||
Contents = new[]
|
||||
{
|
||||
"manifest.json",
|
||||
@@ -405,7 +408,7 @@ public sealed class ReplayCommandService : IReplayCommandService
|
||||
SizeBytes = null,
|
||||
ContentHash = contentHash,
|
||||
Format = "tar.gz",
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
||||
ExpiresAt = _timeProvider.GetUtcNow().AddDays(30),
|
||||
Contents = new[]
|
||||
{
|
||||
"manifest.json",
|
||||
|
||||
@@ -84,13 +84,13 @@ internal sealed record RuntimeReconciliationResult
|
||||
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
public static RuntimeReconciliationResult Error(string imageDigest, string code, string message)
|
||||
public static RuntimeReconciliationResult Error(string imageDigest, string code, string message, TimeProvider? timeProvider = null)
|
||||
=> new()
|
||||
{
|
||||
ImageDigest = imageDigest,
|
||||
ErrorCode = code,
|
||||
ErrorMessage = message,
|
||||
ReconciledAt = DateTimeOffset.UtcNow
|
||||
ReconciledAt = (timeProvider ?? TimeProvider.System).GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SecretDetectionSettingsService.cs
|
||||
// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
|
||||
// Task: SDC-005 - Create Settings CRUD API endpoints
|
||||
// Description: Service layer for secret detection configuration.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.Core.Secrets.Configuration;
|
||||
using StellaOps.Scanner.Storage.Entities;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service interface for secret detection settings.
|
||||
/// </summary>
|
||||
public interface ISecretDetectionSettingsService
|
||||
{
|
||||
/// <summary>Gets settings for a tenant.</summary>
|
||||
Task<SecretDetectionSettingsResponseDto?> GetSettingsAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Creates default settings for a tenant.</summary>
|
||||
Task<SecretDetectionSettingsResponseDto> CreateSettingsAsync(
|
||||
Guid tenantId,
|
||||
string createdBy,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Updates settings with optimistic concurrency.</summary>
|
||||
Task<(bool Success, SecretDetectionSettingsResponseDto? Settings, string? Error)> UpdateSettingsAsync(
|
||||
Guid tenantId,
|
||||
SecretDetectionSettingsDto settings,
|
||||
int expectedVersion,
|
||||
string updatedBy,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Gets available rule categories.</summary>
|
||||
Task<RuleCategoriesResponseDto> GetRuleCategoriesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service interface for secret exception patterns.
|
||||
/// </summary>
|
||||
public interface ISecretExceptionPatternService
|
||||
{
|
||||
/// <summary>Gets all exception patterns for a tenant.</summary>
|
||||
Task<SecretExceptionPatternListResponseDto> GetPatternsAsync(
|
||||
Guid tenantId,
|
||||
bool includeInactive = false,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Gets a specific pattern by ID.</summary>
|
||||
Task<SecretExceptionPatternResponseDto?> GetPatternAsync(
|
||||
Guid patternId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Creates a new exception pattern.</summary>
|
||||
Task<(SecretExceptionPatternResponseDto? Pattern, IReadOnlyList<string> Errors)> CreatePatternAsync(
|
||||
Guid tenantId,
|
||||
SecretExceptionPatternDto pattern,
|
||||
string createdBy,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Updates an exception pattern.</summary>
|
||||
Task<(bool Success, SecretExceptionPatternResponseDto? Pattern, IReadOnlyList<string> Errors)> UpdatePatternAsync(
|
||||
Guid patternId,
|
||||
SecretExceptionPatternDto pattern,
|
||||
string updatedBy,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Deletes an exception pattern.</summary>
|
||||
Task<bool> DeletePatternAsync(
|
||||
Guid patternId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of secret detection settings service.
|
||||
/// </summary>
|
||||
public sealed class SecretDetectionSettingsService : ISecretDetectionSettingsService
|
||||
{
|
||||
private readonly ISecretDetectionSettingsRepository _repository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public SecretDetectionSettingsService(
|
||||
ISecretDetectionSettingsRepository repository,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public async Task<SecretDetectionSettingsResponseDto?> GetSettingsAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var row = await _repository.GetByTenantAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
return row is null ? null : MapToDto(row);
|
||||
}
|
||||
|
||||
public async Task<SecretDetectionSettingsResponseDto> CreateSettingsAsync(
|
||||
Guid tenantId,
|
||||
string createdBy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var defaultSettings = SecretDetectionSettings.CreateDefault(tenantId, createdBy);
|
||||
var row = MapToRow(defaultSettings, tenantId, createdBy);
|
||||
|
||||
var created = await _repository.CreateAsync(row, cancellationToken).ConfigureAwait(false);
|
||||
return MapToDto(created);
|
||||
}
|
||||
|
||||
public async Task<(bool Success, SecretDetectionSettingsResponseDto? Settings, string? Error)> UpdateSettingsAsync(
|
||||
Guid tenantId,
|
||||
SecretDetectionSettingsDto settings,
|
||||
int expectedVersion,
|
||||
string updatedBy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await _repository.GetByTenantAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
return (false, null, "Settings not found for tenant");
|
||||
}
|
||||
|
||||
// Validate settings
|
||||
var validationErrors = ValidateSettings(settings);
|
||||
if (validationErrors.Count > 0)
|
||||
{
|
||||
return (false, null, string.Join("; ", validationErrors));
|
||||
}
|
||||
|
||||
// Apply updates
|
||||
existing.Enabled = settings.Enabled;
|
||||
existing.RevelationPolicy = JsonSerializer.Serialize(settings.RevelationPolicy, JsonOptions);
|
||||
existing.EnabledRuleCategories = settings.EnabledRuleCategories.ToArray();
|
||||
existing.DisabledRuleIds = settings.DisabledRuleIds.ToArray();
|
||||
existing.AlertSettings = JsonSerializer.Serialize(settings.AlertSettings, JsonOptions);
|
||||
existing.MaxFileSizeBytes = settings.MaxFileSizeBytes;
|
||||
existing.ExcludedFileExtensions = settings.ExcludedFileExtensions.ToArray();
|
||||
existing.ExcludedPaths = settings.ExcludedPaths.ToArray();
|
||||
existing.ScanBinaryFiles = settings.ScanBinaryFiles;
|
||||
existing.RequireSignedRuleBundles = settings.RequireSignedRuleBundles;
|
||||
existing.UpdatedBy = updatedBy;
|
||||
|
||||
var success = await _repository.UpdateAsync(existing, expectedVersion, cancellationToken).ConfigureAwait(false);
|
||||
if (!success)
|
||||
{
|
||||
return (false, null, "Version conflict - settings were modified by another request");
|
||||
}
|
||||
|
||||
// Fetch updated version
|
||||
var updated = await _repository.GetByTenantAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
return (true, updated is null ? null : MapToDto(updated), null);
|
||||
}
|
||||
|
||||
public Task<RuleCategoriesResponseDto> GetRuleCategoriesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var categories = new List<RuleCategoryDto>
|
||||
{
|
||||
new() { Id = SecretRuleCategories.Aws, Name = "AWS", Description = "Amazon Web Services credentials", RuleCount = 15 },
|
||||
new() { Id = SecretRuleCategories.Gcp, Name = "GCP", Description = "Google Cloud Platform credentials", RuleCount = 12 },
|
||||
new() { Id = SecretRuleCategories.Azure, Name = "Azure", Description = "Microsoft Azure credentials", RuleCount = 10 },
|
||||
new() { Id = SecretRuleCategories.Generic, Name = "Generic", Description = "Generic secrets and passwords", RuleCount = 25 },
|
||||
new() { Id = SecretRuleCategories.PrivateKeys, Name = "Private Keys", Description = "SSH, PGP, and other private keys", RuleCount = 8 },
|
||||
new() { Id = SecretRuleCategories.Database, Name = "Database", Description = "Database connection strings and credentials", RuleCount = 18 },
|
||||
new() { Id = SecretRuleCategories.Messaging, Name = "Messaging", Description = "Messaging platform credentials (Slack, Discord)", RuleCount = 6 },
|
||||
new() { Id = SecretRuleCategories.Payment, Name = "Payment", Description = "Payment processor credentials (Stripe, PayPal)", RuleCount = 5 },
|
||||
new() { Id = SecretRuleCategories.SocialMedia, Name = "Social Media", Description = "Social media API keys", RuleCount = 8 },
|
||||
new() { Id = SecretRuleCategories.Internal, Name = "Internal", Description = "Custom internal secrets", RuleCount = 0 }
|
||||
};
|
||||
|
||||
return Task.FromResult(new RuleCategoriesResponseDto { Categories = categories });
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ValidateSettings(SecretDetectionSettingsDto settings)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
if (settings.MaxFileSizeBytes < 1024)
|
||||
{
|
||||
errors.Add("MaxFileSizeBytes must be at least 1024 bytes");
|
||||
}
|
||||
|
||||
if (settings.MaxFileSizeBytes > 100 * 1024 * 1024)
|
||||
{
|
||||
errors.Add("MaxFileSizeBytes must be 100 MB or less");
|
||||
}
|
||||
|
||||
if (settings.RevelationPolicy.PartialRevealChars < 1 || settings.RevelationPolicy.PartialRevealChars > 10)
|
||||
{
|
||||
errors.Add("PartialRevealChars must be between 1 and 10");
|
||||
}
|
||||
|
||||
if (settings.AlertSettings.Enabled && settings.AlertSettings.Destinations.Count == 0)
|
||||
{
|
||||
errors.Add("At least one destination is required when alerting is enabled");
|
||||
}
|
||||
|
||||
if (settings.AlertSettings.MaxAlertsPerScan < 1 || settings.AlertSettings.MaxAlertsPerScan > 100)
|
||||
{
|
||||
errors.Add("MaxAlertsPerScan must be between 1 and 100");
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
private static SecretDetectionSettingsResponseDto MapToDto(SecretDetectionSettingsRow row)
|
||||
{
|
||||
var revelationPolicy = JsonSerializer.Deserialize<RevelationPolicyDto>(row.RevelationPolicy, JsonOptions)
|
||||
?? new RevelationPolicyDto
|
||||
{
|
||||
DefaultPolicy = SecretRevelationPolicyType.PartialReveal,
|
||||
ExportPolicy = SecretRevelationPolicyType.FullMask,
|
||||
PartialRevealChars = 4,
|
||||
MaxMaskChars = 8,
|
||||
FullRevealRoles = []
|
||||
};
|
||||
|
||||
var alertSettings = JsonSerializer.Deserialize<SecretAlertSettingsDto>(row.AlertSettings, JsonOptions)
|
||||
?? new SecretAlertSettingsDto
|
||||
{
|
||||
Enabled = false,
|
||||
MinimumAlertSeverity = SecretSeverityType.High,
|
||||
Destinations = [],
|
||||
MaxAlertsPerScan = 10,
|
||||
DeduplicationWindowMinutes = 1440,
|
||||
IncludeFilePath = true,
|
||||
IncludeMaskedValue = true,
|
||||
IncludeImageRef = true
|
||||
};
|
||||
|
||||
return new SecretDetectionSettingsResponseDto
|
||||
{
|
||||
TenantId = row.TenantId,
|
||||
Settings = new SecretDetectionSettingsDto
|
||||
{
|
||||
Enabled = row.Enabled,
|
||||
RevelationPolicy = revelationPolicy,
|
||||
EnabledRuleCategories = row.EnabledRuleCategories,
|
||||
DisabledRuleIds = row.DisabledRuleIds,
|
||||
AlertSettings = alertSettings,
|
||||
MaxFileSizeBytes = row.MaxFileSizeBytes,
|
||||
ExcludedFileExtensions = row.ExcludedFileExtensions,
|
||||
ExcludedPaths = row.ExcludedPaths,
|
||||
ScanBinaryFiles = row.ScanBinaryFiles,
|
||||
RequireSignedRuleBundles = row.RequireSignedRuleBundles
|
||||
},
|
||||
Version = row.Version,
|
||||
UpdatedAt = row.UpdatedAt,
|
||||
UpdatedBy = row.UpdatedBy
|
||||
};
|
||||
}
|
||||
|
||||
private static SecretDetectionSettingsRow MapToRow(SecretDetectionSettings settings, Guid tenantId, string updatedBy)
|
||||
{
|
||||
var revelationPolicyDto = new RevelationPolicyDto
|
||||
{
|
||||
DefaultPolicy = (SecretRevelationPolicyType)settings.RevelationPolicy.DefaultPolicy,
|
||||
ExportPolicy = (SecretRevelationPolicyType)settings.RevelationPolicy.ExportPolicy,
|
||||
PartialRevealChars = settings.RevelationPolicy.PartialRevealChars,
|
||||
MaxMaskChars = settings.RevelationPolicy.MaxMaskChars,
|
||||
FullRevealRoles = settings.RevelationPolicy.FullRevealRoles
|
||||
};
|
||||
|
||||
var alertSettingsDto = new SecretAlertSettingsDto
|
||||
{
|
||||
Enabled = settings.AlertSettings.Enabled,
|
||||
MinimumAlertSeverity = (SecretSeverityType)settings.AlertSettings.MinimumAlertSeverity,
|
||||
Destinations = settings.AlertSettings.Destinations.Select(d => new SecretAlertDestinationDto
|
||||
{
|
||||
Id = d.Id,
|
||||
Name = d.Name,
|
||||
ChannelType = (Contracts.AlertChannelType)d.ChannelType,
|
||||
ChannelId = d.ChannelId,
|
||||
SeverityFilter = d.SeverityFilter?.Select(s => (SecretSeverityType)s).ToList(),
|
||||
RuleCategoryFilter = d.RuleCategoryFilter?.ToList(),
|
||||
IsActive = d.IsActive
|
||||
}).ToList(),
|
||||
MaxAlertsPerScan = settings.AlertSettings.MaxAlertsPerScan,
|
||||
DeduplicationWindowMinutes = (int)settings.AlertSettings.DeduplicationWindow.TotalMinutes,
|
||||
IncludeFilePath = settings.AlertSettings.IncludeFilePath,
|
||||
IncludeMaskedValue = settings.AlertSettings.IncludeMaskedValue,
|
||||
IncludeImageRef = settings.AlertSettings.IncludeImageRef,
|
||||
AlertMessagePrefix = settings.AlertSettings.AlertMessagePrefix
|
||||
};
|
||||
|
||||
return new SecretDetectionSettingsRow
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Enabled = settings.Enabled,
|
||||
RevelationPolicy = JsonSerializer.Serialize(revelationPolicyDto, JsonOptions),
|
||||
EnabledRuleCategories = settings.EnabledRuleCategories.ToArray(),
|
||||
DisabledRuleIds = settings.DisabledRuleIds.ToArray(),
|
||||
AlertSettings = JsonSerializer.Serialize(alertSettingsDto, JsonOptions),
|
||||
MaxFileSizeBytes = settings.MaxFileSizeBytes,
|
||||
ExcludedFileExtensions = settings.ExcludedFileExtensions.ToArray(),
|
||||
ExcludedPaths = settings.ExcludedPaths.ToArray(),
|
||||
ScanBinaryFiles = settings.ScanBinaryFiles,
|
||||
RequireSignedRuleBundles = settings.RequireSignedRuleBundles,
|
||||
UpdatedBy = updatedBy
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of secret exception pattern service.
|
||||
/// </summary>
|
||||
public sealed class SecretExceptionPatternService : ISecretExceptionPatternService
|
||||
{
|
||||
private readonly ISecretExceptionPatternRepository _repository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public SecretExceptionPatternService(
|
||||
ISecretExceptionPatternRepository repository,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public async Task<SecretExceptionPatternListResponseDto> GetPatternsAsync(
|
||||
Guid tenantId,
|
||||
bool includeInactive = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var patterns = includeInactive
|
||||
? await _repository.GetAllByTenantAsync(tenantId, cancellationToken).ConfigureAwait(false)
|
||||
: await _repository.GetActiveByTenantAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new SecretExceptionPatternListResponseDto
|
||||
{
|
||||
Patterns = patterns.Select(MapToDto).ToList(),
|
||||
TotalCount = patterns.Count
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<SecretExceptionPatternResponseDto?> GetPatternAsync(
|
||||
Guid patternId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pattern = await _repository.GetByIdAsync(patternId, cancellationToken).ConfigureAwait(false);
|
||||
return pattern is null ? null : MapToDto(pattern);
|
||||
}
|
||||
|
||||
public async Task<(SecretExceptionPatternResponseDto? Pattern, IReadOnlyList<string> Errors)> CreatePatternAsync(
|
||||
Guid tenantId,
|
||||
SecretExceptionPatternDto pattern,
|
||||
string createdBy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var errors = ValidatePattern(pattern);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return (null, errors);
|
||||
}
|
||||
|
||||
var row = new SecretExceptionPatternRow
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Name = pattern.Name,
|
||||
Description = pattern.Description,
|
||||
ValuePattern = pattern.ValuePattern,
|
||||
ApplicableRuleIds = pattern.ApplicableRuleIds.ToArray(),
|
||||
FilePathGlob = pattern.FilePathGlob,
|
||||
Justification = pattern.Justification,
|
||||
ExpiresAt = pattern.ExpiresAt,
|
||||
IsActive = pattern.IsActive,
|
||||
CreatedBy = createdBy
|
||||
};
|
||||
|
||||
var created = await _repository.CreateAsync(row, cancellationToken).ConfigureAwait(false);
|
||||
return (MapToDto(created), []);
|
||||
}
|
||||
|
||||
public async Task<(bool Success, SecretExceptionPatternResponseDto? Pattern, IReadOnlyList<string> Errors)> UpdatePatternAsync(
|
||||
Guid patternId,
|
||||
SecretExceptionPatternDto pattern,
|
||||
string updatedBy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await _repository.GetByIdAsync(patternId, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
return (false, null, ["Pattern not found"]);
|
||||
}
|
||||
|
||||
var errors = ValidatePattern(pattern);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return (false, null, errors);
|
||||
}
|
||||
|
||||
existing.Name = pattern.Name;
|
||||
existing.Description = pattern.Description;
|
||||
existing.ValuePattern = pattern.ValuePattern;
|
||||
existing.ApplicableRuleIds = pattern.ApplicableRuleIds.ToArray();
|
||||
existing.FilePathGlob = pattern.FilePathGlob;
|
||||
existing.Justification = pattern.Justification;
|
||||
existing.ExpiresAt = pattern.ExpiresAt;
|
||||
existing.IsActive = pattern.IsActive;
|
||||
existing.UpdatedBy = updatedBy;
|
||||
existing.UpdatedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
var success = await _repository.UpdateAsync(existing, cancellationToken).ConfigureAwait(false);
|
||||
if (!success)
|
||||
{
|
||||
return (false, null, ["Failed to update pattern"]);
|
||||
}
|
||||
|
||||
var updated = await _repository.GetByIdAsync(patternId, cancellationToken).ConfigureAwait(false);
|
||||
return (true, updated is null ? null : MapToDto(updated), []);
|
||||
}
|
||||
|
||||
public async Task<bool> DeletePatternAsync(
|
||||
Guid patternId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _repository.DeleteAsync(patternId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ValidatePattern(SecretExceptionPatternDto pattern)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pattern.Name))
|
||||
{
|
||||
errors.Add("Name is required");
|
||||
}
|
||||
else if (pattern.Name.Length > 100)
|
||||
{
|
||||
errors.Add("Name must be 100 characters or less");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pattern.ValuePattern))
|
||||
{
|
||||
errors.Add("ValuePattern is required");
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = new System.Text.RegularExpressions.Regex(pattern.ValuePattern);
|
||||
}
|
||||
catch (System.Text.RegularExpressions.RegexParseException ex)
|
||||
{
|
||||
errors.Add(string.Format(CultureInfo.InvariantCulture, "ValuePattern is not a valid regex: {0}", ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pattern.Justification))
|
||||
{
|
||||
errors.Add("Justification is required");
|
||||
}
|
||||
else if (pattern.Justification.Length < 20)
|
||||
{
|
||||
errors.Add("Justification must be at least 20 characters");
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
private static SecretExceptionPatternResponseDto MapToDto(SecretExceptionPatternRow row)
|
||||
{
|
||||
return new SecretExceptionPatternResponseDto
|
||||
{
|
||||
Id = row.ExceptionId,
|
||||
TenantId = row.TenantId,
|
||||
Pattern = new SecretExceptionPatternDto
|
||||
{
|
||||
Name = row.Name,
|
||||
Description = row.Description,
|
||||
ValuePattern = row.ValuePattern,
|
||||
ApplicableRuleIds = row.ApplicableRuleIds,
|
||||
FilePathGlob = row.FilePathGlob,
|
||||
Justification = row.Justification,
|
||||
ExpiresAt = row.ExpiresAt,
|
||||
IsActive = row.IsActive
|
||||
},
|
||||
MatchCount = row.MatchCount,
|
||||
LastMatchedAt = row.LastMatchedAt,
|
||||
CreatedAt = row.CreatedAt,
|
||||
CreatedBy = row.CreatedBy,
|
||||
UpdatedAt = row.UpdatedAt,
|
||||
UpdatedBy = row.UpdatedBy
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,14 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CycloneDX.Core" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
<PackageReference Include="Serilog.AspNetCore" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" />
|
||||
<PackageReference Include="YamlDotNet" />
|
||||
<PackageReference Include="StackExchange.Redis" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}" };
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()}";
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]}";
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; } = [];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user