Merge remote changes (theirs)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -264,7 +265,7 @@ internal sealed class ExceptionAdapter : IExceptionAdapter
|
||||
builder["exception.owner"] = exception.OwnerId;
|
||||
builder["exception.requester"] = exception.RequesterId;
|
||||
builder["exception.rationale"] = exception.Rationale;
|
||||
builder["exception.expiresAt"] = exception.ExpiresAt.ToString("O");
|
||||
builder["exception.expiresAt"] = exception.ExpiresAt.ToString("O", CultureInfo.InvariantCulture);
|
||||
|
||||
if (exception.ApproverIds.Length > 0)
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Policy.Engine.AirGap;
|
||||
@@ -393,7 +394,7 @@ internal sealed class WebhookNotificationChannel : IAirGapNotificationChannel
|
||||
severity = notification.Severity.ToString(),
|
||||
title = notification.Title,
|
||||
message = notification.Message,
|
||||
occurred_at = notification.OccurredAt.ToString("O"),
|
||||
occurred_at = notification.OccurredAt.ToString("O", CultureInfo.InvariantCulture),
|
||||
metadata = notification.Metadata
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -68,7 +69,7 @@ internal sealed class PolicyPackBundleImportService
|
||||
TenantId: tenantId,
|
||||
Status: BundleImportStatus.Validating,
|
||||
ExportCount: 0,
|
||||
ImportedAt: now.ToString("O"),
|
||||
ImportedAt: now.ToString("O", CultureInfo.InvariantCulture),
|
||||
Error: null,
|
||||
Bundle: null);
|
||||
|
||||
@@ -163,7 +164,7 @@ internal sealed class PolicyPackBundleImportService
|
||||
TenantId: tenantId,
|
||||
Status: BundleImportStatus.Imported,
|
||||
ExportCount: bundle.Exports.Count,
|
||||
ImportedAt: now.ToString("O"),
|
||||
ImportedAt: now.ToString("O", CultureInfo.InvariantCulture),
|
||||
Error: null,
|
||||
Bundle: bundle);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -76,7 +77,7 @@ public sealed class RiskProfileAirGapExportService
|
||||
ExportId: Guid.NewGuid().ToString("N")[..16],
|
||||
ProfileId: profile.Id,
|
||||
ProfileVersion: profile.Version,
|
||||
CreatedAt: now.ToString("O"),
|
||||
CreatedAt: now.ToString("O", CultureInfo.InvariantCulture),
|
||||
ArtifactSizeBytes: Encoding.UTF8.GetByteCount(profileJson),
|
||||
ArtifactDigest: artifactDigest,
|
||||
ContentHash: contentHash,
|
||||
@@ -99,7 +100,7 @@ public sealed class RiskProfileAirGapExportService
|
||||
|
||||
return new RiskProfileAirGapBundle(
|
||||
SchemaVersion: 1,
|
||||
GeneratedAt: now.ToString("O"),
|
||||
GeneratedAt: now.ToString("O", CultureInfo.InvariantCulture),
|
||||
TargetRepository: request.TargetRepository,
|
||||
DomainId: DomainId,
|
||||
DisplayName: request.DisplayName ?? "Risk Profiles Export",
|
||||
@@ -337,7 +338,7 @@ public sealed class RiskProfileAirGapExportService
|
||||
Algorithm: "HMAC-SHA256",
|
||||
KeyId: keyId ?? "default",
|
||||
Provider: "stellaops",
|
||||
SignedAt: signedAt.ToString("O"));
|
||||
SignedAt: signedAt.ToString("O", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
private static string ComputeSignatureData(List<RiskProfileAirGapExport> exports, string merkleRoot)
|
||||
@@ -422,7 +423,7 @@ public sealed class RiskProfileAirGapExportService
|
||||
PredicateType: PredicateType,
|
||||
RekorLocation: null,
|
||||
EnvelopeDigest: null,
|
||||
SignedAt: signedAt.ToString("O"));
|
||||
SignedAt: signedAt.ToString("O", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
private static string GenerateBundleId(DateTimeOffset timestamp)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -129,7 +130,7 @@ public sealed record ScoreProvenanceChain
|
||||
rule_name = Verdict.MatchedRuleName,
|
||||
verdict_digest = Verdict.VerdictDigest
|
||||
},
|
||||
created_at = CreatedAt.ToUniversalTime().ToString("O")
|
||||
created_at = CreatedAt.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(canonical, ProvenanceJsonOptions.Default);
|
||||
@@ -662,7 +663,7 @@ public sealed record ProvenanceVerdictRef
|
||||
status = predicate.Verdict.Status,
|
||||
severity = predicate.Verdict.Severity,
|
||||
score = predicate.Verdict.Score,
|
||||
evaluated_at = predicate.EvaluatedAt.ToUniversalTime().ToString("O")
|
||||
evaluated_at = predicate.EvaluatedAt.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(canonical, ProvenanceJsonOptions.Default);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -38,7 +39,7 @@ internal sealed class BatchContextService
|
||||
ComputeTraceRef(request.TenantId, i)))
|
||||
.ToList();
|
||||
|
||||
var expires = _timeProvider.GetUtcNow().AddHours(1).ToString("O");
|
||||
var expires = _timeProvider.GetUtcNow().AddHours(1).ToString("O", CultureInfo.InvariantCulture);
|
||||
var contextId = ComputeContextId(request, sortedItems);
|
||||
|
||||
return new BatchContextResponse(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -58,7 +59,7 @@ internal sealed partial class ConsoleExportJobService
|
||||
Destination: request.Destination,
|
||||
Signing: request.Signing,
|
||||
Enabled: true,
|
||||
CreatedAt: now.ToString("O"),
|
||||
CreatedAt: now.ToString("O", CultureInfo.InvariantCulture),
|
||||
LastRunAt: null,
|
||||
NextRunAt: CalculateNextRun(request.Schedule, now));
|
||||
|
||||
@@ -128,7 +129,7 @@ internal sealed partial class ConsoleExportJobService
|
||||
JobId: jobId,
|
||||
Status: "running",
|
||||
BundleId: null,
|
||||
StartedAt: now.ToString("O"),
|
||||
StartedAt: now.ToString("O", CultureInfo.InvariantCulture),
|
||||
CompletedAt: null,
|
||||
Error: null);
|
||||
|
||||
@@ -177,7 +178,7 @@ internal sealed partial class ConsoleExportJobService
|
||||
BundleId: bundleId,
|
||||
JobId: job.JobId,
|
||||
TenantId: job.TenantId,
|
||||
CreatedAt: now.ToString("O"),
|
||||
CreatedAt: now.ToString("O", CultureInfo.InvariantCulture),
|
||||
Format: job.Format,
|
||||
ArtifactDigest: artifactDigest,
|
||||
ArtifactSizeBytes: contentBytes.Length,
|
||||
@@ -195,14 +196,14 @@ internal sealed partial class ConsoleExportJobService
|
||||
{
|
||||
Status = "completed",
|
||||
BundleId = bundleId,
|
||||
CompletedAt = now.ToString("O")
|
||||
CompletedAt = now.ToString("O", CultureInfo.InvariantCulture)
|
||||
};
|
||||
await _executionStore.SaveAsync(completedExecution, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Update job with last run
|
||||
var updatedJob = job with
|
||||
{
|
||||
LastRunAt = now.ToString("O"),
|
||||
LastRunAt = now.ToString("O", CultureInfo.InvariantCulture),
|
||||
NextRunAt = CalculateNextRun(job.Schedule, now)
|
||||
};
|
||||
await _jobStore.SaveAsync(updatedJob, cancellationToken).ConfigureAwait(false);
|
||||
@@ -212,7 +213,7 @@ internal sealed partial class ConsoleExportJobService
|
||||
var failedExecution = execution with
|
||||
{
|
||||
Status = "failed",
|
||||
CompletedAt = _timeProvider.GetUtcNow().ToString("O"),
|
||||
CompletedAt = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture),
|
||||
Error = ex.Message
|
||||
};
|
||||
await _executionStore.SaveAsync(failedExecution, CancellationToken.None).ConfigureAwait(false);
|
||||
@@ -269,7 +270,7 @@ internal sealed partial class ConsoleExportJobService
|
||||
// In production, this would use a proper cron parser like Cronos
|
||||
if (schedule.StartsWith("0 0 ", StringComparison.Ordinal))
|
||||
{
|
||||
return from.AddDays(1).ToString("O");
|
||||
return from.AddDays(1).ToString("O", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
if (schedule.StartsWith("0 */", StringComparison.Ordinal))
|
||||
@@ -277,11 +278,11 @@ internal sealed partial class ConsoleExportJobService
|
||||
var hourMatch = Regex.Match(schedule, @"\*/(\d+)");
|
||||
if (hourMatch.Success && int.TryParse(hourMatch.Groups[1].Value, out var hours))
|
||||
{
|
||||
return from.AddHours(hours).ToString("O");
|
||||
return from.AddHours(hours).ToString("O", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
|
||||
return from.AddDays(1).ToString("O");
|
||||
return from.AddDays(1).ToString("O", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string GenerateId(string prefix)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Policy.Determinization;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
using StellaOps.Policy.Engine.Gates.Determinization;
|
||||
using StellaOps.Policy.Engine.Policies;
|
||||
using StellaOps.Policy.Engine.Subscriptions;
|
||||
|
||||
namespace StellaOps.Policy.Engine.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency injection extensions for determinization engine.
|
||||
/// </summary>
|
||||
public static class DeterminizationEngineExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Add determinization gate and related services to the service collection.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddDeterminizationEngine(this IServiceCollection services)
|
||||
{
|
||||
// Add determinization library services
|
||||
services.AddDeterminization();
|
||||
|
||||
// Add metrics
|
||||
services.TryAddSingleton<DeterminizationGateMetrics>();
|
||||
|
||||
// Add gate
|
||||
services.TryAddSingleton<IDeterminizationGate, DeterminizationGate>();
|
||||
|
||||
// Add policy
|
||||
services.TryAddSingleton<IDeterminizationPolicy, DeterminizationPolicy>();
|
||||
|
||||
// Add signal snapshot builder
|
||||
services.TryAddSingleton<ISignalSnapshotBuilder, SignalSnapshotBuilder>();
|
||||
|
||||
// Add signal update subscription
|
||||
services.TryAddSingleton<ISignalUpdateSubscription, SignalUpdateHandler>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -294,7 +294,7 @@ public static class PolicyEngineServiceCollectionExtensions
|
||||
/// Adds all Policy Engine services with default configuration.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Includes core services, event pipeline, worker, explainer, and Evidence-Weighted Score services.
|
||||
/// Includes core services, event pipeline, worker, explainer, determinization gate, and Evidence-Weighted Score services.
|
||||
/// EWS services are registered but only activate when <see cref="PolicyEvidenceWeightedScoreOptions.Enabled"/> is true.
|
||||
/// </remarks>
|
||||
public static IServiceCollection AddPolicyEngine(this IServiceCollection services)
|
||||
@@ -304,6 +304,9 @@ public static class PolicyEngineServiceCollectionExtensions
|
||||
services.AddPolicyEngineWorker();
|
||||
services.AddPolicyEngineExplainer();
|
||||
|
||||
// Determinization gate and policy services (Sprint 20260106_001_003)
|
||||
services.AddDeterminizationEngine();
|
||||
|
||||
// Evidence-Weighted Score services (Sprint 8200.0012.0003)
|
||||
// Always registered; activation controlled by PolicyEvidenceWeightedScoreOptions.Enabled
|
||||
services.AddEvidenceWeightedScore();
|
||||
@@ -342,6 +345,9 @@ public static class PolicyEngineServiceCollectionExtensions
|
||||
services.AddPolicyEngineWorker();
|
||||
services.AddPolicyEngineExplainer();
|
||||
|
||||
// Determinization gate and policy services (Sprint 20260106_001_003)
|
||||
services.AddDeterminizationEngine();
|
||||
|
||||
// Conditional EWS registration based on configuration
|
||||
services.AddEvidenceWeightedScoreIfEnabled(configuration);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -544,7 +545,7 @@ internal sealed class MessagingExceptionEffectiveCache : IExceptionEffectiveCach
|
||||
var statsKey = GetStatsKey(tenantId);
|
||||
var stats = new Dictionary<string, string>
|
||||
{
|
||||
["lastWarmAt"] = warmAt.ToString("O"),
|
||||
["lastWarmAt"] = warmAt.ToString("O", CultureInfo.InvariantCulture),
|
||||
["lastWarmCount"] = count.ToString(),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -642,7 +643,7 @@ internal sealed class RedisExceptionEffectiveCache : IExceptionEffectiveCache
|
||||
|
||||
var stats = new Dictionary<string, string>
|
||||
{
|
||||
["lastWarmAt"] = warmAt.ToString("O"),
|
||||
["lastWarmAt"] = warmAt.ToString("O", CultureInfo.InvariantCulture),
|
||||
["lastWarmCount"] = count.ToString(),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Determinization;
|
||||
using StellaOps.Policy.Determinization.Models;
|
||||
using StellaOps.Policy.Determinization.Scoring;
|
||||
using StellaOps.Policy.Engine.Gates.Determinization;
|
||||
using StellaOps.Policy.Engine.Policies;
|
||||
using StellaOps.Policy.Gates;
|
||||
using StellaOps.Policy.TrustLattice;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Gate that evaluates CVE observations against determinization thresholds.
|
||||
/// </summary>
|
||||
public sealed class DeterminizationGate : IDeterminizationGate
|
||||
{
|
||||
private static readonly Meter Meter = new("StellaOps.Policy.Engine.Gates");
|
||||
private static readonly Counter<long> EvaluationsCounter = Meter.CreateCounter<long>(
|
||||
"stellaops_policy_determinization_evaluations_total",
|
||||
"evaluations",
|
||||
"Total determinization gate evaluations");
|
||||
private static readonly Counter<long> RuleMatchesCounter = Meter.CreateCounter<long>(
|
||||
"stellaops_policy_determinization_rule_matches_total",
|
||||
"matches",
|
||||
"Total determinization rule matches by rule name");
|
||||
|
||||
private readonly IDeterminizationPolicy _policy;
|
||||
private readonly IUncertaintyScoreCalculator _uncertaintyCalculator;
|
||||
private readonly IDecayedConfidenceCalculator _decayCalculator;
|
||||
private readonly TrustScoreAggregator _trustAggregator;
|
||||
private readonly ISignalSnapshotBuilder _snapshotBuilder;
|
||||
private readonly ILogger<DeterminizationGate> _logger;
|
||||
|
||||
public DeterminizationGate(
|
||||
IDeterminizationPolicy policy,
|
||||
IUncertaintyScoreCalculator uncertaintyCalculator,
|
||||
IDecayedConfidenceCalculator decayCalculator,
|
||||
TrustScoreAggregator trustAggregator,
|
||||
ISignalSnapshotBuilder snapshotBuilder,
|
||||
ILogger<DeterminizationGate> logger)
|
||||
{
|
||||
_policy = policy;
|
||||
_uncertaintyCalculator = uncertaintyCalculator;
|
||||
_decayCalculator = decayCalculator;
|
||||
_trustAggregator = trustAggregator;
|
||||
_snapshotBuilder = snapshotBuilder;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<GateResult> EvaluateAsync(
|
||||
MergeResult mergeResult,
|
||||
PolicyGateContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var result = await EvaluateDeterminizationAsync(mergeResult, context, ct);
|
||||
|
||||
return new GateResult
|
||||
{
|
||||
GateName = "DeterminizationGate",
|
||||
Passed = result.Passed,
|
||||
Reason = result.Reason,
|
||||
Details = BuildDetails(result)
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<DeterminizationGateResult> EvaluateDeterminizationAsync(
|
||||
MergeResult mergeResult,
|
||||
PolicyGateContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(mergeResult);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
// Extract CVE ID and PURL from context
|
||||
var cveId = context.CveId ?? throw new ArgumentException("CveId is required", nameof(context));
|
||||
var purl = context.SubjectKey ?? throw new ArgumentException("SubjectKey is required", nameof(context));
|
||||
|
||||
// 1. Build signal snapshot for the CVE/component
|
||||
var snapshot = await _snapshotBuilder.BuildAsync(cveId, purl, ct);
|
||||
|
||||
// 2. Calculate uncertainty
|
||||
var uncertainty = _uncertaintyCalculator.Calculate(snapshot);
|
||||
|
||||
// 3. Calculate decay
|
||||
var lastUpdate = DetermineLastSignalUpdate(snapshot);
|
||||
var decay = _decayCalculator.Calculate(
|
||||
baseConfidence: 1.0,
|
||||
ageDays: (snapshot.SnapshotAt - lastUpdate).TotalDays,
|
||||
halfLifeDays: 30,
|
||||
floor: 0.1);
|
||||
|
||||
// 4. Calculate trust score
|
||||
var trustScore = _trustAggregator.Aggregate(snapshot, uncertainty);
|
||||
|
||||
// 5. Parse environment from context
|
||||
var environment = ParseEnvironment(context.Environment);
|
||||
|
||||
// 6. Build determinization context
|
||||
var determCtx = new DeterminizationContext
|
||||
{
|
||||
SignalSnapshot = snapshot,
|
||||
UncertaintyScore = uncertainty,
|
||||
Decay = new ObservationDecay
|
||||
{
|
||||
LastSignalUpdate = lastUpdate,
|
||||
AgeDays = (snapshot.SnapshotAt - lastUpdate).TotalDays,
|
||||
DecayedMultiplier = decay,
|
||||
IsStale = decay < 0.5
|
||||
},
|
||||
TrustScore = trustScore,
|
||||
Environment = environment
|
||||
};
|
||||
|
||||
// 7. Evaluate policy
|
||||
var policyResult = _policy.Evaluate(determCtx);
|
||||
|
||||
// 8. Record metrics
|
||||
EvaluationsCounter.Add(1,
|
||||
new KeyValuePair<string, object?>("status", policyResult.Status.ToString()),
|
||||
new KeyValuePair<string, object?>("environment", environment.ToString()),
|
||||
new KeyValuePair<string, object?>("passed", policyResult.Status is PolicyVerdictStatus.Pass or PolicyVerdictStatus.GuardedPass));
|
||||
|
||||
if (policyResult.MatchedRule is not null)
|
||||
{
|
||||
RuleMatchesCounter.Add(1,
|
||||
new KeyValuePair<string, object?>("rule", policyResult.MatchedRule),
|
||||
new KeyValuePair<string, object?>("status", policyResult.Status.ToString()));
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"DeterminizationGate evaluated CVE {CveId} on {Purl}: status={Status}, entropy={Entropy:F3}, trust={Trust:F3}, rule={Rule}",
|
||||
cveId,
|
||||
purl,
|
||||
policyResult.Status,
|
||||
uncertainty.Entropy,
|
||||
trustScore,
|
||||
policyResult.MatchedRule);
|
||||
|
||||
return new DeterminizationGateResult
|
||||
{
|
||||
Passed = policyResult.Status is PolicyVerdictStatus.Pass or PolicyVerdictStatus.GuardedPass,
|
||||
Status = policyResult.Status,
|
||||
Reason = policyResult.Reason,
|
||||
GuardRails = policyResult.GuardRails,
|
||||
UncertaintyScore = uncertainty,
|
||||
Decay = determCtx.Decay,
|
||||
TrustScore = trustScore,
|
||||
MatchedRule = policyResult.MatchedRule,
|
||||
Metadata = null
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTimeOffset DetermineLastSignalUpdate(SignalSnapshot snapshot)
|
||||
{
|
||||
var timestamps = new List<DateTimeOffset>();
|
||||
|
||||
if (snapshot.Epss.QueriedAt.HasValue) timestamps.Add(snapshot.Epss.QueriedAt.Value);
|
||||
if (snapshot.Vex.QueriedAt.HasValue) timestamps.Add(snapshot.Vex.QueriedAt.Value);
|
||||
if (snapshot.Reachability.QueriedAt.HasValue) timestamps.Add(snapshot.Reachability.QueriedAt.Value);
|
||||
if (snapshot.Runtime.QueriedAt.HasValue) timestamps.Add(snapshot.Runtime.QueriedAt.Value);
|
||||
if (snapshot.Backport.QueriedAt.HasValue) timestamps.Add(snapshot.Backport.QueriedAt.Value);
|
||||
if (snapshot.Sbom.QueriedAt.HasValue) timestamps.Add(snapshot.Sbom.QueriedAt.Value);
|
||||
if (snapshot.Cvss.QueriedAt.HasValue) timestamps.Add(snapshot.Cvss.QueriedAt.Value);
|
||||
|
||||
return timestamps.Count > 0 ? timestamps.Max() : snapshot.SnapshotAt;
|
||||
}
|
||||
|
||||
private static DeploymentEnvironment ParseEnvironment(string environment) =>
|
||||
environment.ToLowerInvariant() switch
|
||||
{
|
||||
"production" or "prod" => DeploymentEnvironment.Production,
|
||||
"staging" or "stage" => DeploymentEnvironment.Staging,
|
||||
"testing" or "test" => DeploymentEnvironment.Testing,
|
||||
"development" or "dev" => DeploymentEnvironment.Development,
|
||||
_ => DeploymentEnvironment.Development
|
||||
};
|
||||
|
||||
private static ImmutableDictionary<string, object> BuildDetails(DeterminizationGateResult result)
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, object>();
|
||||
|
||||
builder["uncertainty_entropy"] = result.UncertaintyScore.Entropy;
|
||||
builder["uncertainty_tier"] = result.UncertaintyScore.Tier.ToString();
|
||||
builder["uncertainty_completeness"] = result.UncertaintyScore.Completeness;
|
||||
builder["decay_multiplier"] = result.Decay.DecayedMultiplier;
|
||||
builder["decay_is_stale"] = result.Decay.IsStale;
|
||||
builder["decay_age_days"] = result.Decay.AgeDays;
|
||||
builder["trust_score"] = result.TrustScore;
|
||||
|
||||
if (result.MatchedRule is not null)
|
||||
builder["matched_rule"] = result.MatchedRule;
|
||||
|
||||
if (result.GuardRails is not null)
|
||||
{
|
||||
builder["guardrails_monitoring"] = result.GuardRails.EnableMonitoring;
|
||||
if (result.GuardRails.ReevalAfter.HasValue)
|
||||
builder["guardrails_reeval_after"] = result.GuardRails.ReevalAfter.Value.ToString();
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using StellaOps.Policy.Determinization.Models;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Gates.Determinization;
|
||||
|
||||
/// <summary>
|
||||
/// OpenTelemetry metrics for determinization gate and observation state tracking.
|
||||
/// </summary>
|
||||
public sealed class DeterminizationGateMetrics : IDisposable
|
||||
{
|
||||
private readonly Meter _meter;
|
||||
private readonly Counter<long> _evaluationsTotal;
|
||||
private readonly Counter<long> _ruleMatchesTotal;
|
||||
private readonly Counter<long> _stateTransitionsTotal;
|
||||
private readonly Histogram<double> _entropyDistribution;
|
||||
private readonly Histogram<double> _trustScoreDistribution;
|
||||
private readonly Histogram<double> _evaluationDurationMs;
|
||||
|
||||
public const string MeterName = "StellaOps.Policy.Engine.DeterminizationGate";
|
||||
|
||||
public DeterminizationGateMetrics()
|
||||
{
|
||||
_meter = new Meter(MeterName, "1.0.0");
|
||||
|
||||
_evaluationsTotal = _meter.CreateCounter<long>(
|
||||
"stellaops_policy_determinization_evaluations_total",
|
||||
unit: "{evaluations}",
|
||||
description: "Total number of determinization gate evaluations");
|
||||
|
||||
_ruleMatchesTotal = _meter.CreateCounter<long>(
|
||||
"stellaops_policy_determinization_rule_matches_total",
|
||||
unit: "{matches}",
|
||||
description: "Total number of determinization rule matches by rule name");
|
||||
|
||||
_stateTransitionsTotal = _meter.CreateCounter<long>(
|
||||
"stellaops_policy_observation_state_transitions_total",
|
||||
unit: "{transitions}",
|
||||
description: "Total number of observation state transitions");
|
||||
|
||||
_entropyDistribution = _meter.CreateHistogram<double>(
|
||||
"stellaops_policy_determinization_entropy",
|
||||
unit: "1",
|
||||
description: "Distribution of entropy scores evaluated");
|
||||
|
||||
_trustScoreDistribution = _meter.CreateHistogram<double>(
|
||||
"stellaops_policy_determinization_trust_score",
|
||||
unit: "1",
|
||||
description: "Distribution of trust scores evaluated");
|
||||
|
||||
_evaluationDurationMs = _meter.CreateHistogram<double>(
|
||||
"stellaops_policy_determinization_evaluation_duration_ms",
|
||||
unit: "ms",
|
||||
description: "Duration of determinization gate evaluations");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record a gate evaluation.
|
||||
/// </summary>
|
||||
public void RecordEvaluation(
|
||||
PolicyVerdictStatus status,
|
||||
string environment,
|
||||
string? matchedRule)
|
||||
{
|
||||
_evaluationsTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("status", status.ToString().ToLowerInvariant()),
|
||||
new KeyValuePair<string, object?>("environment", environment),
|
||||
new KeyValuePair<string, object?>("rule", matchedRule ?? "none"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record a rule match.
|
||||
/// </summary>
|
||||
public void RecordRuleMatch(
|
||||
string ruleName,
|
||||
PolicyVerdictStatus status,
|
||||
string environment)
|
||||
{
|
||||
_ruleMatchesTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("rule", ruleName),
|
||||
new KeyValuePair<string, object?>("status", status.ToString().ToLowerInvariant()),
|
||||
new KeyValuePair<string, object?>("environment", environment));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record an observation state transition.
|
||||
/// </summary>
|
||||
public void RecordStateTransition(
|
||||
ObservationState fromState,
|
||||
ObservationState toState,
|
||||
string trigger,
|
||||
string environment)
|
||||
{
|
||||
_stateTransitionsTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("from_state", fromState.ToString().ToLowerInvariant()),
|
||||
new KeyValuePair<string, object?>("to_state", toState.ToString().ToLowerInvariant()),
|
||||
new KeyValuePair<string, object?>("trigger", trigger),
|
||||
new KeyValuePair<string, object?>("environment", environment));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record entropy value from evaluation.
|
||||
/// </summary>
|
||||
public void RecordEntropy(double entropy, string environment)
|
||||
{
|
||||
_entropyDistribution.Record(
|
||||
entropy,
|
||||
new KeyValuePair<string, object?>("environment", environment));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record trust score from evaluation.
|
||||
/// </summary>
|
||||
public void RecordTrustScore(double trustScore, string environment)
|
||||
{
|
||||
_trustScoreDistribution.Record(
|
||||
trustScore,
|
||||
new KeyValuePair<string, object?>("environment", environment));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record evaluation duration.
|
||||
/// </summary>
|
||||
public void RecordDuration(TimeSpan duration, string environment)
|
||||
{
|
||||
_evaluationDurationMs.Record(
|
||||
duration.TotalMilliseconds,
|
||||
new KeyValuePair<string, object?>("environment", environment));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a timer scope for measuring evaluation duration.
|
||||
/// </summary>
|
||||
public IDisposable StartEvaluationTimer(string environment)
|
||||
{
|
||||
return new DurationScope(this, environment);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_meter.Dispose();
|
||||
}
|
||||
|
||||
private sealed class DurationScope : IDisposable
|
||||
{
|
||||
private readonly DeterminizationGateMetrics _metrics;
|
||||
private readonly string _environment;
|
||||
private readonly Stopwatch _stopwatch;
|
||||
|
||||
public DurationScope(DeterminizationGateMetrics metrics, string environment)
|
||||
{
|
||||
_metrics = metrics;
|
||||
_environment = environment;
|
||||
_stopwatch = Stopwatch.StartNew();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_stopwatch.Stop();
|
||||
_metrics.RecordDuration(_stopwatch.Elapsed, _environment);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using StellaOps.Policy.Determinization.Models;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Gates.Determinization;
|
||||
|
||||
/// <summary>
|
||||
/// Builds signal snapshots for determinization evaluation.
|
||||
/// </summary>
|
||||
public interface ISignalSnapshotBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Build a signal snapshot for the given CVE/component pair.
|
||||
/// </summary>
|
||||
/// <param name="cveId">CVE identifier.</param>
|
||||
/// <param name="componentPurl">Component PURL.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Signal snapshot containing all available signals.</returns>
|
||||
Task<SignalSnapshot> BuildAsync(
|
||||
string cveId,
|
||||
string componentPurl,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Determinization.Models;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Gates.Determinization;
|
||||
|
||||
/// <summary>
|
||||
/// Builds signal snapshots for determinization evaluation by querying signal repositories.
|
||||
/// </summary>
|
||||
public sealed class SignalSnapshotBuilder : ISignalSnapshotBuilder
|
||||
{
|
||||
private readonly ISignalRepository _signalRepository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<SignalSnapshotBuilder> _logger;
|
||||
|
||||
public SignalSnapshotBuilder(
|
||||
ISignalRepository signalRepository,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<SignalSnapshotBuilder> logger)
|
||||
{
|
||||
_signalRepository = signalRepository;
|
||||
_timeProvider = timeProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SignalSnapshot> BuildAsync(
|
||||
string cveId,
|
||||
string componentPurl,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(componentPurl);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Building signal snapshot for CVE {CveId} on {Purl}",
|
||||
cveId,
|
||||
componentPurl);
|
||||
|
||||
var snapshotAt = _timeProvider.GetUtcNow();
|
||||
var subjectKey = BuildSubjectKey(cveId, componentPurl);
|
||||
|
||||
// Query all signals in parallel
|
||||
var signalsTask = _signalRepository.GetSignalsAsync(subjectKey, ct);
|
||||
var signals = await signalsTask;
|
||||
|
||||
// Build snapshot from retrieved signals
|
||||
var snapshot = SignalSnapshot.Empty(cveId, componentPurl, snapshotAt);
|
||||
|
||||
foreach (var signal in signals)
|
||||
{
|
||||
snapshot = ApplySignal(snapshot, signal);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Built signal snapshot for CVE {CveId} on {Purl}: {SignalCount} signals present",
|
||||
cveId,
|
||||
componentPurl,
|
||||
signals.Count);
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private static string BuildSubjectKey(string cveId, string componentPurl)
|
||||
=> $"{cveId}::{componentPurl}";
|
||||
|
||||
private SignalSnapshot ApplySignal(SignalSnapshot snapshot, Signal signal)
|
||||
{
|
||||
// This is a placeholder implementation
|
||||
// In a real implementation, this would map Signal objects to SignalState<T> instances
|
||||
// based on signal type and update the appropriate field in the snapshot
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repository for retrieving signals.
|
||||
/// </summary>
|
||||
public interface ISignalRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Get all signals for the given subject key.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Signal>> GetSignalsAsync(string subjectKey, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a signal retrieved from storage.
|
||||
/// </summary>
|
||||
public sealed record Signal
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public required string SubjectKey { get; init; }
|
||||
public required DateTimeOffset ObservedAt { get; init; }
|
||||
public required object? Evidence { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Determinization.Models;
|
||||
using StellaOps.Policy.Gates;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Gate that evaluates determinization state and uncertainty for findings.
|
||||
/// </summary>
|
||||
public interface IDeterminizationGate : IPolicyGate
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluate a finding against determinization thresholds.
|
||||
/// </summary>
|
||||
/// <param name="mergeResult">The merge result from trust lattice.</param>
|
||||
/// <param name="context">Policy gate context.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Determinization-specific gate evaluation result.</returns>
|
||||
Task<DeterminizationGateResult> EvaluateDeterminizationAsync(
|
||||
TrustLattice.MergeResult mergeResult,
|
||||
PolicyGateContext context,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of determinization gate evaluation.
|
||||
/// </summary>
|
||||
public sealed record DeterminizationGateResult
|
||||
{
|
||||
/// <summary>Whether the gate passed.</summary>
|
||||
public required bool Passed { get; init; }
|
||||
|
||||
/// <summary>Policy verdict status.</summary>
|
||||
public required PolicyVerdictStatus Status { get; init; }
|
||||
|
||||
/// <summary>Reason for the decision.</summary>
|
||||
public required string Reason { get; init; }
|
||||
|
||||
/// <summary>Guardrails if GuardedPass.</summary>
|
||||
public GuardRails? GuardRails { get; init; }
|
||||
|
||||
/// <summary>Uncertainty score.</summary>
|
||||
public required UncertaintyScore UncertaintyScore { get; init; }
|
||||
|
||||
/// <summary>Decay information.</summary>
|
||||
public required ObservationDecay Decay { get; init; }
|
||||
|
||||
/// <summary>Trust score.</summary>
|
||||
public required double TrustScore { get; init; }
|
||||
|
||||
/// <summary>Rule that matched.</summary>
|
||||
public string? MatchedRule { get; init; }
|
||||
|
||||
/// <summary>Additional metadata for audit.</summary>
|
||||
public ImmutableDictionary<string, object>? Metadata { get; init; }
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
using StellaOps.Facet;
|
||||
using StellaOps.Policy.Gates;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Gates;
|
||||
|
||||
/// <summary>
|
||||
@@ -35,6 +38,11 @@ public sealed class PolicyGateOptions
|
||||
/// </summary>
|
||||
public OverrideOptions Override { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Facet quota gate options.
|
||||
/// </summary>
|
||||
public FacetQuotaGateOptions FacetQuota { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Whether gates are enabled.
|
||||
/// </summary>
|
||||
@@ -139,3 +147,72 @@ public sealed class OverrideOptions
|
||||
/// </summary>
|
||||
public int MinJustificationLength { get; set; } = 20;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the facet drift quota gate.
|
||||
/// Sprint: SPRINT_20260105_002_003_FACET (QTA-011)
|
||||
/// </summary>
|
||||
public sealed class FacetQuotaGateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether facet quota enforcement is enabled.
|
||||
/// When disabled, the facet quota gate will skip evaluation.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Default action when quota is exceeded and no facet-specific action is defined.
|
||||
/// </summary>
|
||||
public QuotaExceededAction DefaultAction { get; set; } = QuotaExceededAction.Warn;
|
||||
|
||||
/// <summary>
|
||||
/// Default maximum churn percentage allowed before quota enforcement triggers.
|
||||
/// </summary>
|
||||
public decimal DefaultMaxChurnPercent { get; set; } = 10.0m;
|
||||
|
||||
/// <summary>
|
||||
/// Default maximum number of changed files allowed before quota enforcement triggers.
|
||||
/// </summary>
|
||||
public int DefaultMaxChangedFiles { get; set; } = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to skip quota check when no baseline seal is found.
|
||||
/// </summary>
|
||||
public bool SkipIfNoBaseline { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// SLA in days for VEX draft review when action is RequireVex.
|
||||
/// </summary>
|
||||
public int VexReviewSlaDays { get; set; } = 7;
|
||||
|
||||
/// <summary>
|
||||
/// Per-facet quota overrides by facet ID.
|
||||
/// </summary>
|
||||
public Dictionary<string, FacetQuotaOverride> FacetOverrides { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-facet quota configuration override.
|
||||
/// </summary>
|
||||
public sealed class FacetQuotaOverride
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum churn percentage for this facet.
|
||||
/// </summary>
|
||||
public decimal? MaxChurnPercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum changed files for this facet.
|
||||
/// </summary>
|
||||
public int? MaxChangedFiles { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Action when this facet's quota is exceeded.
|
||||
/// </summary>
|
||||
public QuotaExceededAction? Action { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Allowlist globs for files that don't count against quota.
|
||||
/// </summary>
|
||||
public List<string> AllowlistGlobs { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
@@ -396,7 +397,7 @@ public sealed class IncrementalPolicyOrchestrator
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.Append(tenantId).Append('|');
|
||||
builder.Append(createdAt.ToString("O")).Append('|');
|
||||
builder.Append(createdAt.ToString("O", CultureInfo.InvariantCulture)).Append('|');
|
||||
|
||||
foreach (var evt in events.OrderBy(e => e.EventId, StringComparer.Ordinal))
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
@@ -55,7 +56,7 @@ internal sealed class LedgerExportService
|
||||
AdvisoryId: item.AdvisoryId,
|
||||
Status: item.Status,
|
||||
TraceRef: item.TraceRef,
|
||||
OccurredAt: result.CompletedAt.ToString("O")));
|
||||
OccurredAt: result.CompletedAt.ToString("O", CultureInfo.InvariantCulture)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +67,7 @@ internal sealed class LedgerExportService
|
||||
.ThenBy(r => r.AdvisoryId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var generatedAt = _timeProvider.GetUtcNow().ToString("O");
|
||||
var generatedAt = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture);
|
||||
var exportId = StableIdGenerator.CreateUlid($"{request.TenantId}|{generatedAt}|{ordered.Count}");
|
||||
|
||||
var recordLines = ordered.Select(r => JsonSerializer.Serialize(r, SerializerOptions)).ToList();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Orchestration;
|
||||
@@ -95,7 +96,7 @@ internal sealed class OrchestratorJobService
|
||||
.Append(request.ContextId).Append('|')
|
||||
.Append(request.PolicyProfileHash).Append('|')
|
||||
.Append(priority).Append('|')
|
||||
.Append(requestedAt.ToString("O"));
|
||||
.Append(requestedAt.ToString("O", CultureInfo.InvariantCulture));
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Overlay;
|
||||
@@ -36,7 +37,7 @@ internal sealed class OverlayChangeEventPublisher
|
||||
string correlationId,
|
||||
PathDecisionDelta? delta = null)
|
||||
{
|
||||
var emittedAt = _timeProvider.GetUtcNow().ToString("O");
|
||||
var emittedAt = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture);
|
||||
return new OverlayChangeEvent(
|
||||
Tenant: tenant,
|
||||
RuleId: projection.RuleId,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
@@ -40,7 +41,7 @@ internal sealed class OverlayProjectionService
|
||||
|
||||
var projections = new List<OverlayProjection>(orderedTargets.Count);
|
||||
var version = 1;
|
||||
var effectiveAt = _timeProvider.GetUtcNow().ToString("O");
|
||||
var effectiveAt = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture);
|
||||
|
||||
foreach (var target in orderedTargets)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Determinization;
|
||||
using StellaOps.Policy.Determinization.Models;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Policies;
|
||||
|
||||
/// <summary>
|
||||
/// Implements allow/quarantine/escalate logic per advisory specification.
|
||||
/// </summary>
|
||||
public sealed class DeterminizationPolicy : IDeterminizationPolicy
|
||||
{
|
||||
private readonly DeterminizationOptions _options;
|
||||
private readonly DeterminizationRuleSet _ruleSet;
|
||||
private readonly ILogger<DeterminizationPolicy> _logger;
|
||||
|
||||
public DeterminizationPolicy(
|
||||
IOptions<DeterminizationOptions> options,
|
||||
ILogger<DeterminizationPolicy> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_ruleSet = DeterminizationRuleSet.Default(_options);
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public DeterminizationResult Evaluate(DeterminizationContext ctx)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ctx);
|
||||
|
||||
// Get environment-specific thresholds
|
||||
var thresholds = GetEnvironmentThresholds(ctx.Environment);
|
||||
|
||||
// Evaluate rules in priority order
|
||||
foreach (var rule in _ruleSet.Rules.OrderBy(r => r.Priority))
|
||||
{
|
||||
if (rule.Condition(ctx, thresholds))
|
||||
{
|
||||
var result = rule.Action(ctx, thresholds);
|
||||
result = result with { MatchedRule = rule.Name };
|
||||
|
||||
_logger.LogDebug(
|
||||
"Rule {RuleName} matched for CVE {CveId}: {Status}",
|
||||
rule.Name,
|
||||
ctx.SignalSnapshot.Cve,
|
||||
result.Status);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Default: Deferred (no rule matched, needs more evidence)
|
||||
return DeterminizationResult.Deferred(
|
||||
"No determinization rule matched; additional evidence required");
|
||||
}
|
||||
|
||||
private EnvironmentThresholds GetEnvironmentThresholds(DeploymentEnvironment env)
|
||||
{
|
||||
return env switch
|
||||
{
|
||||
DeploymentEnvironment.Production => DefaultEnvironmentThresholds.Production,
|
||||
DeploymentEnvironment.Staging => DefaultEnvironmentThresholds.Staging,
|
||||
DeploymentEnvironment.Testing => DefaultEnvironmentThresholds.Development,
|
||||
DeploymentEnvironment.Development => DefaultEnvironmentThresholds.Development,
|
||||
_ => DefaultEnvironmentThresholds.Development
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Environment-specific thresholds for determinization decisions.
|
||||
/// </summary>
|
||||
public sealed record EnvironmentThresholds
|
||||
{
|
||||
public required DeploymentEnvironment Environment { get; init; }
|
||||
public required double MinConfidenceForNotAffected { get; init; }
|
||||
public required double MaxEntropyForAllow { get; init; }
|
||||
public required double EpssBlockThreshold { get; init; }
|
||||
public required bool RequireReachabilityForAllow { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default environment thresholds per advisory.
|
||||
/// </summary>
|
||||
public static class DefaultEnvironmentThresholds
|
||||
{
|
||||
public static EnvironmentThresholds Production => new()
|
||||
{
|
||||
Environment = DeploymentEnvironment.Production,
|
||||
MinConfidenceForNotAffected = 0.75,
|
||||
MaxEntropyForAllow = 0.3,
|
||||
EpssBlockThreshold = 0.3,
|
||||
RequireReachabilityForAllow = true
|
||||
};
|
||||
|
||||
public static EnvironmentThresholds Staging => new()
|
||||
{
|
||||
Environment = DeploymentEnvironment.Staging,
|
||||
MinConfidenceForNotAffected = 0.60,
|
||||
MaxEntropyForAllow = 0.5,
|
||||
EpssBlockThreshold = 0.4,
|
||||
RequireReachabilityForAllow = true
|
||||
};
|
||||
|
||||
public static EnvironmentThresholds Development => new()
|
||||
{
|
||||
Environment = DeploymentEnvironment.Development,
|
||||
MinConfidenceForNotAffected = 0.40,
|
||||
MaxEntropyForAllow = 0.7,
|
||||
EpssBlockThreshold = 0.6,
|
||||
RequireReachabilityForAllow = false
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Determinization;
|
||||
using StellaOps.Policy.Determinization.Models;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Policies;
|
||||
|
||||
/// <summary>
|
||||
/// Rule set for determinization policy evaluation.
|
||||
/// Rules are evaluated in priority order (lower = higher priority).
|
||||
/// </summary>
|
||||
public sealed class DeterminizationRuleSet
|
||||
{
|
||||
public IReadOnlyList<DeterminizationRule> Rules { get; }
|
||||
|
||||
private DeterminizationRuleSet(IReadOnlyList<DeterminizationRule> rules)
|
||||
{
|
||||
Rules = rules;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the default rule set per advisory specification.
|
||||
/// </summary>
|
||||
public static DeterminizationRuleSet Default(DeterminizationOptions options) =>
|
||||
new(new List<DeterminizationRule>
|
||||
{
|
||||
// Rule 1: Escalate if runtime evidence shows vulnerable code loaded
|
||||
new DeterminizationRule
|
||||
{
|
||||
Name = "RuntimeEscalation",
|
||||
Priority = 10,
|
||||
Condition = (ctx, _) =>
|
||||
ctx.SignalSnapshot.Runtime.HasValue &&
|
||||
ctx.SignalSnapshot.Runtime.Value!.ObservedLoaded,
|
||||
Action = (ctx, _) =>
|
||||
DeterminizationResult.Escalated(
|
||||
"Runtime evidence shows vulnerable code loaded in memory")
|
||||
},
|
||||
|
||||
// Rule 2: Quarantine if EPSS exceeds threshold
|
||||
new DeterminizationRule
|
||||
{
|
||||
Name = "EpssQuarantine",
|
||||
Priority = 20,
|
||||
Condition = (ctx, thresholds) =>
|
||||
ctx.SignalSnapshot.Epss.HasValue &&
|
||||
ctx.SignalSnapshot.Epss.Value!.Score >= thresholds.EpssBlockThreshold,
|
||||
Action = (ctx, thresholds) =>
|
||||
DeterminizationResult.Quarantined(
|
||||
$"EPSS score {ctx.SignalSnapshot.Epss.Value!.Score:P1} exceeds threshold {thresholds.EpssBlockThreshold:P1}")
|
||||
},
|
||||
|
||||
// Rule 3: Quarantine if proven reachable
|
||||
new DeterminizationRule
|
||||
{
|
||||
Name = "ReachabilityQuarantine",
|
||||
Priority = 25,
|
||||
Condition = (ctx, _) =>
|
||||
ctx.SignalSnapshot.Reachability.HasValue &&
|
||||
ctx.SignalSnapshot.Reachability.Value!.IsReachable,
|
||||
Action = (ctx, _) =>
|
||||
DeterminizationResult.Quarantined(
|
||||
$"Vulnerable code is reachable via call graph analysis")
|
||||
},
|
||||
|
||||
// Rule 4: Block high entropy in production
|
||||
new DeterminizationRule
|
||||
{
|
||||
Name = "ProductionEntropyBlock",
|
||||
Priority = 30,
|
||||
Condition = (ctx, thresholds) =>
|
||||
ctx.Environment == DeploymentEnvironment.Production &&
|
||||
ctx.UncertaintyScore.Entropy > thresholds.MaxEntropyForAllow,
|
||||
Action = (ctx, thresholds) =>
|
||||
DeterminizationResult.Quarantined(
|
||||
$"High uncertainty (entropy={ctx.UncertaintyScore.Entropy:F2}) exceeds production threshold ({thresholds.MaxEntropyForAllow:F2})")
|
||||
},
|
||||
|
||||
// Rule 5: Defer if evidence is stale
|
||||
new DeterminizationRule
|
||||
{
|
||||
Name = "StaleEvidenceDefer",
|
||||
Priority = 40,
|
||||
Condition = (ctx, _) => ctx.Decay.IsStale,
|
||||
Action = (ctx, _) =>
|
||||
DeterminizationResult.Deferred(
|
||||
$"Evidence is stale (last update: {ctx.Decay.LastSignalUpdate:u}, age: {ctx.Decay.AgeDays:F1} days)")
|
||||
},
|
||||
|
||||
// Rule 6: Guarded allow for uncertain observations in non-prod
|
||||
new DeterminizationRule
|
||||
{
|
||||
Name = "GuardedAllowNonProd",
|
||||
Priority = 50,
|
||||
Condition = (ctx, _) =>
|
||||
ctx.TrustScore < 0.5 &&
|
||||
ctx.UncertaintyScore.Entropy > 0.4 &&
|
||||
ctx.Environment != DeploymentEnvironment.Production,
|
||||
Action = (ctx, _) =>
|
||||
DeterminizationResult.GuardedPass(
|
||||
$"Uncertain observation (entropy={ctx.UncertaintyScore.Entropy:F2}, trust={ctx.TrustScore:F2}) allowed with guardrails in {ctx.Environment}",
|
||||
BuildGuardrails(ctx, GuardRailsLevel.Moderate))
|
||||
},
|
||||
|
||||
// Rule 7: Allow if unreachable with high confidence
|
||||
new DeterminizationRule
|
||||
{
|
||||
Name = "UnreachableAllow",
|
||||
Priority = 60,
|
||||
Condition = (ctx, thresholds) =>
|
||||
ctx.SignalSnapshot.Reachability.HasValue &&
|
||||
!ctx.SignalSnapshot.Reachability.Value!.IsReachable &&
|
||||
ctx.SignalSnapshot.Reachability.Value.Confidence >= thresholds.MinConfidenceForNotAffected,
|
||||
Action = (ctx, _) =>
|
||||
DeterminizationResult.Allowed(
|
||||
$"Vulnerable code is unreachable (confidence={ctx.SignalSnapshot.Reachability.Value!.Confidence:P0})")
|
||||
},
|
||||
|
||||
// Rule 8: Allow if VEX not_affected with trusted issuer
|
||||
new DeterminizationRule
|
||||
{
|
||||
Name = "VexNotAffectedAllow",
|
||||
Priority = 65,
|
||||
Condition = (ctx, thresholds) =>
|
||||
ctx.SignalSnapshot.Vex.HasValue &&
|
||||
ctx.SignalSnapshot.Vex.Value!.IsNotAffected &&
|
||||
ctx.SignalSnapshot.Vex.Value.IssuerTrust >= thresholds.MinConfidenceForNotAffected,
|
||||
Action = (ctx, _) =>
|
||||
DeterminizationResult.Allowed(
|
||||
$"VEX statement indicates not_affected (trust={ctx.SignalSnapshot.Vex.Value!.IssuerTrust:P0})")
|
||||
},
|
||||
|
||||
// Rule 9: Allow if sufficient evidence and low entropy
|
||||
new DeterminizationRule
|
||||
{
|
||||
Name = "SufficientEvidenceAllow",
|
||||
Priority = 70,
|
||||
Condition = (ctx, thresholds) =>
|
||||
ctx.UncertaintyScore.Entropy <= thresholds.MaxEntropyForAllow &&
|
||||
ctx.TrustScore >= thresholds.MinConfidenceForNotAffected,
|
||||
Action = (ctx, _) =>
|
||||
DeterminizationResult.Allowed(
|
||||
$"Sufficient evidence (entropy={ctx.UncertaintyScore.Entropy:F2}, trust={ctx.TrustScore:F2}) for confident determination")
|
||||
},
|
||||
|
||||
// Rule 10: Guarded allow for moderate uncertainty
|
||||
new DeterminizationRule
|
||||
{
|
||||
Name = "GuardedAllowModerateUncertainty",
|
||||
Priority = 80,
|
||||
Condition = (ctx, _) =>
|
||||
ctx.UncertaintyScore.Tier <= UncertaintyTier.Moderate &&
|
||||
ctx.TrustScore >= 0.4,
|
||||
Action = (ctx, _) =>
|
||||
DeterminizationResult.GuardedPass(
|
||||
$"Moderate uncertainty (tier={ctx.UncertaintyScore.Tier}, trust={ctx.TrustScore:F2}) allowed with monitoring",
|
||||
BuildGuardrails(ctx, GuardRailsLevel.Light))
|
||||
},
|
||||
|
||||
// Rule 11: Default - require more evidence
|
||||
new DeterminizationRule
|
||||
{
|
||||
Name = "DefaultDefer",
|
||||
Priority = 100,
|
||||
Condition = (_, _) => true,
|
||||
Action = (ctx, _) =>
|
||||
DeterminizationResult.Deferred(
|
||||
$"Insufficient evidence for determination (entropy={ctx.UncertaintyScore.Entropy:F2}, tier={ctx.UncertaintyScore.Tier})")
|
||||
}
|
||||
});
|
||||
|
||||
private enum GuardRailsLevel { Light, Moderate, Strict }
|
||||
|
||||
private static GuardRails BuildGuardrails(DeterminizationContext ctx, GuardRailsLevel level) =>
|
||||
level switch
|
||||
{
|
||||
GuardRailsLevel.Light => new GuardRails
|
||||
{
|
||||
EnableMonitoring = true,
|
||||
RestrictToNonProd = false,
|
||||
RequireApproval = false,
|
||||
ReevalAfter = TimeSpan.FromDays(14),
|
||||
Notes = $"Light guardrails: entropy={ctx.UncertaintyScore.Entropy:F2}, trust={ctx.TrustScore:F2}, env={ctx.Environment}"
|
||||
},
|
||||
GuardRailsLevel.Moderate => new GuardRails
|
||||
{
|
||||
EnableMonitoring = true,
|
||||
RestrictToNonProd = ctx.Environment == DeploymentEnvironment.Production,
|
||||
RequireApproval = false,
|
||||
ReevalAfter = TimeSpan.FromDays(7),
|
||||
Notes = $"Moderate guardrails: entropy={ctx.UncertaintyScore.Entropy:F2}, trust={ctx.TrustScore:F2}, env={ctx.Environment}"
|
||||
},
|
||||
GuardRailsLevel.Strict => new GuardRails
|
||||
{
|
||||
EnableMonitoring = true,
|
||||
RestrictToNonProd = true,
|
||||
RequireApproval = true,
|
||||
ReevalAfter = TimeSpan.FromDays(3),
|
||||
Notes = $"Strict guardrails: entropy={ctx.UncertaintyScore.Entropy:F2}, trust={ctx.TrustScore:F2}, env={ctx.Environment}"
|
||||
},
|
||||
_ => GuardRails.Default
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single determinization rule.
|
||||
/// </summary>
|
||||
public sealed record DeterminizationRule
|
||||
{
|
||||
/// <summary>Rule name for audit/logging.</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>Priority (lower = evaluated first).</summary>
|
||||
public required int Priority { get; init; }
|
||||
|
||||
/// <summary>Condition function.</summary>
|
||||
public required Func<DeterminizationContext, EnvironmentThresholds, bool> Condition { get; init; }
|
||||
|
||||
/// <summary>Action function.</summary>
|
||||
public required Func<DeterminizationContext, EnvironmentThresholds, DeterminizationResult> Action { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Determinization.Models;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Policies;
|
||||
|
||||
/// <summary>
|
||||
/// Policy for evaluating determinization decisions (allow/quarantine/escalate).
|
||||
/// </summary>
|
||||
public interface IDeterminizationPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluate a CVE observation against determinization rules.
|
||||
/// </summary>
|
||||
/// <param name="context">Determinization context.</param>
|
||||
/// <returns>Policy decision result.</returns>
|
||||
DeterminizationResult Evaluate(DeterminizationContext context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of determinization policy evaluation.
|
||||
/// </summary>
|
||||
public sealed record DeterminizationResult
|
||||
{
|
||||
/// <summary>Policy verdict status.</summary>
|
||||
public required PolicyVerdictStatus Status { get; init; }
|
||||
|
||||
/// <summary>Explanation of the decision.</summary>
|
||||
public required string Reason { get; init; }
|
||||
|
||||
/// <summary>Guardrails if GuardedPass.</summary>
|
||||
public GuardRails? GuardRails { get; init; }
|
||||
|
||||
/// <summary>Rule that matched.</summary>
|
||||
public string? MatchedRule { get; init; }
|
||||
|
||||
/// <summary>Suggested observation state.</summary>
|
||||
public ObservationState? SuggestedState { get; init; }
|
||||
|
||||
public static DeterminizationResult Allowed(string reason) =>
|
||||
new() { Status = PolicyVerdictStatus.Pass, Reason = reason };
|
||||
|
||||
public static DeterminizationResult GuardedPass(string reason, GuardRails guardRails) =>
|
||||
new() { Status = PolicyVerdictStatus.GuardedPass, Reason = reason, GuardRails = guardRails };
|
||||
|
||||
public static DeterminizationResult Quarantined(string reason, PolicyVerdictStatus status = PolicyVerdictStatus.Blocked) =>
|
||||
new() { Status = status, Reason = reason };
|
||||
|
||||
public static DeterminizationResult Escalated(string reason, PolicyVerdictStatus status = PolicyVerdictStatus.Escalated) =>
|
||||
new() { Status = status, Reason = reason };
|
||||
|
||||
public static DeterminizationResult Deferred(string reason, PolicyVerdictStatus status = PolicyVerdictStatus.Deferred) =>
|
||||
new() { Status = status, Reason = reason };
|
||||
}
|
||||
@@ -375,8 +375,8 @@ app.MapConflictsApi();
|
||||
|
||||
app.Run();
|
||||
|
||||
// Make Program class partial to allow integration testing while keeping it minimal
|
||||
// Make Program class internal to prevent type conflicts when referencing this assembly
|
||||
namespace StellaOps.Policy.Engine
|
||||
{
|
||||
public partial class Program { }
|
||||
internal partial class Program { }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.RiskProfile.Scope;
|
||||
|
||||
@@ -155,7 +156,7 @@ internal sealed class EffectivePolicyAuditor : IEffectivePolicyAuditor
|
||||
var scope = new Dictionary<string, object?>
|
||||
{
|
||||
["event"] = eventType,
|
||||
["timestamp"] = _timeProvider.GetUtcNow().ToString("O")
|
||||
["timestamp"] = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(actorId))
|
||||
|
||||
@@ -8,9 +8,9 @@ internal sealed class InMemoryPolicyPackRepository : IPolicyPackRepository
|
||||
private readonly ConcurrentDictionary<string, PolicyPackRecord> packs = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryPolicyPackRepository(TimeProvider timeProvider)
|
||||
public InMemoryPolicyPackRepository(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<PolicyPackRecord> CreateAsync(string packId, string? displayName, CancellationToken cancellationToken)
|
||||
@@ -31,16 +31,15 @@ internal sealed class InMemoryPolicyPackRepository : IPolicyPackRepository
|
||||
|
||||
public Task<PolicyRevisionRecord> UpsertRevisionAsync(string packId, int version, bool requiresTwoPersonApproval, PolicyRevisionStatus initialStatus, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var pack = packs.GetOrAdd(packId, id => new PolicyPackRecord(id, null, now));
|
||||
var pack = packs.GetOrAdd(packId, id => new PolicyPackRecord(id, null, _timeProvider.GetUtcNow()));
|
||||
int revisionVersion = version > 0 ? version : pack.GetNextVersion();
|
||||
var revision = pack.GetOrAddRevision(
|
||||
revisionVersion,
|
||||
v => new PolicyRevisionRecord(v, requiresTwoPersonApproval, initialStatus, now));
|
||||
v => new PolicyRevisionRecord(v, requiresTwoPersonApproval, initialStatus, _timeProvider.GetUtcNow()));
|
||||
|
||||
if (revision.Status != initialStatus)
|
||||
{
|
||||
revision.SetStatus(initialStatus, now);
|
||||
revision.SetStatus(initialStatus, _timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
return Task.FromResult(revision);
|
||||
@@ -102,10 +101,9 @@ internal sealed class InMemoryPolicyPackRepository : IPolicyPackRepository
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var pack = packs.GetOrAdd(packId, id => new PolicyPackRecord(id, null, now));
|
||||
var pack = packs.GetOrAdd(packId, id => new PolicyPackRecord(id, null, _timeProvider.GetUtcNow()));
|
||||
var revision = pack.GetOrAddRevision(version > 0 ? version : pack.GetNextVersion(),
|
||||
v => new PolicyRevisionRecord(v, requiresTwoPerson: false, status: PolicyRevisionStatus.Draft, now));
|
||||
v => new PolicyRevisionRecord(v, requiresTwoPerson: false, status: PolicyRevisionStatus.Draft, _timeProvider.GetUtcNow()));
|
||||
|
||||
revision.SetBundle(bundle);
|
||||
return Task.FromResult(bundle);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using StellaOps.Policy.Engine.Ledger;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
@@ -40,7 +41,7 @@ internal sealed class SnapshotService
|
||||
.GroupBy(r => r.Status)
|
||||
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal);
|
||||
|
||||
var generatedAt = _timeProvider.GetUtcNow().ToString("O");
|
||||
var generatedAt = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture);
|
||||
var snapshotId = StableIdGenerator.CreateUlid($"{export.Manifest.ExportId}|{request.OverlayHash}");
|
||||
|
||||
var snapshot = new SnapshotDetail(
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<ProjectReference Include="../../Router/__Libraries/StellaOps.Messaging/StellaOps.Messaging.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Provcache/StellaOps.Provcache.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy.Determinization/StellaOps.Policy.Determinization.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy.Exceptions/StellaOps.Policy.Exceptions.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy.Unknowns/StellaOps.Policy.Unknowns.csproj" />
|
||||
<ProjectReference Include="../StellaOps.PolicyDsl/StellaOps.PolicyDsl.csproj" />
|
||||
|
||||
@@ -16,6 +16,12 @@ public sealed class InMemoryExceptionRepository(TimeProvider timeProvider, IGuid
|
||||
private readonly TimeProvider _timeProvider = timeProvider;
|
||||
private readonly IGuidProvider _guidProvider = guidProvider;
|
||||
private readonly ConcurrentDictionary<(string Tenant, Guid Id), ExceptionEntity> _exceptions = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryExceptionRepository(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<ExceptionEntity> CreateAsync(ExceptionEntity exception, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using StellaOps.Policy.Determinization.Models;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Subscriptions;
|
||||
|
||||
/// <summary>
|
||||
/// Events for signal updates that trigger re-evaluation.
|
||||
/// </summary>
|
||||
public static class DeterminizationEventTypes
|
||||
{
|
||||
public const string EpssUpdated = "epss.updated";
|
||||
public const string VexUpdated = "vex.updated";
|
||||
public const string ReachabilityUpdated = "reachability.updated";
|
||||
public const string RuntimeUpdated = "runtime.updated";
|
||||
public const string BackportUpdated = "backport.updated";
|
||||
public const string ObservationStateChanged = "observation.state_changed";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event published when a signal is updated.
|
||||
/// </summary>
|
||||
public sealed record SignalUpdatedEvent
|
||||
{
|
||||
public required string EventType { get; init; }
|
||||
public required string CveId { get; init; }
|
||||
public required string Purl { get; init; }
|
||||
public required DateTimeOffset UpdatedAt { get; init; }
|
||||
public required string Source { get; init; }
|
||||
public object? NewValue { get; init; }
|
||||
public object? PreviousValue { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event published when observation state changes.
|
||||
/// </summary>
|
||||
public sealed record ObservationStateChangedEvent
|
||||
{
|
||||
public required Guid ObservationId { get; init; }
|
||||
public required string CveId { get; init; }
|
||||
public required string Purl { get; init; }
|
||||
public required ObservationState PreviousState { get; init; }
|
||||
public required ObservationState NewState { get; init; }
|
||||
public required string Reason { get; init; }
|
||||
public required DateTimeOffset ChangedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.Policy.Engine.Subscriptions;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for signal update events.
|
||||
/// </summary>
|
||||
public interface ISignalUpdateSubscription
|
||||
{
|
||||
/// <summary>
|
||||
/// Handle a signal update and re-evaluate affected observations.
|
||||
/// </summary>
|
||||
Task HandleAsync(SignalUpdatedEvent evt, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Determinization.Models;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Subscriptions;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of signal update handling.
|
||||
/// </summary>
|
||||
public sealed class SignalUpdateHandler : ISignalUpdateSubscription
|
||||
{
|
||||
private readonly IObservationRepository _observations;
|
||||
private readonly IDeterminizationGate _gate;
|
||||
private readonly IEventPublisher _eventPublisher;
|
||||
private readonly ILogger<SignalUpdateHandler> _logger;
|
||||
|
||||
public SignalUpdateHandler(
|
||||
IObservationRepository observations,
|
||||
IDeterminizationGate gate,
|
||||
IEventPublisher eventPublisher,
|
||||
ILogger<SignalUpdateHandler> logger)
|
||||
{
|
||||
_observations = observations;
|
||||
_gate = gate;
|
||||
_eventPublisher = eventPublisher;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task HandleAsync(SignalUpdatedEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Processing signal update: {EventType} for CVE {CveId} on {Purl}",
|
||||
evt.EventType,
|
||||
evt.CveId,
|
||||
evt.Purl);
|
||||
|
||||
// Find observations affected by this signal
|
||||
var affected = await _observations.FindByCveAndPurlAsync(evt.CveId, evt.Purl, ct);
|
||||
|
||||
foreach (var obs in affected)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ReEvaluateObservationAsync(obs, evt, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to re-evaluate observation {ObservationId} after signal update",
|
||||
obs.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReEvaluateObservationAsync(
|
||||
CveObservation obs,
|
||||
SignalUpdatedEvent trigger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// This is a placeholder for re-evaluation logic
|
||||
// In a full implementation, this would:
|
||||
// 1. Build PolicyGateContext from observation
|
||||
// 2. Call gate.EvaluateDeterminizationAsync()
|
||||
// 3. Compare new verdict with old verdict
|
||||
// 4. Publish ObservationStateChangedEvent if state changed
|
||||
// 5. Update observation in repository
|
||||
|
||||
_logger.LogDebug(
|
||||
"Re-evaluating observation {ObservationId} after {EventType}",
|
||||
obs.Id,
|
||||
trigger.EventType);
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repository for CVE observations.
|
||||
/// </summary>
|
||||
public interface IObservationRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Find observations by CVE ID and component PURL.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<CveObservation>> FindByCveAndPurlAsync(
|
||||
string cveId,
|
||||
string purl,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event publisher abstraction.
|
||||
/// </summary>
|
||||
public interface IEventPublisher
|
||||
{
|
||||
/// <summary>
|
||||
/// Publish an event.
|
||||
/// </summary>
|
||||
Task PublishAsync<TEvent>(TEvent evt, CancellationToken ct = default)
|
||||
where TEvent : class;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CVE observation model.
|
||||
/// </summary>
|
||||
public sealed record CveObservation
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
public required string CveId { get; init; }
|
||||
public required string SubjectPurl { get; init; }
|
||||
public required ObservationState State { get; init; }
|
||||
public required DateTimeOffset ObservedAt { get; init; }
|
||||
}
|
||||
@@ -5,6 +5,6 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0440-M | DONE | Maintainability audit for StellaOps.Policy.Engine. |
|
||||
| AUDIT-0440-T | DONE | Test coverage audit for StellaOps.Policy.Engine. |
|
||||
| AUDIT-0440-A | TODO | APPLY pending approval for StellaOps.Policy.Engine. |
|
||||
| AUDIT-0440-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.Policy.Engine. |
|
||||
| AUDIT-0440-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Policy.Engine. |
|
||||
| AUDIT-0440-A | TODO | Revalidated 2026-01-07 (open findings). |
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OpenTelemetry.Trace;
|
||||
@@ -62,7 +63,7 @@ public sealed class IncidentModeService
|
||||
_logger.LogWarning(
|
||||
"Incident mode ENABLED. Reason: {Reason}, ExpiresAt: {ExpiresAt}",
|
||||
reason,
|
||||
expiresAt?.ToString("O") ?? "never");
|
||||
expiresAt?.ToString("O", CultureInfo.InvariantCulture) ?? "never");
|
||||
|
||||
PolicyEngineTelemetry.RecordError("incident_mode_enabled", null);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -48,7 +49,7 @@ internal sealed class TrustWeightingService
|
||||
|
||||
private IReadOnlyList<TrustWeightingEntry> Normalize(IReadOnlyList<TrustWeightingEntry> entries)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow().ToString("O");
|
||||
var now = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture);
|
||||
|
||||
var normalized = entries
|
||||
.Where(e => !string.IsNullOrWhiteSpace(e.Source))
|
||||
@@ -65,7 +66,7 @@ internal sealed class TrustWeightingService
|
||||
|
||||
private static IReadOnlyList<TrustWeightingEntry> DefaultWeights()
|
||||
{
|
||||
var now = TimeProvider.System.GetUtcNow().ToString("O");
|
||||
var now = TimeProvider.System.GetUtcNow().ToString("O", CultureInfo.InvariantCulture);
|
||||
return new[]
|
||||
{
|
||||
new TrustWeightingEntry("cartographer", 1.000m, null, now),
|
||||
|
||||
Reference in New Issue
Block a user