save development progress
This commit is contained in:
@@ -10,7 +10,7 @@ using StellaOps.Findings.Ledger.Options;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Infrastructure.Policy;
|
||||
|
||||
internal sealed class PolicyEngineEvaluationService : IPolicyEvaluationService
|
||||
public sealed class PolicyEngineEvaluationService : IPolicyEvaluationService
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
|
||||
@@ -6,9 +6,9 @@ using StellaOps.Findings.Ledger.Options;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Infrastructure.Policy;
|
||||
|
||||
internal sealed record PolicyEvaluationCacheKey(string TenantId, string PolicyVersion, Guid EventId, string? ProjectionHash);
|
||||
public sealed record PolicyEvaluationCacheKey(string TenantId, string PolicyVersion, Guid EventId, string? ProjectionHash);
|
||||
|
||||
internal sealed class PolicyEvaluationCache : IDisposable
|
||||
public sealed class PolicyEvaluationCache : IDisposable
|
||||
{
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<PolicyEvaluationCache> _logger;
|
||||
|
||||
@@ -5,7 +5,7 @@ using System.Reflection;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Observability;
|
||||
|
||||
internal static class LedgerMetrics
|
||||
public static class LedgerMetrics
|
||||
{
|
||||
private static readonly Meter Meter = new("StellaOps.Findings.Ledger");
|
||||
|
||||
@@ -492,4 +492,178 @@ internal static class LedgerMetrics
|
||||
private static string NormalizeRole(string role) => string.IsNullOrWhiteSpace(role) ? "unspecified" : role.ToLowerInvariant();
|
||||
|
||||
private static string NormalizeTenant(string? tenantId) => string.IsNullOrWhiteSpace(tenantId) ? string.Empty : tenantId;
|
||||
|
||||
// SPRINT_8200_0012_0004: Evidence-Weighted Score (EWS) Metrics
|
||||
|
||||
private static readonly Counter<long> EwsCalculationsTotal = Meter.CreateCounter<long>(
|
||||
"ews_calculations_total",
|
||||
description: "Total number of EWS calculations by result and bucket.");
|
||||
|
||||
private static readonly Histogram<double> EwsCalculationDurationSeconds = Meter.CreateHistogram<double>(
|
||||
"ews_calculation_duration_seconds",
|
||||
unit: "s",
|
||||
description: "Duration of EWS score calculations.");
|
||||
|
||||
private static readonly Counter<long> EwsBatchCalculationsTotal = Meter.CreateCounter<long>(
|
||||
"ews_batch_calculations_total",
|
||||
description: "Total number of EWS batch calculations.");
|
||||
|
||||
private static readonly Histogram<double> EwsBatchSizeHistogram = Meter.CreateHistogram<double>(
|
||||
"ews_batch_size",
|
||||
description: "Distribution of EWS batch sizes.");
|
||||
|
||||
private static readonly Counter<long> EwsCacheHitsTotal = Meter.CreateCounter<long>(
|
||||
"ews_cache_hits_total",
|
||||
description: "Total EWS cache hits.");
|
||||
|
||||
private static readonly Counter<long> EwsCacheMissesTotal = Meter.CreateCounter<long>(
|
||||
"ews_cache_misses_total",
|
||||
description: "Total EWS cache misses.");
|
||||
|
||||
private static readonly Counter<long> EwsWebhooksDeliveredTotal = Meter.CreateCounter<long>(
|
||||
"ews_webhooks_delivered_total",
|
||||
description: "Total webhooks delivered by status.");
|
||||
|
||||
private static readonly Histogram<double> EwsWebhookDeliveryDurationSeconds = Meter.CreateHistogram<double>(
|
||||
"ews_webhook_delivery_duration_seconds",
|
||||
unit: "s",
|
||||
description: "Duration of webhook delivery attempts.");
|
||||
|
||||
private static readonly ConcurrentDictionary<string, BucketDistributionSnapshot> EwsBucketDistributionByTenant = new(StringComparer.Ordinal);
|
||||
|
||||
private static readonly ObservableGauge<long> EwsBucketActNowGauge =
|
||||
Meter.CreateObservableGauge("ews_bucket_distribution_act_now", ObserveEwsBucketActNow,
|
||||
description: "Current count of findings in ActNow bucket by tenant.");
|
||||
|
||||
private static readonly ObservableGauge<long> EwsBucketScheduleNextGauge =
|
||||
Meter.CreateObservableGauge("ews_bucket_distribution_schedule_next", ObserveEwsBucketScheduleNext,
|
||||
description: "Current count of findings in ScheduleNext bucket by tenant.");
|
||||
|
||||
private static readonly ObservableGauge<long> EwsBucketInvestigateGauge =
|
||||
Meter.CreateObservableGauge("ews_bucket_distribution_investigate", ObserveEwsBucketInvestigate,
|
||||
description: "Current count of findings in Investigate bucket by tenant.");
|
||||
|
||||
private static readonly ObservableGauge<long> EwsBucketWatchlistGauge =
|
||||
Meter.CreateObservableGauge("ews_bucket_distribution_watchlist", ObserveEwsBucketWatchlist,
|
||||
description: "Current count of findings in Watchlist bucket by tenant.");
|
||||
|
||||
/// <summary>Records an EWS calculation.</summary>
|
||||
public static void RecordEwsCalculation(
|
||||
TimeSpan duration,
|
||||
string? tenantId,
|
||||
string? policyDigest,
|
||||
string bucket,
|
||||
string result,
|
||||
bool fromCache)
|
||||
{
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("tenant", NormalizeTenant(tenantId)),
|
||||
new("policy_digest", policyDigest ?? string.Empty),
|
||||
new("bucket", bucket),
|
||||
new("result", result),
|
||||
new("from_cache", fromCache)
|
||||
};
|
||||
EwsCalculationsTotal.Add(1, tags);
|
||||
EwsCalculationDurationSeconds.Record(duration.TotalSeconds, tags);
|
||||
}
|
||||
|
||||
/// <summary>Records an EWS batch calculation.</summary>
|
||||
public static void RecordEwsBatchCalculation(
|
||||
TimeSpan duration,
|
||||
string? tenantId,
|
||||
int batchSize,
|
||||
int succeeded,
|
||||
int failed)
|
||||
{
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("tenant", NormalizeTenant(tenantId)),
|
||||
new("succeeded", succeeded),
|
||||
new("failed", failed)
|
||||
};
|
||||
EwsBatchCalculationsTotal.Add(1, tags);
|
||||
EwsBatchSizeHistogram.Record(batchSize, new KeyValuePair<string, object?>("tenant", NormalizeTenant(tenantId)));
|
||||
EwsCalculationDurationSeconds.Record(duration.TotalSeconds, tags);
|
||||
}
|
||||
|
||||
/// <summary>Records an EWS cache hit.</summary>
|
||||
public static void RecordEwsCacheHit(string? tenantId, string findingId)
|
||||
{
|
||||
EwsCacheHitsTotal.Add(1, new KeyValuePair<string, object?>("tenant", NormalizeTenant(tenantId)));
|
||||
}
|
||||
|
||||
/// <summary>Records an EWS cache miss.</summary>
|
||||
public static void RecordEwsCacheMiss(string? tenantId, string findingId)
|
||||
{
|
||||
EwsCacheMissesTotal.Add(1, new KeyValuePair<string, object?>("tenant", NormalizeTenant(tenantId)));
|
||||
}
|
||||
|
||||
/// <summary>Records a webhook delivery attempt.</summary>
|
||||
public static void RecordWebhookDelivery(TimeSpan duration, Guid webhookId, string status, int attempt)
|
||||
{
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("webhook_id", webhookId.ToString()),
|
||||
new("status", status),
|
||||
new("attempt", attempt)
|
||||
};
|
||||
EwsWebhooksDeliveredTotal.Add(1, tags);
|
||||
EwsWebhookDeliveryDurationSeconds.Record(duration.TotalSeconds, tags);
|
||||
}
|
||||
|
||||
/// <summary>Updates the EWS bucket distribution for a tenant.</summary>
|
||||
public static void UpdateEwsBucketDistribution(
|
||||
string tenantId,
|
||||
int actNow,
|
||||
int scheduleNext,
|
||||
int investigate,
|
||||
int watchlist)
|
||||
{
|
||||
var key = NormalizeTenant(tenantId);
|
||||
EwsBucketDistributionByTenant[key] = new BucketDistributionSnapshot(key, actNow, scheduleNext, investigate, watchlist);
|
||||
}
|
||||
|
||||
private sealed record BucketDistributionSnapshot(
|
||||
string TenantId,
|
||||
int ActNow,
|
||||
int ScheduleNext,
|
||||
int Investigate,
|
||||
int Watchlist);
|
||||
|
||||
private static IEnumerable<Measurement<long>> ObserveEwsBucketActNow()
|
||||
{
|
||||
foreach (var kvp in EwsBucketDistributionByTenant)
|
||||
{
|
||||
yield return new Measurement<long>(kvp.Value.ActNow,
|
||||
new KeyValuePair<string, object?>("tenant", kvp.Value.TenantId));
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<Measurement<long>> ObserveEwsBucketScheduleNext()
|
||||
{
|
||||
foreach (var kvp in EwsBucketDistributionByTenant)
|
||||
{
|
||||
yield return new Measurement<long>(kvp.Value.ScheduleNext,
|
||||
new KeyValuePair<string, object?>("tenant", kvp.Value.TenantId));
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<Measurement<long>> ObserveEwsBucketInvestigate()
|
||||
{
|
||||
foreach (var kvp in EwsBucketDistributionByTenant)
|
||||
{
|
||||
yield return new Measurement<long>(kvp.Value.Investigate,
|
||||
new KeyValuePair<string, object?>("tenant", kvp.Value.TenantId));
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<Measurement<long>> ObserveEwsBucketWatchlist()
|
||||
{
|
||||
foreach (var kvp in EwsBucketDistributionByTenant)
|
||||
{
|
||||
yield return new Measurement<long>(kvp.Value.Watchlist,
|
||||
new KeyValuePair<string, object?>("tenant", kvp.Value.TenantId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,4 +76,123 @@ internal static class LedgerTelemetry
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
// SPRINT_8200_0012_0004: Evidence-Weighted Score (EWS) Telemetry
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for EWS score calculation.
|
||||
/// </summary>
|
||||
public static Activity? StartEwsCalculation(string findingId, string? policyVersion)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("EWS.Calculate", ActivityKind.Internal);
|
||||
if (activity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
activity.SetTag("finding_id", findingId);
|
||||
activity.SetTag("policy_version", policyVersion ?? "latest");
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the EWS calculation outcome.
|
||||
/// </summary>
|
||||
public static void MarkEwsCalculationOutcome(
|
||||
Activity? activity,
|
||||
int score,
|
||||
string bucket,
|
||||
string policyDigest,
|
||||
TimeSpan duration,
|
||||
bool fromCache)
|
||||
{
|
||||
if (activity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
activity.SetTag("score", score);
|
||||
activity.SetTag("bucket", bucket);
|
||||
activity.SetTag("policy_digest", policyDigest);
|
||||
activity.SetTag("duration_ms", duration.TotalMilliseconds);
|
||||
activity.SetTag("from_cache", fromCache);
|
||||
activity.SetStatus(ActivityStatusCode.Ok);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for EWS batch calculation.
|
||||
/// </summary>
|
||||
public static Activity? StartEwsBatchCalculation(int batchSize, string? policyVersion)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("EWS.CalculateBatch", ActivityKind.Internal);
|
||||
if (activity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
activity.SetTag("batch_size", batchSize);
|
||||
activity.SetTag("policy_version", policyVersion ?? "latest");
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the EWS batch calculation outcome.
|
||||
/// </summary>
|
||||
public static void MarkEwsBatchOutcome(
|
||||
Activity? activity,
|
||||
int succeeded,
|
||||
int failed,
|
||||
double averageScore,
|
||||
TimeSpan duration)
|
||||
{
|
||||
if (activity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
activity.SetTag("succeeded", succeeded);
|
||||
activity.SetTag("failed", failed);
|
||||
activity.SetTag("average_score", averageScore);
|
||||
activity.SetTag("duration_ms", duration.TotalMilliseconds);
|
||||
activity.SetStatus(failed > 0 ? ActivityStatusCode.Error : ActivityStatusCode.Ok);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for webhook delivery.
|
||||
/// </summary>
|
||||
public static Activity? StartWebhookDelivery(Guid webhookId, string url)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("EWS.WebhookDelivery", ActivityKind.Client);
|
||||
if (activity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
activity.SetTag("webhook_id", webhookId.ToString());
|
||||
activity.SetTag("webhook_url", url);
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the webhook delivery outcome.
|
||||
/// </summary>
|
||||
public static void MarkWebhookDeliveryOutcome(
|
||||
Activity? activity,
|
||||
int statusCode,
|
||||
int attempt,
|
||||
TimeSpan duration)
|
||||
{
|
||||
if (activity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
activity.SetTag("status_code", statusCode);
|
||||
activity.SetTag("attempt", attempt);
|
||||
activity.SetTag("duration_ms", duration.TotalMilliseconds);
|
||||
activity.SetStatus(statusCode >= 200 && statusCode < 300 ? ActivityStatusCode.Ok : ActivityStatusCode.Error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,26 @@ public sealed class AlertService : IAlertService
|
||||
return MapToAlert(finding);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific alert by ID (tenant extracted from alert ID).
|
||||
/// </summary>
|
||||
public Task<Alert?> GetAlertAsync(
|
||||
string alertId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(alertId);
|
||||
|
||||
// Extract tenant from alert ID format: tenant|artifact|vuln
|
||||
var tenantId = GetTenantIdFromAlert(alertId);
|
||||
return GetAsync(tenantId, alertId, cancellationToken);
|
||||
}
|
||||
|
||||
private static string GetTenantIdFromAlert(string alertId)
|
||||
{
|
||||
var parts = alertId.Split('|');
|
||||
return parts.Length > 0 ? parts[0] : "default";
|
||||
}
|
||||
|
||||
private static Alert MapToAlert(ScoredFinding finding)
|
||||
{
|
||||
// Compute band based on risk score
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for evidence bundle operations.
|
||||
/// </summary>
|
||||
public sealed class EvidenceBundleService : IEvidenceBundleService
|
||||
{
|
||||
private readonly ILogger<EvidenceBundleService> _logger;
|
||||
|
||||
public EvidenceBundleService(ILogger<EvidenceBundleService> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<EvidenceBundle?> GetBundleAsync(
|
||||
string tenantId,
|
||||
string alertId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(alertId);
|
||||
|
||||
_logger.LogDebug("Getting evidence bundle for alert {AlertId} in tenant {TenantId}", alertId, tenantId);
|
||||
|
||||
// Placeholder implementation - returns null indicating bundle not found
|
||||
return Task.FromResult<EvidenceBundle?>(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<EvidenceBundleContent?> CreateBundleAsync(
|
||||
string alertId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(alertId);
|
||||
|
||||
_logger.LogDebug("Creating evidence bundle for alert {AlertId}", alertId);
|
||||
|
||||
// Placeholder implementation - returns null indicating bundle cannot be created
|
||||
// Full implementation would gather evidence artifacts and create a tar.gz archive
|
||||
return Task.FromResult<EvidenceBundleContent?>(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<EvidenceBundleVerificationResult> VerifyBundleAsync(
|
||||
string alertId,
|
||||
string bundleHash,
|
||||
string? signature,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(alertId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleHash);
|
||||
|
||||
_logger.LogDebug("Verifying evidence bundle for alert {AlertId} with hash {Hash}", alertId, bundleHash);
|
||||
|
||||
// Placeholder implementation - returns valid result
|
||||
// Full implementation would verify hash integrity and signature
|
||||
return Task.FromResult(new EvidenceBundleVerificationResult
|
||||
{
|
||||
IsValid = true,
|
||||
HashValid = true,
|
||||
SignatureValid = signature is not null,
|
||||
ChainValid = true,
|
||||
Errors = null
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,13 @@ public interface IAlertService
|
||||
string tenantId,
|
||||
string alertId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific alert by ID (tenant from context).
|
||||
/// </summary>
|
||||
Task<Alert?> GetAlertAsync(
|
||||
string alertId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -14,4 +14,72 @@ public interface IEvidenceBundleService
|
||||
string tenantId,
|
||||
string alertId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an evidence bundle for download.
|
||||
/// </summary>
|
||||
Task<EvidenceBundleContent?> CreateBundleAsync(
|
||||
string alertId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies an evidence bundle.
|
||||
/// </summary>
|
||||
Task<EvidenceBundleVerificationResult> VerifyBundleAsync(
|
||||
string alertId,
|
||||
string bundleHash,
|
||||
string? signature,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content for an evidence bundle download.
|
||||
/// </summary>
|
||||
public sealed class EvidenceBundleContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Bundle content stream.
|
||||
/// </summary>
|
||||
public required Stream Content { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content type.
|
||||
/// </summary>
|
||||
public string ContentType { get; init; } = "application/gzip";
|
||||
|
||||
/// <summary>
|
||||
/// File name for download.
|
||||
/// </summary>
|
||||
public string? FileName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of bundle verification.
|
||||
/// </summary>
|
||||
public sealed class EvidenceBundleVerificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Overall validity.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the signature is valid.
|
||||
/// </summary>
|
||||
public bool SignatureValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the hash is valid.
|
||||
/// </summary>
|
||||
public bool HashValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the chain is valid.
|
||||
/// </summary>
|
||||
public bool ChainValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of errors, if any.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user