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),
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
// Sprint: SPRINT_20251226_003_BE_exception_approval
|
||||
// Task: EXCEPT-05, EXCEPT-06, EXCEPT-07 - Exception approval API endpoints
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Determinism.Abstractions;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Persistence.Postgres.Models;
|
||||
using StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
@@ -91,8 +89,7 @@ public static class ExceptionApprovalEndpoints
|
||||
CreateApprovalRequestDto request,
|
||||
IExceptionApprovalRepository repository,
|
||||
IExceptionApprovalRulesService rulesService,
|
||||
TimeProvider timeProvider,
|
||||
IGuidProvider guidProvider,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
ILogger<ExceptionApprovalRequestEntity> logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -114,8 +111,7 @@ public static class ExceptionApprovalEndpoints
|
||||
}
|
||||
|
||||
// Generate request ID
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var requestId = $"EAR-{now.ToString("yyyyMMdd", CultureInfo.InvariantCulture)}-{guidProvider.NewGuid().ToString("N", CultureInfo.InvariantCulture)[..8].ToUpperInvariant()}";
|
||||
var requestId = $"EAR-{timeProvider.GetUtcNow():yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
|
||||
|
||||
// Parse gate level
|
||||
if (!Enum.TryParse<GateLevel>(request.GateLevel, ignoreCase: true, out var gateLevel))
|
||||
@@ -144,9 +140,10 @@ public static class ExceptionApprovalEndpoints
|
||||
});
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var entity = new ExceptionApprovalRequestEntity
|
||||
{
|
||||
Id = guidProvider.NewGuid(),
|
||||
Id = Guid.NewGuid(),
|
||||
RequestId = requestId,
|
||||
TenantId = tenantId,
|
||||
ExceptionId = request.ExceptionId,
|
||||
@@ -208,7 +205,7 @@ public static class ExceptionApprovalEndpoints
|
||||
// Record audit entry
|
||||
await repository.RecordAuditAsync(new ExceptionApprovalAuditEntity
|
||||
{
|
||||
Id = guidProvider.NewGuid(),
|
||||
Id = Guid.NewGuid(),
|
||||
RequestId = requestId,
|
||||
TenantId = tenantId,
|
||||
SequenceNumber = 1,
|
||||
|
||||
@@ -8,7 +8,6 @@ using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Determinism.Abstractions;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Exceptions.Repositories;
|
||||
using StellaOps.Policy.Gateway.Contracts;
|
||||
@@ -135,8 +134,7 @@ public static class ExceptionEndpoints
|
||||
CreateExceptionRequest request,
|
||||
HttpContext context,
|
||||
IExceptionRepository repository,
|
||||
TimeProvider timeProvider,
|
||||
IGuidProvider guidProvider,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (request is null)
|
||||
@@ -148,10 +146,8 @@ public static class ExceptionEndpoints
|
||||
});
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
// Validate expiry is in future
|
||||
if (request.ExpiresAt <= now)
|
||||
if (request.ExpiresAt <= timeProvider.GetUtcNow())
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
@@ -162,7 +158,7 @@ public static class ExceptionEndpoints
|
||||
}
|
||||
|
||||
// Validate expiry is not more than 1 year
|
||||
if (request.ExpiresAt > now.AddYears(1))
|
||||
if (request.ExpiresAt > timeProvider.GetUtcNow().AddYears(1))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
@@ -175,7 +171,7 @@ public static class ExceptionEndpoints
|
||||
var actorId = GetActorId(context);
|
||||
var clientInfo = GetClientInfo(context);
|
||||
|
||||
var exceptionId = $"EXC-{guidProvider.NewGuid():N}"[..20];
|
||||
var exceptionId = $"EXC-{Guid.NewGuid():N}"[..20];
|
||||
|
||||
var exception = new ExceptionObject
|
||||
{
|
||||
@@ -193,8 +189,8 @@ public static class ExceptionEndpoints
|
||||
},
|
||||
OwnerId = request.OwnerId,
|
||||
RequesterId = actorId,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
CreatedAt = timeProvider.GetUtcNow(),
|
||||
UpdatedAt = timeProvider.GetUtcNow(),
|
||||
ExpiresAt = request.ExpiresAt,
|
||||
ReasonCode = ParseReasonRequired(request.ReasonCode),
|
||||
Rationale = request.Rationale,
|
||||
@@ -215,7 +211,7 @@ public static class ExceptionEndpoints
|
||||
UpdateExceptionRequest request,
|
||||
HttpContext context,
|
||||
IExceptionRepository repository,
|
||||
TimeProvider timeProvider,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var existing = await repository.GetByIdAsync(id, cancellationToken);
|
||||
@@ -264,7 +260,7 @@ public static class ExceptionEndpoints
|
||||
ApproveExceptionRequest? request,
|
||||
HttpContext context,
|
||||
IExceptionRepository repository,
|
||||
TimeProvider timeProvider,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var existing = await repository.GetByIdAsync(id, cancellationToken);
|
||||
@@ -297,13 +293,12 @@ public static class ExceptionEndpoints
|
||||
});
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var updated = existing with
|
||||
{
|
||||
Version = existing.Version + 1,
|
||||
Status = ExceptionStatus.Approved,
|
||||
UpdatedAt = now,
|
||||
ApprovedAt = now,
|
||||
UpdatedAt = timeProvider.GetUtcNow(),
|
||||
ApprovedAt = timeProvider.GetUtcNow(),
|
||||
ApproverIds = existing.ApproverIds.Add(actorId)
|
||||
};
|
||||
|
||||
@@ -318,7 +313,7 @@ public static class ExceptionEndpoints
|
||||
string id,
|
||||
HttpContext context,
|
||||
IExceptionRepository repository,
|
||||
TimeProvider timeProvider,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var existing = await repository.GetByIdAsync(id, cancellationToken);
|
||||
@@ -359,7 +354,7 @@ public static class ExceptionEndpoints
|
||||
ExtendExceptionRequest request,
|
||||
HttpContext context,
|
||||
IExceptionRepository repository,
|
||||
TimeProvider timeProvider,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var existing = await repository.GetByIdAsync(id, cancellationToken);
|
||||
@@ -410,7 +405,7 @@ public static class ExceptionEndpoints
|
||||
[FromBody] RevokeExceptionRequest? request,
|
||||
HttpContext context,
|
||||
IExceptionRepository repository,
|
||||
TimeProvider timeProvider,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var existing = await repository.GetByIdAsync(id, cancellationToken);
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration
|
||||
// Task: CICD-GATE-01 - Create POST /api/v1/policy/gate/evaluate endpoint
|
||||
|
||||
using System.Globalization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Determinism.Abstractions;
|
||||
using StellaOps.Policy.Audit;
|
||||
using StellaOps.Policy.Deltas;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
@@ -41,8 +39,7 @@ public static class GateEndpoints
|
||||
IBaselineSelector baselineSelector,
|
||||
IGateBypassAuditor bypassAuditor,
|
||||
IMemoryCache cache,
|
||||
TimeProvider timeProvider,
|
||||
IGuidProvider guidProvider,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
ILogger<DriftGateEvaluator> logger,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
@@ -83,7 +80,7 @@ public static class GateEndpoints
|
||||
|
||||
return Results.Ok(new GateEvaluateResponse
|
||||
{
|
||||
DecisionId = $"gate:{timeProvider.GetUtcNow().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)}:{guidProvider.NewGuid():N}",
|
||||
DecisionId = $"gate:{timeProvider.GetUtcNow():yyyyMMddHHmmss}:{Guid.NewGuid():N}",
|
||||
Status = GateStatus.Pass,
|
||||
ExitCode = GateExitCodes.Pass,
|
||||
ImageDigest = request.ImageDigest,
|
||||
@@ -228,7 +225,8 @@ public static class GateEndpoints
|
||||
.WithDescription("Retrieve a previous gate evaluation decision by ID");
|
||||
|
||||
// GET /api/v1/policy/gate/health - Health check for gate service
|
||||
gates.MapGet("/health", (TimeProvider timeProvider) => Results.Ok(new { status = "healthy", timestamp = timeProvider.GetUtcNow() }))
|
||||
gates.MapGet("/health", ([FromServices] TimeProvider timeProvider) =>
|
||||
Results.Ok(new { status = "healthy", timestamp = timeProvider.GetUtcNow() }))
|
||||
.WithName("GateHealth")
|
||||
.WithDescription("Health check for the gate evaluation service");
|
||||
}
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
// Task: GOV-018 - Sealed mode overrides and risk profile events endpoints
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Determinism.Abstractions;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Endpoints;
|
||||
|
||||
@@ -102,11 +101,11 @@ public static class GovernanceEndpoints
|
||||
|
||||
private static Task<IResult> GetSealedModeStatusAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
[FromQuery] string? tenantId)
|
||||
{
|
||||
var tenant = tenantId ?? GetTenantId(httpContext) ?? "default";
|
||||
var state = SealedModeStates.GetOrAdd(tenant, _ => new SealedModeState());
|
||||
var timeProvider = httpContext.RequestServices.GetRequiredService<TimeProvider>();
|
||||
|
||||
var response = new SealedModeStatusResponse
|
||||
{
|
||||
@@ -121,7 +120,7 @@ public static class GovernanceEndpoints
|
||||
.Select(MapOverrideToResponse)
|
||||
.ToList(),
|
||||
VerificationStatus = "verified",
|
||||
LastVerifiedAt = timeProvider.GetUtcNow().ToString("O")
|
||||
LastVerifiedAt = timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
return Task.FromResult(Results.Ok(response));
|
||||
@@ -143,20 +142,21 @@ public static class GovernanceEndpoints
|
||||
|
||||
private static Task<IResult> ToggleSealedModeAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
SealedModeToggleRequest request)
|
||||
{
|
||||
var tenant = GetTenantId(httpContext) ?? "default";
|
||||
var actor = GetActorId(httpContext) ?? "system";
|
||||
var timeProvider = httpContext.RequestServices.GetRequiredService<TimeProvider>();
|
||||
var guidProvider = httpContext.RequestServices.GetRequiredService<IGuidProvider>();
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var state = SealedModeStates.GetOrAdd(tenant, _ => new SealedModeState());
|
||||
|
||||
if (request.Enable)
|
||||
{
|
||||
state = new SealedModeState
|
||||
{
|
||||
IsSealed = true,
|
||||
SealedAt = now.ToString("O"),
|
||||
SealedAt = now.ToString("O", CultureInfo.InvariantCulture),
|
||||
SealedBy = actor,
|
||||
Reason = request.Reason,
|
||||
TrustRoots = request.TrustRoots ?? [],
|
||||
@@ -168,7 +168,7 @@ public static class GovernanceEndpoints
|
||||
state = new SealedModeState
|
||||
{
|
||||
IsSealed = false,
|
||||
LastUnsealedAt = now.ToString("O")
|
||||
LastUnsealedAt = now.ToString("O", CultureInfo.InvariantCulture)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ public static class GovernanceEndpoints
|
||||
|
||||
// Audit
|
||||
RecordAudit(tenant, actor, "sealed_mode_toggled", "sealed-mode", "system_config",
|
||||
$"{(request.Enable ? "Enabled" : "Disabled")} sealed mode: {request.Reason}", timeProvider, guidProvider);
|
||||
$"{(request.Enable ? "Enabled" : "Disabled")} sealed mode: {request.Reason}", timeProvider);
|
||||
|
||||
var response = new SealedModeStatusResponse
|
||||
{
|
||||
@@ -188,7 +188,7 @@ public static class GovernanceEndpoints
|
||||
AllowedSources = state.AllowedSources,
|
||||
Overrides = [],
|
||||
VerificationStatus = "verified",
|
||||
LastVerifiedAt = now.ToString("O")
|
||||
LastVerifiedAt = now.ToString("O", CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
return Task.FromResult(Results.Ok(response));
|
||||
@@ -196,15 +196,14 @@ public static class GovernanceEndpoints
|
||||
|
||||
private static Task<IResult> CreateSealedModeOverrideAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
SealedModeOverrideRequest request)
|
||||
{
|
||||
var tenant = GetTenantId(httpContext) ?? "default";
|
||||
var actor = GetActorId(httpContext) ?? "system";
|
||||
var timeProvider = httpContext.RequestServices.GetRequiredService<TimeProvider>();
|
||||
var guidProvider = httpContext.RequestServices.GetRequiredService<IGuidProvider>();
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var overrideId = $"override-{guidProvider.NewGuid():N}";
|
||||
var overrideId = $"override-{Guid.NewGuid():N}";
|
||||
var entity = new SealedModeOverrideEntity
|
||||
{
|
||||
Id = overrideId,
|
||||
@@ -212,30 +211,29 @@ public static class GovernanceEndpoints
|
||||
Type = request.Type,
|
||||
Target = request.Target,
|
||||
Reason = request.Reason,
|
||||
ApprovalId = $"approval-{guidProvider.NewGuid():N}",
|
||||
ApprovalId = $"approval-{Guid.NewGuid():N}",
|
||||
ApprovedBy = [actor],
|
||||
ExpiresAt = now.AddHours(request.DurationHours).ToString("O"),
|
||||
CreatedAt = now.ToString("O"),
|
||||
ExpiresAt = now.AddHours(request.DurationHours).ToString("O", CultureInfo.InvariantCulture),
|
||||
CreatedAt = now.ToString("O", CultureInfo.InvariantCulture),
|
||||
Active = true
|
||||
};
|
||||
|
||||
Overrides[overrideId] = entity;
|
||||
|
||||
RecordAudit(tenant, actor, "sealed_mode_override_created", overrideId, "sealed_mode_override",
|
||||
$"Created override for {request.Target}: {request.Reason}", timeProvider, guidProvider);
|
||||
$"Created override for {request.Target}: {request.Reason}", timeProvider);
|
||||
|
||||
return Task.FromResult(Results.Ok(MapOverrideToResponse(entity)));
|
||||
}
|
||||
|
||||
private static Task<IResult> RevokeSealedModeOverrideAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
string overrideId,
|
||||
RevokeOverrideRequest request)
|
||||
{
|
||||
var tenant = GetTenantId(httpContext) ?? "default";
|
||||
var actor = GetActorId(httpContext) ?? "system";
|
||||
var timeProvider = httpContext.RequestServices.GetRequiredService<TimeProvider>();
|
||||
var guidProvider = httpContext.RequestServices.GetRequiredService<IGuidProvider>();
|
||||
|
||||
if (!Overrides.TryGetValue(overrideId, out var entity) || entity.TenantId != tenant)
|
||||
{
|
||||
@@ -250,7 +248,7 @@ public static class GovernanceEndpoints
|
||||
Overrides[overrideId] = entity;
|
||||
|
||||
RecordAudit(tenant, actor, "sealed_mode_override_revoked", overrideId, "sealed_mode_override",
|
||||
$"Revoked override: {request.Reason}", timeProvider, guidProvider);
|
||||
$"Revoked override: {request.Reason}", timeProvider);
|
||||
|
||||
return Task.FromResult(Results.NoContent());
|
||||
}
|
||||
@@ -296,15 +294,14 @@ public static class GovernanceEndpoints
|
||||
|
||||
private static Task<IResult> CreateRiskProfileAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CreateRiskProfileRequest request)
|
||||
{
|
||||
var tenant = GetTenantId(httpContext) ?? "default";
|
||||
var actor = GetActorId(httpContext) ?? "system";
|
||||
var timeProvider = httpContext.RequestServices.GetRequiredService<TimeProvider>();
|
||||
var guidProvider = httpContext.RequestServices.GetRequiredService<IGuidProvider>();
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var profileId = $"profile-{guidProvider.NewGuid():N}";
|
||||
var profileId = $"profile-{Guid.NewGuid():N}";
|
||||
var entity = new RiskProfileEntity
|
||||
{
|
||||
Id = profileId,
|
||||
@@ -317,8 +314,8 @@ public static class GovernanceEndpoints
|
||||
Signals = request.Signals ?? [],
|
||||
SeverityOverrides = request.SeverityOverrides ?? [],
|
||||
ActionOverrides = request.ActionOverrides ?? [],
|
||||
CreatedAt = now.ToString("O"),
|
||||
ModifiedAt = now.ToString("O"),
|
||||
CreatedAt = now.ToString("O", CultureInfo.InvariantCulture),
|
||||
ModifiedAt = now.ToString("O", CultureInfo.InvariantCulture),
|
||||
CreatedBy = actor,
|
||||
ModifiedBy = actor
|
||||
};
|
||||
@@ -326,20 +323,19 @@ public static class GovernanceEndpoints
|
||||
RiskProfiles[profileId] = entity;
|
||||
|
||||
RecordAudit(tenant, actor, "risk_profile_created", profileId, "risk_profile",
|
||||
$"Created risk profile: {request.Name}", timeProvider, guidProvider);
|
||||
$"Created risk profile: {request.Name}", timeProvider);
|
||||
|
||||
return Task.FromResult(Results.Created($"/api/v1/governance/risk-profiles/{profileId}", MapProfileToResponse(entity)));
|
||||
}
|
||||
|
||||
private static Task<IResult> UpdateRiskProfileAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
string profileId,
|
||||
UpdateRiskProfileRequest request)
|
||||
{
|
||||
var tenant = GetTenantId(httpContext) ?? "default";
|
||||
var actor = GetActorId(httpContext) ?? "system";
|
||||
var timeProvider = httpContext.RequestServices.GetRequiredService<TimeProvider>();
|
||||
var guidProvider = httpContext.RequestServices.GetRequiredService<IGuidProvider>();
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
if (!RiskProfiles.TryGetValue(profileId, out var existing))
|
||||
@@ -358,26 +354,25 @@ public static class GovernanceEndpoints
|
||||
Signals = request.Signals ?? existing.Signals,
|
||||
SeverityOverrides = request.SeverityOverrides ?? existing.SeverityOverrides,
|
||||
ActionOverrides = request.ActionOverrides ?? existing.ActionOverrides,
|
||||
ModifiedAt = now.ToString("O"),
|
||||
ModifiedAt = now.ToString("O", CultureInfo.InvariantCulture),
|
||||
ModifiedBy = actor
|
||||
};
|
||||
|
||||
RiskProfiles[profileId] = entity;
|
||||
|
||||
RecordAudit(tenant, actor, "risk_profile_updated", profileId, "risk_profile",
|
||||
$"Updated risk profile: {entity.Name}", timeProvider, guidProvider);
|
||||
$"Updated risk profile: {entity.Name}", timeProvider);
|
||||
|
||||
return Task.FromResult(Results.Ok(MapProfileToResponse(entity)));
|
||||
}
|
||||
|
||||
private static Task<IResult> DeleteRiskProfileAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
string profileId)
|
||||
{
|
||||
var tenant = GetTenantId(httpContext) ?? "default";
|
||||
var actor = GetActorId(httpContext) ?? "system";
|
||||
var timeProvider = httpContext.RequestServices.GetRequiredService<TimeProvider>();
|
||||
var guidProvider = httpContext.RequestServices.GetRequiredService<IGuidProvider>();
|
||||
|
||||
if (!RiskProfiles.TryRemove(profileId, out var removed))
|
||||
{
|
||||
@@ -389,19 +384,18 @@ public static class GovernanceEndpoints
|
||||
}
|
||||
|
||||
RecordAudit(tenant, actor, "risk_profile_deleted", profileId, "risk_profile",
|
||||
$"Deleted risk profile: {removed.Name}", timeProvider, guidProvider);
|
||||
$"Deleted risk profile: {removed.Name}", timeProvider);
|
||||
|
||||
return Task.FromResult(Results.NoContent());
|
||||
}
|
||||
|
||||
private static Task<IResult> ActivateRiskProfileAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
string profileId)
|
||||
{
|
||||
var tenant = GetTenantId(httpContext) ?? "default";
|
||||
var actor = GetActorId(httpContext) ?? "system";
|
||||
var timeProvider = httpContext.RequestServices.GetRequiredService<TimeProvider>();
|
||||
var guidProvider = httpContext.RequestServices.GetRequiredService<IGuidProvider>();
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
if (!RiskProfiles.TryGetValue(profileId, out var existing))
|
||||
@@ -416,27 +410,26 @@ public static class GovernanceEndpoints
|
||||
var entity = existing with
|
||||
{
|
||||
Status = "active",
|
||||
ModifiedAt = now.ToString("O"),
|
||||
ModifiedAt = now.ToString("O", CultureInfo.InvariantCulture),
|
||||
ModifiedBy = actor
|
||||
};
|
||||
|
||||
RiskProfiles[profileId] = entity;
|
||||
|
||||
RecordAudit(tenant, actor, "risk_profile_activated", profileId, "risk_profile",
|
||||
$"Activated risk profile: {entity.Name}", timeProvider, guidProvider);
|
||||
$"Activated risk profile: {entity.Name}", timeProvider);
|
||||
|
||||
return Task.FromResult(Results.Ok(MapProfileToResponse(entity)));
|
||||
}
|
||||
|
||||
private static Task<IResult> DeprecateRiskProfileAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
string profileId,
|
||||
DeprecateProfileRequest request)
|
||||
{
|
||||
var tenant = GetTenantId(httpContext) ?? "default";
|
||||
var actor = GetActorId(httpContext) ?? "system";
|
||||
var timeProvider = httpContext.RequestServices.GetRequiredService<TimeProvider>();
|
||||
var guidProvider = httpContext.RequestServices.GetRequiredService<IGuidProvider>();
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
if (!RiskProfiles.TryGetValue(profileId, out var existing))
|
||||
@@ -451,7 +444,7 @@ public static class GovernanceEndpoints
|
||||
var entity = existing with
|
||||
{
|
||||
Status = "deprecated",
|
||||
ModifiedAt = now.ToString("O"),
|
||||
ModifiedAt = now.ToString("O", CultureInfo.InvariantCulture),
|
||||
ModifiedBy = actor,
|
||||
DeprecationReason = request.Reason
|
||||
};
|
||||
@@ -459,7 +452,7 @@ public static class GovernanceEndpoints
|
||||
RiskProfiles[profileId] = entity;
|
||||
|
||||
RecordAudit(tenant, actor, "risk_profile_deprecated", profileId, "risk_profile",
|
||||
$"Deprecated risk profile: {entity.Name} - {request.Reason}", timeProvider, guidProvider);
|
||||
$"Deprecated risk profile: {entity.Name} - {request.Reason}", timeProvider);
|
||||
|
||||
return Task.FromResult(Results.Ok(MapProfileToResponse(entity)));
|
||||
}
|
||||
@@ -559,7 +552,7 @@ public static class GovernanceEndpoints
|
||||
{
|
||||
if (RiskProfiles.IsEmpty)
|
||||
{
|
||||
var now = TimeProvider.System.GetUtcNow().ToString("O", System.Globalization.CultureInfo.InvariantCulture);
|
||||
var now = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture);
|
||||
RiskProfiles["profile-default"] = new RiskProfileEntity
|
||||
{
|
||||
Id = "profile-default",
|
||||
@@ -599,15 +592,15 @@ public static class GovernanceEndpoints
|
||||
?? httpContext.Request.Headers["X-StellaOps-Actor"].FirstOrDefault();
|
||||
}
|
||||
|
||||
private static void RecordAudit(string tenantId, string actor, string eventType, string targetId, string targetType, string summary, TimeProvider timeProvider, IGuidProvider guidProvider)
|
||||
private static void RecordAudit(string tenantId, string actor, string eventType, string targetId, string targetType, string summary, TimeProvider timeProvider)
|
||||
{
|
||||
var id = $"audit-{guidProvider.NewGuid():N}";
|
||||
var id = $"audit-{Guid.NewGuid():N}";
|
||||
AuditEntries[id] = new GovernanceAuditEntry
|
||||
{
|
||||
Id = id,
|
||||
TenantId = tenantId,
|
||||
Type = eventType,
|
||||
Timestamp = timeProvider.GetUtcNow().ToString("O", System.Globalization.CultureInfo.InvariantCulture),
|
||||
Timestamp = timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture),
|
||||
Actor = actor,
|
||||
ActorType = "user",
|
||||
TargetResource = targetId,
|
||||
|
||||
@@ -50,7 +50,7 @@ internal static class RegistryWebhookEndpoints
|
||||
private static async Task<Results<Accepted<WebhookAcceptedResponse>, ProblemHttpResult>> HandleDockerRegistryWebhook(
|
||||
[FromBody] DockerRegistryNotification notification,
|
||||
IGateEvaluationQueue evaluationQueue,
|
||||
TimeProvider timeProvider,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
ILogger<RegistryWebhookEndpointMarker> logger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
@@ -101,7 +101,7 @@ internal static class RegistryWebhookEndpoints
|
||||
private static async Task<Results<Accepted<WebhookAcceptedResponse>, ProblemHttpResult>> HandleHarborWebhook(
|
||||
[FromBody] HarborWebhookEvent notification,
|
||||
IGateEvaluationQueue evaluationQueue,
|
||||
TimeProvider timeProvider,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
ILogger<RegistryWebhookEndpointMarker> logger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
@@ -161,7 +161,7 @@ internal static class RegistryWebhookEndpoints
|
||||
private static async Task<Results<Accepted<WebhookAcceptedResponse>, ProblemHttpResult>> HandleGenericWebhook(
|
||||
[FromBody] GenericRegistryWebhook notification,
|
||||
IGateEvaluationQueue evaluationQueue,
|
||||
TimeProvider timeProvider,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
ILogger<RegistryWebhookEndpointMarker> logger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -5,11 +5,9 @@
|
||||
// Description: In-memory queue for gate evaluation jobs with background processing
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Determinism.Abstractions;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
using StellaOps.Policy.Gateway.Endpoints;
|
||||
|
||||
@@ -24,14 +22,14 @@ public sealed class InMemoryGateEvaluationQueue : IGateEvaluationQueue
|
||||
private readonly Channel<GateEvaluationJob> _channel;
|
||||
private readonly ILogger<InMemoryGateEvaluationQueue> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public InMemoryGateEvaluationQueue(ILogger<InMemoryGateEvaluationQueue> logger, TimeProvider timeProvider, IGuidProvider guidProvider)
|
||||
public InMemoryGateEvaluationQueue(
|
||||
ILogger<InMemoryGateEvaluationQueue> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
_guidProvider = guidProvider;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
// Bounded channel to prevent unbounded memory growth
|
||||
_channel = Channel.CreateBounded<GateEvaluationJob>(new BoundedChannelOptions(1000)
|
||||
@@ -74,8 +72,8 @@ public sealed class InMemoryGateEvaluationQueue : IGateEvaluationQueue
|
||||
private string GenerateJobId()
|
||||
{
|
||||
// Format: gate-{timestamp}-{random}
|
||||
var timestamp = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture);
|
||||
var random = _guidProvider.NewGuid().ToString("N", CultureInfo.InvariantCulture)[..8];
|
||||
var timestamp = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
var random = Guid.NewGuid().ToString("N")[..8];
|
||||
return $"gate-{timestamp}-{random}";
|
||||
}
|
||||
}
|
||||
@@ -92,7 +90,7 @@ public sealed record GateEvaluationJob
|
||||
|
||||
/// <summary>
|
||||
/// Background service that processes gate evaluation jobs from the queue.
|
||||
/// Orchestrates: image analysis → drift delta computation → gate evaluation.
|
||||
/// Orchestrates: image analysis -> drift delta computation -> gate evaluation.
|
||||
/// </summary>
|
||||
public sealed class GateEvaluationWorker : BackgroundService
|
||||
{
|
||||
|
||||
@@ -5,6 +5,6 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0445-M | DONE | Maintainability audit for StellaOps.Policy.Gateway. |
|
||||
| AUDIT-0445-T | DONE | Test coverage audit for StellaOps.Policy.Gateway. |
|
||||
| AUDIT-0445-A | TODO | APPLY pending approval for StellaOps.Policy.Gateway. |
|
||||
| AUDIT-0445-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.Policy.Gateway. |
|
||||
| AUDIT-0445-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Policy.Gateway. |
|
||||
| AUDIT-0445-A | TODO | Revalidated 2026-01-07 (open findings). |
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Sprint: SPRINT_5200_0001_0001 - Starter Policy Template
|
||||
// Task: T7 - Policy Pack Distribution
|
||||
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
@@ -344,7 +345,7 @@ public sealed class PolicyPackOciPublisher : IPolicyPackOciPublisher
|
||||
{
|
||||
var annotations = new SortedDictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["org.opencontainers.image.created"] = _timeProvider.GetUtcNow().ToString("O"),
|
||||
["org.opencontainers.image.created"] = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture),
|
||||
["org.opencontainers.image.title"] = request.PackName,
|
||||
["org.opencontainers.image.version"] = request.PackVersion,
|
||||
["stellaops.policy.pack.name"] = request.PackName,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Determinism.Abstractions;
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Storage;
|
||||
@@ -11,12 +10,10 @@ public sealed class InMemoryOverrideStore : IOverrideStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, Guid OverrideId), OverrideEntity> _overrides = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public InMemoryOverrideStore(TimeProvider timeProvider, IGuidProvider guidProvider)
|
||||
public InMemoryOverrideStore(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider;
|
||||
_guidProvider = guidProvider;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<OverrideEntity> CreateAsync(
|
||||
@@ -26,7 +23,7 @@ public sealed class InMemoryOverrideStore : IOverrideStore
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var overrideId = _guidProvider.NewGuid();
|
||||
var overrideId = Guid.NewGuid();
|
||||
|
||||
var entity = new OverrideEntity
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@ using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Determinism.Abstractions;
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Storage;
|
||||
@@ -15,12 +14,10 @@ public sealed class InMemoryPolicyPackStore : IPolicyPackStore
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, Guid PackId), PolicyPackEntity> _packs = new();
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, Guid PackId), List<PolicyPackHistoryEntry>> _history = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public InMemoryPolicyPackStore(TimeProvider timeProvider, IGuidProvider guidProvider)
|
||||
public InMemoryPolicyPackStore(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider;
|
||||
_guidProvider = guidProvider;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<PolicyPackEntity> CreateAsync(
|
||||
@@ -30,7 +27,7 @@ public sealed class InMemoryPolicyPackStore : IPolicyPackStore
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var packId = _guidProvider.NewGuid();
|
||||
var packId = Guid.NewGuid();
|
||||
|
||||
var entity = new PolicyPackEntity
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@ using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Determinism.Abstractions;
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Storage;
|
||||
@@ -10,11 +9,15 @@ namespace StellaOps.Policy.Registry.Storage;
|
||||
/// <summary>
|
||||
/// In-memory implementation of ISnapshotStore for testing and development.
|
||||
/// </summary>
|
||||
public sealed class InMemorySnapshotStore(TimeProvider timeProvider, IGuidProvider guidProvider) : ISnapshotStore
|
||||
public sealed class InMemorySnapshotStore : ISnapshotStore
|
||||
{
|
||||
private readonly TimeProvider _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
private readonly IGuidProvider _guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, Guid SnapshotId), SnapshotEntity> _snapshots = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemorySnapshotStore(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<SnapshotEntity> CreateAsync(
|
||||
Guid tenantId,
|
||||
@@ -23,7 +26,7 @@ public sealed class InMemorySnapshotStore(TimeProvider timeProvider, IGuidProvid
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var snapshotId = _guidProvider.NewGuid();
|
||||
var snapshotId = Guid.NewGuid();
|
||||
|
||||
// Compute digest from pack IDs and timestamp for uniqueness
|
||||
var digest = ComputeDigest(request.PackIds, now);
|
||||
|
||||
@@ -10,6 +10,12 @@ public sealed class InMemoryVerificationPolicyStore(TimeProvider timeProvider) :
|
||||
{
|
||||
private readonly TimeProvider _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, string PolicyId), VerificationPolicyEntity> _policies = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryVerificationPolicyStore(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<VerificationPolicyEntity> CreateAsync(
|
||||
Guid tenantId,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Determinism.Abstractions;
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Storage;
|
||||
@@ -11,12 +10,10 @@ public sealed class InMemoryViolationStore : IViolationStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, Guid ViolationId), ViolationEntity> _violations = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public InMemoryViolationStore(TimeProvider timeProvider, IGuidProvider guidProvider)
|
||||
public InMemoryViolationStore(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider;
|
||||
_guidProvider = guidProvider;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<ViolationEntity> AppendAsync(
|
||||
@@ -25,7 +22,7 @@ public sealed class InMemoryViolationStore : IViolationStore
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var violationId = _guidProvider.NewGuid();
|
||||
var violationId = Guid.NewGuid();
|
||||
|
||||
var entity = new ViolationEntity
|
||||
{
|
||||
@@ -61,7 +58,7 @@ public sealed class InMemoryViolationStore : IViolationStore
|
||||
try
|
||||
{
|
||||
var request = requests[i];
|
||||
var violationId = _guidProvider.NewGuid();
|
||||
var violationId = Guid.NewGuid();
|
||||
|
||||
var entity = new ViolationEntity
|
||||
{
|
||||
|
||||
@@ -5,6 +5,6 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0450-M | DONE | Maintainability audit for StellaOps.Policy.Registry. |
|
||||
| AUDIT-0450-T | DONE | Test coverage audit for StellaOps.Policy.Registry. |
|
||||
| AUDIT-0450-A | TODO | APPLY pending approval for StellaOps.Policy.Registry. |
|
||||
| AUDIT-0450-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.Policy.Registry. |
|
||||
| AUDIT-0450-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Policy.Registry. |
|
||||
| AUDIT-0450-A | TODO | Revalidated 2026-01-07 (open findings). |
|
||||
|
||||
@@ -5,6 +5,6 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0451-M | DONE | Maintainability audit for StellaOps.Policy.RiskProfile. |
|
||||
| AUDIT-0451-T | DONE | Test coverage audit for StellaOps.Policy.RiskProfile. |
|
||||
| AUDIT-0451-A | TODO | APPLY pending approval for StellaOps.Policy.RiskProfile. |
|
||||
| AUDIT-0451-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.Policy.RiskProfile. |
|
||||
| AUDIT-0451-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Policy.RiskProfile. |
|
||||
| AUDIT-0451-A | TODO | Revalidated 2026-01-07 (open findings). |
|
||||
|
||||
@@ -50,7 +50,7 @@ internal static class ReceiptCanonicalizer
|
||||
// If the value looks like a timestamp, normalize to ISO 8601 round-trip
|
||||
if (DateTimeOffset.TryParse(element.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dto))
|
||||
{
|
||||
writer.WriteStringValue(dto.ToUniversalTime().ToString("O"));
|
||||
writer.WriteStringValue(dto.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture));
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -5,6 +5,6 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0453-M | DONE | Maintainability audit for StellaOps.Policy.Scoring. |
|
||||
| AUDIT-0453-T | DONE | Test coverage audit for StellaOps.Policy.Scoring. |
|
||||
| AUDIT-0453-A | TODO | Awaiting approval to apply changes. |
|
||||
| AUDIT-0453-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.Policy.Scoring. |
|
||||
| AUDIT-0453-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Policy.Scoring. |
|
||||
| AUDIT-0453-A | TODO | Revalidated 2026-01-07 (open findings). |
|
||||
|
||||
@@ -5,6 +5,6 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0439-M | DONE | Maintainability audit for StellaOps.Policy.AuthSignals. |
|
||||
| AUDIT-0439-T | DONE | Test coverage audit for StellaOps.Policy.AuthSignals. |
|
||||
| AUDIT-0439-A | TODO | APPLY pending approval for StellaOps.Policy.AuthSignals. |
|
||||
| AUDIT-0439-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.Policy.AuthSignals. |
|
||||
| AUDIT-0439-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Policy.AuthSignals. |
|
||||
| AUDIT-0439-A | TODO | Revalidated 2026-01-07 (open findings). |
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
# StellaOps.Policy.Determinization - Agent Guide
|
||||
|
||||
## Module Overview
|
||||
|
||||
The **Determinization** library handles CVEs that arrive without complete evidence (EPSS, VEX, reachability). It treats unknown observations as probabilistic with entropy-weighted trust that matures as evidence arrives.
|
||||
|
||||
**Key Concepts:**
|
||||
- `ObservationState`: Lifecycle state for CVE observations (PendingDeterminization, Determined, Disputed, etc.)
|
||||
- `SignalState<T>`: Null-aware wrapper distinguishing "not queried" from "queried but absent"
|
||||
- `UncertaintyScore`: Knowledge completeness measurement (high entropy = missing signals)
|
||||
- `ObservationDecay`: Time-based confidence decay with configurable half-life
|
||||
- `GuardRails`: Monitoring requirements when allowing uncertain observations
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
src/Policy/__Libraries/StellaOps.Policy.Determinization/
|
||||
├── Models/ # Core data models
|
||||
│ ├── ObservationState.cs
|
||||
│ ├── SignalState.cs
|
||||
│ ├── SignalSnapshot.cs
|
||||
│ ├── UncertaintyScore.cs
|
||||
│ ├── ObservationDecay.cs
|
||||
│ ├── GuardRails.cs
|
||||
│ └── DeterminizationContext.cs
|
||||
├── Evidence/ # Signal evidence types
|
||||
│ ├── EpssEvidence.cs
|
||||
│ ├── VexClaimSummary.cs
|
||||
│ ├── ReachabilityEvidence.cs
|
||||
│ └── ...
|
||||
├── Scoring/ # Calculation services
|
||||
│ ├── UncertaintyScoreCalculator.cs
|
||||
│ ├── DecayedConfidenceCalculator.cs
|
||||
│ ├── TrustScoreAggregator.cs
|
||||
│ └── SignalWeights.cs
|
||||
├── Policies/ # Policy rules (in Policy.Engine)
|
||||
└── DeterminizationOptions.cs
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### 1. SignalState<T> Usage
|
||||
|
||||
Always use `SignalState<T>` to wrap signal values:
|
||||
|
||||
```csharp
|
||||
// Good - explicit status
|
||||
var epss = SignalState<EpssEvidence>.WithValue(evidence, queriedAt, "first.org");
|
||||
var vex = SignalState<VexClaimSummary>.Absent(queriedAt, "vendor");
|
||||
var reach = SignalState<ReachabilityEvidence>.NotQueried();
|
||||
var failed = SignalState<CvssEvidence>.Failed("Timeout");
|
||||
|
||||
// Bad - nullable without status
|
||||
EpssEvidence? epss = null; // Can't tell if not queried or absent
|
||||
```
|
||||
|
||||
### 2. Uncertainty Calculation
|
||||
|
||||
Entropy = 1 - (weighted present signals / max weight):
|
||||
|
||||
```csharp
|
||||
// All signals present = 0.0 entropy (fully certain)
|
||||
// No signals present = 1.0 entropy (fully uncertain)
|
||||
// Formula uses configurable weights per signal type
|
||||
```
|
||||
|
||||
### 3. Decay Calculation
|
||||
|
||||
Exponential decay with floor:
|
||||
|
||||
```csharp
|
||||
decayed = max(floor, exp(-ln(2) * age_days / half_life_days))
|
||||
|
||||
// Default: 14-day half-life, 0.35 floor
|
||||
// After 14 days: ~50% confidence
|
||||
// After 28 days: ~35% confidence (floor)
|
||||
```
|
||||
|
||||
### 4. Policy Rules
|
||||
|
||||
Rules evaluate in priority order (lower = first):
|
||||
|
||||
| Priority | Rule | Outcome |
|
||||
|----------|------|---------|
|
||||
| 10 | Runtime shows loaded | Escalated |
|
||||
| 20 | EPSS >= threshold | Blocked |
|
||||
| 25 | Proven reachable | Blocked |
|
||||
| 30 | High entropy in prod | Blocked |
|
||||
| 40 | Evidence stale | Deferred |
|
||||
| 50 | Uncertain + non-prod | GuardedPass |
|
||||
| 60 | Unreachable + confident | Pass |
|
||||
| 70 | Sufficient evidence | Pass |
|
||||
| 100 | Default | Deferred |
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
### Unit Tests Required
|
||||
|
||||
1. `SignalState<T>` factory methods
|
||||
2. `UncertaintyScoreCalculator` entropy bounds [0.0, 1.0]
|
||||
3. `DecayedConfidenceCalculator` half-life formula
|
||||
4. Policy rule priority ordering
|
||||
5. State transition logic
|
||||
|
||||
### Property Tests
|
||||
|
||||
- Entropy always in [0.0, 1.0]
|
||||
- Decay monotonically decreasing with age
|
||||
- Same snapshot produces same uncertainty
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- DI registration with configuration
|
||||
- Signal snapshot building
|
||||
- Policy gate evaluation
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
Determinization:
|
||||
EpssQuarantineThreshold: 0.4
|
||||
GuardedAllowScoreThreshold: 0.5
|
||||
GuardedAllowEntropyThreshold: 0.4
|
||||
ProductionBlockEntropyThreshold: 0.3
|
||||
DecayHalfLifeDays: 14
|
||||
DecayFloor: 0.35
|
||||
GuardedReviewIntervalDays: 7
|
||||
MaxGuardedDurationDays: 30
|
||||
SignalWeights:
|
||||
Vex: 0.25
|
||||
Epss: 0.15
|
||||
Reachability: 0.25
|
||||
Runtime: 0.15
|
||||
Backport: 0.10
|
||||
SbomLineage: 0.10
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Don't confuse EntropySignal with UncertaintyScore**: `EntropySignal` measures code complexity; `UncertaintyScore` measures knowledge completeness.
|
||||
|
||||
2. **Always inject TimeProvider**: Never use `DateTime.UtcNow` directly for decay calculations.
|
||||
|
||||
3. **Normalize weights before calculation**: Call `SignalWeights.Normalize()` to ensure weights sum to 1.0.
|
||||
|
||||
4. **Check signal status before accessing value**: `signal.HasValue` must be true before using `signal.Value!`.
|
||||
|
||||
5. **Handle all ObservationStates**: Switch expressions must be exhaustive.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `StellaOps.Policy` (PolicyVerdictStatus, existing confidence models)
|
||||
- `System.Collections.Immutable` (ImmutableArray for collections)
|
||||
- `Microsoft.Extensions.Options` (configuration)
|
||||
- `Microsoft.Extensions.Logging` (logging)
|
||||
|
||||
## Related Modules
|
||||
|
||||
- **Policy.Engine**: DeterminizationGate integrates with policy pipeline
|
||||
- **Feedser**: Signal attachers emit SignalState<T>
|
||||
- **VexLens**: VEX updates emit SignalUpdatedEvent
|
||||
- **Graph**: CVE nodes carry ObservationState and UncertaintyScore
|
||||
- **Findings**: Observation persistence and audit trail
|
||||
|
||||
## Sprint References
|
||||
|
||||
- SPRINT_20260106_001_001_LB: Core models
|
||||
- SPRINT_20260106_001_002_LB: Scoring services
|
||||
- SPRINT_20260106_001_003_POLICY: Policy integration
|
||||
- SPRINT_20260106_001_004_BE: Backend integration
|
||||
- SPRINT_20260106_001_005_FE: Frontend UI
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace StellaOps.Policy.Determinization;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the Determinization subsystem.
|
||||
/// </summary>
|
||||
public sealed record DeterminizationOptions
|
||||
{
|
||||
/// <summary>Default section name in appsettings.json.</summary>
|
||||
public const string SectionName = "Determinization";
|
||||
|
||||
/// <summary>Signal weights for entropy calculation (default: advisory-recommended weights).</summary>
|
||||
public Scoring.SignalWeights SignalWeights { get; init; } = Scoring.SignalWeights.Default;
|
||||
|
||||
/// <summary>Prior distribution for missing signals (default: Conservative).</summary>
|
||||
public Scoring.PriorDistribution PriorDistribution { get; init; } = Scoring.PriorDistribution.Conservative;
|
||||
|
||||
/// <summary>Half-life for confidence decay in days (default: 14 days).</summary>
|
||||
public double ConfidenceHalfLifeDays { get; init; } = 14.0;
|
||||
|
||||
/// <summary>Minimum confidence floor after decay (default: 0.1).</summary>
|
||||
public double ConfidenceFloor { get; init; } = 0.1;
|
||||
|
||||
/// <summary>Threshold for triggering manual review (default: entropy >= 0.60).</summary>
|
||||
public double ManualReviewEntropyThreshold { get; init; } = 0.60;
|
||||
|
||||
/// <summary>Threshold for triggering refresh (default: entropy >= 0.40).</summary>
|
||||
public double RefreshEntropyThreshold { get; init; } = 0.40;
|
||||
|
||||
/// <summary>Maximum age before observation is considered stale (default: 30 days).</summary>
|
||||
public double StaleObservationDays { get; init; } = 30.0;
|
||||
|
||||
/// <summary>Enable detailed determinization logging (default: false).</summary>
|
||||
public bool EnableDetailedLogging { get; init; } = false;
|
||||
|
||||
/// <summary>Enable automatic refresh for stale observations (default: true).</summary>
|
||||
public bool EnableAutoRefresh { get; init; } = true;
|
||||
|
||||
/// <summary>Maximum retry attempts for failed signal queries (default: 3).</summary>
|
||||
public int MaxSignalQueryRetries { get; init; } = 3;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Evidence;
|
||||
|
||||
/// <summary>
|
||||
/// Backport detection evidence.
|
||||
/// </summary>
|
||||
public sealed record BackportEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Backport detected.
|
||||
/// </summary>
|
||||
[JsonPropertyName("detected")]
|
||||
public required bool Detected { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Backport source (e.g., "vendor-advisory", "patch-diff", "build-id").
|
||||
/// </summary>
|
||||
[JsonPropertyName("source")]
|
||||
public required string Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vendor package version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vendor_version")]
|
||||
public string? VendorVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Upstream version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("upstream_version")]
|
||||
public string? UpstreamVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Patch identifier (e.g., commit hash, KB number).
|
||||
/// </summary>
|
||||
[JsonPropertyName("patch_id")]
|
||||
public string? PatchId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this backport was detected (UTC).
|
||||
/// </summary>
|
||||
[JsonPropertyName("detected_at")]
|
||||
public required DateTimeOffset DetectedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence in this evidence [0.0, 1.0].
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public required double Confidence { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Evidence;
|
||||
|
||||
/// <summary>
|
||||
/// CVSS (Common Vulnerability Scoring System) evidence.
|
||||
/// </summary>
|
||||
public sealed record CvssEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// CVSS version (e.g., "3.1", "4.0").
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base score [0.0, 10.0].
|
||||
/// </summary>
|
||||
[JsonPropertyName("base_score")]
|
||||
public required double BaseScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity (e.g., "LOW", "MEDIUM", "HIGH", "CRITICAL").
|
||||
/// </summary>
|
||||
[JsonPropertyName("severity")]
|
||||
public required string Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vector string (e.g., "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H").
|
||||
/// </summary>
|
||||
[JsonPropertyName("vector")]
|
||||
public string? Vector { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source of CVSS score (e.g., "NVD", "vendor").
|
||||
/// </summary>
|
||||
[JsonPropertyName("source")]
|
||||
public required string Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this CVSS score was published (UTC).
|
||||
/// </summary>
|
||||
[JsonPropertyName("published_at")]
|
||||
public required DateTimeOffset PublishedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Evidence;
|
||||
|
||||
/// <summary>
|
||||
/// EPSS (Exploit Prediction Scoring System) evidence.
|
||||
/// </summary>
|
||||
public sealed record EpssEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// CVE identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("cve")]
|
||||
public required string Cve { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EPSS score [0.0, 1.0].
|
||||
/// Probability of exploitation in the next 30 days.
|
||||
/// </summary>
|
||||
[JsonPropertyName("epss")]
|
||||
public required double Epss { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EPSS percentile [0.0, 1.0].
|
||||
/// </summary>
|
||||
[JsonPropertyName("percentile")]
|
||||
public required double Percentile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this EPSS value was published (UTC).
|
||||
/// </summary>
|
||||
[JsonPropertyName("published_at")]
|
||||
public required DateTimeOffset PublishedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EPSS model version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("model_version")]
|
||||
public string? ModelVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Convenience property returning the EPSS score.
|
||||
/// Alias for <see cref="Epss"/> for API compatibility.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public double Score => Epss;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Evidence;
|
||||
|
||||
/// <summary>
|
||||
/// Reachability analysis evidence.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Reachability status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public required ReachabilityStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Call path depth (if reachable).
|
||||
/// </summary>
|
||||
[JsonPropertyName("depth")]
|
||||
public int? Depth { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entry point function name (if reachable).
|
||||
/// </summary>
|
||||
[JsonPropertyName("entry_point")]
|
||||
public string? EntryPoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerable function name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerable_function")]
|
||||
public string? VulnerableFunction { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this reachability analysis was performed (UTC).
|
||||
/// </summary>
|
||||
[JsonPropertyName("analyzed_at")]
|
||||
public required DateTimeOffset AnalyzedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PathWitness digest (if available).
|
||||
/// </summary>
|
||||
[JsonPropertyName("witness_digest")]
|
||||
public string? WitnessDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Analysis confidence [0.0, 1.0].
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public double Confidence { get; init; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Convenience property indicating if code is reachable.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsReachable => Status == ReachabilityStatus.Reachable;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reachability status.
|
||||
/// </summary>
|
||||
public enum ReachabilityStatus
|
||||
{
|
||||
/// <summary>Vulnerable code is reachable from entry points.</summary>
|
||||
Reachable,
|
||||
|
||||
/// <summary>Vulnerable code is not reachable.</summary>
|
||||
Unreachable,
|
||||
|
||||
/// <summary>Reachability indeterminate (analysis incomplete or failed).</summary>
|
||||
Indeterminate
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Evidence;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime detection evidence.
|
||||
/// </summary>
|
||||
public sealed record RuntimeEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Runtime detection status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("detected")]
|
||||
public required bool Detected { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detection source (e.g., "tracer", "eBPF", "logs").
|
||||
/// </summary>
|
||||
[JsonPropertyName("source")]
|
||||
public required string Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of invocations detected.
|
||||
/// </summary>
|
||||
[JsonPropertyName("invocation_count")]
|
||||
public int? InvocationCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When runtime observation started (UTC).
|
||||
/// </summary>
|
||||
[JsonPropertyName("observation_start")]
|
||||
public required DateTimeOffset ObservationStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When runtime observation ended (UTC).
|
||||
/// </summary>
|
||||
[JsonPropertyName("observation_end")]
|
||||
public required DateTimeOffset ObservationEnd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence in this evidence [0.0, 1.0].
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Convenience property indicating if vulnerable code was observed loaded.
|
||||
/// Alias for <see cref="Detected"/> for API compatibility.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool ObservedLoaded => Detected;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Evidence;
|
||||
|
||||
/// <summary>
|
||||
/// SBOM lineage evidence.
|
||||
/// Tracks provenance and chain of custody.
|
||||
/// </summary>
|
||||
public sealed record SbomLineageEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// SBOM digest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sbom_digest")]
|
||||
public required string SbomDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SBOM format (e.g., "SPDX", "CycloneDX").
|
||||
/// </summary>
|
||||
[JsonPropertyName("format")]
|
||||
public required string Format { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Attestation digest (DSSE envelope).
|
||||
/// </summary>
|
||||
[JsonPropertyName("attestation_digest")]
|
||||
public string? AttestationDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of components in SBOM.
|
||||
/// </summary>
|
||||
[JsonPropertyName("component_count")]
|
||||
public required int ComponentCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this SBOM was generated (UTC).
|
||||
/// </summary>
|
||||
[JsonPropertyName("generated_at")]
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Build provenance available.
|
||||
/// </summary>
|
||||
[JsonPropertyName("has_provenance")]
|
||||
public required bool HasProvenance { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Evidence;
|
||||
|
||||
/// <summary>
|
||||
/// VEX (Vulnerability Exploitability eXchange) claim summary.
|
||||
/// </summary>
|
||||
public sealed record VexClaimSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// VEX status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; } // "affected", "not_affected", "fixed", "under_investigation"
|
||||
|
||||
/// <summary>
|
||||
/// Confidence in this claim [0.0, 1.0].
|
||||
/// Weighted average if multiple sources.
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of VEX statements supporting this claim.
|
||||
/// </summary>
|
||||
[JsonPropertyName("statement_count")]
|
||||
public required int StatementCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this summary was computed (UTC).
|
||||
/// </summary>
|
||||
[JsonPropertyName("computed_at")]
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Justification text (if provided).
|
||||
/// </summary>
|
||||
[JsonPropertyName("justification")]
|
||||
public string? Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Convenience property indicating if the VEX status is "not_affected".
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsNotAffected => string.Equals(Status, "not_affected", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Issuer trust level [0.0, 1.0].
|
||||
/// Alias for <see cref="Confidence"/> for API compatibility.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public double IssuerTrust => Confidence;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
global using System;
|
||||
global using System.Collections.Generic;
|
||||
global using System.Collections.Immutable;
|
||||
global using System.Linq;
|
||||
global using System.Threading;
|
||||
global using System.Threading.Tasks;
|
||||
global using Microsoft.Extensions.Logging;
|
||||
global using Microsoft.Extensions.Options;
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Context for determinization evaluation.
|
||||
/// Contains environment, criticality, policy settings, and computed evidence data.
|
||||
/// </summary>
|
||||
public sealed record DeterminizationContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Deployment environment.
|
||||
/// </summary>
|
||||
[JsonPropertyName("environment")]
|
||||
public required DeploymentEnvironment Environment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Asset criticality level.
|
||||
/// </summary>
|
||||
[JsonPropertyName("criticality")]
|
||||
public AssetCriticality Criticality { get; init; } = AssetCriticality.Medium;
|
||||
|
||||
/// <summary>
|
||||
/// Entropy threshold for this context.
|
||||
/// Observations above this trigger guardrails.
|
||||
/// </summary>
|
||||
[JsonPropertyName("entropy_threshold")]
|
||||
public double EntropyThreshold { get; init; } = 0.5;
|
||||
|
||||
/// <summary>
|
||||
/// Decay threshold for this context.
|
||||
/// Observations below this are considered stale.
|
||||
/// </summary>
|
||||
[JsonPropertyName("decay_threshold")]
|
||||
public double DecayThreshold { get; init; } = 0.5;
|
||||
|
||||
/// <summary>
|
||||
/// Signal snapshot containing evidence from various sources.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signal_snapshot")]
|
||||
public required SignalSnapshot SignalSnapshot { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Calculated uncertainty score for this context.
|
||||
/// </summary>
|
||||
[JsonPropertyName("uncertainty_score")]
|
||||
public required UncertaintyScore UncertaintyScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Observation decay information.
|
||||
/// </summary>
|
||||
[JsonPropertyName("decay")]
|
||||
public required ObservationDecay Decay { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated trust score (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("trust_score")]
|
||||
public required double TrustScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates context with default production settings and placeholder signal data.
|
||||
/// </summary>
|
||||
public static DeterminizationContext Production(
|
||||
SignalSnapshot? snapshot = null,
|
||||
UncertaintyScore? uncertaintyScore = null,
|
||||
ObservationDecay? decay = null,
|
||||
double trustScore = 0.5)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return new()
|
||||
{
|
||||
Environment = DeploymentEnvironment.Production,
|
||||
Criticality = AssetCriticality.High,
|
||||
EntropyThreshold = 0.4,
|
||||
DecayThreshold = 0.50,
|
||||
SignalSnapshot = snapshot ?? SignalSnapshot.Empty("CVE-0000-0000", "pkg:unknown/unknown@0.0.0", now),
|
||||
UncertaintyScore = uncertaintyScore ?? UncertaintyScore.Zero(1.0, now),
|
||||
Decay = decay ?? ObservationDecay.Fresh(now),
|
||||
TrustScore = trustScore
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates context with relaxed development settings and placeholder signal data.
|
||||
/// </summary>
|
||||
public static DeterminizationContext Development(
|
||||
SignalSnapshot? snapshot = null,
|
||||
UncertaintyScore? uncertaintyScore = null,
|
||||
ObservationDecay? decay = null,
|
||||
double trustScore = 0.5)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return new()
|
||||
{
|
||||
Environment = DeploymentEnvironment.Development,
|
||||
Criticality = AssetCriticality.Low,
|
||||
EntropyThreshold = 0.6,
|
||||
DecayThreshold = 0.35,
|
||||
SignalSnapshot = snapshot ?? SignalSnapshot.Empty("CVE-0000-0000", "pkg:unknown/unknown@0.0.0", now),
|
||||
UncertaintyScore = uncertaintyScore ?? UncertaintyScore.Zero(1.0, now),
|
||||
Decay = decay ?? ObservationDecay.Fresh(now),
|
||||
TrustScore = trustScore
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates context with custom thresholds and placeholder signal data.
|
||||
/// </summary>
|
||||
public static DeterminizationContext Create(
|
||||
DeploymentEnvironment environment,
|
||||
AssetCriticality criticality,
|
||||
double entropyThreshold,
|
||||
double decayThreshold,
|
||||
SignalSnapshot? snapshot = null,
|
||||
UncertaintyScore? uncertaintyScore = null,
|
||||
ObservationDecay? decay = null,
|
||||
double trustScore = 0.5)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return new()
|
||||
{
|
||||
Environment = environment,
|
||||
Criticality = criticality,
|
||||
EntropyThreshold = entropyThreshold,
|
||||
DecayThreshold = decayThreshold,
|
||||
SignalSnapshot = snapshot ?? SignalSnapshot.Empty("CVE-0000-0000", "pkg:unknown/unknown@0.0.0", now),
|
||||
UncertaintyScore = uncertaintyScore ?? UncertaintyScore.Zero(1.0, now),
|
||||
Decay = decay ?? ObservationDecay.Fresh(now),
|
||||
TrustScore = trustScore
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Result of determinization evaluation.
|
||||
/// Combines observation state, uncertainty score, and guardrails.
|
||||
/// </summary>
|
||||
public sealed record DeterminizationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Resulting observation state.
|
||||
/// </summary>
|
||||
[JsonPropertyName("state")]
|
||||
public required ObservationState State { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Uncertainty score at evaluation time.
|
||||
/// </summary>
|
||||
[JsonPropertyName("uncertainty")]
|
||||
public required UncertaintyScore Uncertainty { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Decay status at evaluation time.
|
||||
/// </summary>
|
||||
[JsonPropertyName("decay")]
|
||||
public required ObservationDecay Decay { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Applied guardrails (if any).
|
||||
/// </summary>
|
||||
[JsonPropertyName("guardrails")]
|
||||
public GuardRails? Guardrails { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evaluation context.
|
||||
/// </summary>
|
||||
[JsonPropertyName("context")]
|
||||
public required DeterminizationContext Context { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this result was computed (UTC).
|
||||
/// </summary>
|
||||
[JsonPropertyName("evaluated_at")]
|
||||
public required DateTimeOffset EvaluatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Decision rationale.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rationale")]
|
||||
public string? Rationale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates result for determined observation (low uncertainty).
|
||||
/// </summary>
|
||||
public static DeterminizationResult Determined(
|
||||
UncertaintyScore uncertainty,
|
||||
ObservationDecay decay,
|
||||
DeterminizationContext context,
|
||||
DateTimeOffset evaluatedAt) => new()
|
||||
{
|
||||
State = ObservationState.Determined,
|
||||
Uncertainty = uncertainty,
|
||||
Decay = decay,
|
||||
Guardrails = GuardRails.None(),
|
||||
Context = context,
|
||||
EvaluatedAt = evaluatedAt,
|
||||
Rationale = "Evidence sufficient for confident determination"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates result for pending observation (high uncertainty).
|
||||
/// </summary>
|
||||
public static DeterminizationResult Pending(
|
||||
UncertaintyScore uncertainty,
|
||||
ObservationDecay decay,
|
||||
GuardRails guardrails,
|
||||
DeterminizationContext context,
|
||||
DateTimeOffset evaluatedAt) => new()
|
||||
{
|
||||
State = ObservationState.PendingDeterminization,
|
||||
Uncertainty = uncertainty,
|
||||
Decay = decay,
|
||||
Guardrails = guardrails,
|
||||
Context = context,
|
||||
EvaluatedAt = evaluatedAt,
|
||||
Rationale = $"Uncertainty ({uncertainty.Entropy:F2}) above threshold ({context.EntropyThreshold:F2})"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates result for stale observation requiring refresh.
|
||||
/// </summary>
|
||||
public static DeterminizationResult Stale(
|
||||
UncertaintyScore uncertainty,
|
||||
ObservationDecay decay,
|
||||
DeterminizationContext context,
|
||||
DateTimeOffset evaluatedAt) => new()
|
||||
{
|
||||
State = ObservationState.StaleRequiresRefresh,
|
||||
Uncertainty = uncertainty,
|
||||
Decay = decay,
|
||||
Guardrails = GuardRails.Strict(),
|
||||
Context = context,
|
||||
EvaluatedAt = evaluatedAt,
|
||||
Rationale = $"Evidence decayed below threshold ({context.DecayThreshold:F2})"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates result for disputed observation (conflicting signals).
|
||||
/// </summary>
|
||||
public static DeterminizationResult Disputed(
|
||||
UncertaintyScore uncertainty,
|
||||
ObservationDecay decay,
|
||||
DeterminizationContext context,
|
||||
DateTimeOffset evaluatedAt,
|
||||
string reason) => new()
|
||||
{
|
||||
State = ObservationState.Disputed,
|
||||
Uncertainty = uncertainty,
|
||||
Decay = decay,
|
||||
Guardrails = GuardRails.Strict(),
|
||||
Context = context,
|
||||
EvaluatedAt = evaluatedAt,
|
||||
Rationale = $"Conflicting signals detected: {reason}"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Guardrails policy configuration for uncertain observations.
|
||||
/// Defines monitoring/restrictions when evidence is incomplete.
|
||||
/// </summary>
|
||||
public sealed record GuardRails
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable runtime monitoring.
|
||||
/// </summary>
|
||||
[JsonPropertyName("enable_monitoring")]
|
||||
public required bool EnableMonitoring { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Restrict deployment to non-production environments.
|
||||
/// </summary>
|
||||
[JsonPropertyName("restrict_to_non_prod")]
|
||||
public required bool RestrictToNonProd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Require manual approval before deployment.
|
||||
/// </summary>
|
||||
[JsonPropertyName("require_approval")]
|
||||
public required bool RequireApproval { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Schedule automatic re-evaluation after this duration.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reeval_after")]
|
||||
public TimeSpan? ReevalAfter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional notes/rationale for guardrails.
|
||||
/// </summary>
|
||||
[JsonPropertyName("notes")]
|
||||
public string? Notes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Default guardrails instance with safe settings.
|
||||
/// </summary>
|
||||
public static GuardRails Default { get; } = new()
|
||||
{
|
||||
EnableMonitoring = true,
|
||||
RestrictToNonProd = false,
|
||||
RequireApproval = false,
|
||||
ReevalAfter = TimeSpan.FromDays(7),
|
||||
Notes = null
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates GuardRails with default safe settings.
|
||||
/// </summary>
|
||||
public static GuardRails CreateDefault() => new()
|
||||
{
|
||||
EnableMonitoring = true,
|
||||
RestrictToNonProd = false,
|
||||
RequireApproval = false,
|
||||
ReevalAfter = TimeSpan.FromDays(7),
|
||||
Notes = null
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates GuardRails for high-uncertainty observations.
|
||||
/// </summary>
|
||||
public static GuardRails Strict() => new()
|
||||
{
|
||||
EnableMonitoring = true,
|
||||
RestrictToNonProd = true,
|
||||
RequireApproval = true,
|
||||
ReevalAfter = TimeSpan.FromDays(3),
|
||||
Notes = "High uncertainty - strict guardrails applied"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates GuardRails with no restrictions (all evidence present).
|
||||
/// </summary>
|
||||
public static GuardRails None() => new()
|
||||
{
|
||||
EnableMonitoring = false,
|
||||
RestrictToNonProd = false,
|
||||
RequireApproval = false,
|
||||
ReevalAfter = null,
|
||||
Notes = null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deployment environment classification.
|
||||
/// </summary>
|
||||
public enum DeploymentEnvironment
|
||||
{
|
||||
/// <summary>Development environment.</summary>
|
||||
Development = 0,
|
||||
|
||||
/// <summary>Testing environment.</summary>
|
||||
Testing = 1,
|
||||
|
||||
/// <summary>Staging/pre-production environment.</summary>
|
||||
Staging = 2,
|
||||
|
||||
/// <summary>Production environment.</summary>
|
||||
Production = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asset criticality classification.
|
||||
/// </summary>
|
||||
public enum AssetCriticality
|
||||
{
|
||||
/// <summary>Low criticality - minimal impact if compromised.</summary>
|
||||
Low = 0,
|
||||
|
||||
/// <summary>Medium criticality - moderate impact.</summary>
|
||||
Medium = 1,
|
||||
|
||||
/// <summary>High criticality - significant impact.</summary>
|
||||
High = 2,
|
||||
|
||||
/// <summary>Critical - severe impact if compromised.</summary>
|
||||
Critical = 3
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Per-observation decay configuration and computed state.
|
||||
/// Tracks evidence staleness with configurable half-life.
|
||||
/// Formula: decayed = max(floor, exp(-ln(2) * age_days / half_life_days))
|
||||
/// </summary>
|
||||
public sealed record ObservationDecay
|
||||
{
|
||||
/// <summary>
|
||||
/// When the observation was first recorded (UTC).
|
||||
/// </summary>
|
||||
[JsonPropertyName("observed_at")]
|
||||
public DateTimeOffset ObservedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// When the observation was last refreshed (UTC).
|
||||
/// </summary>
|
||||
[JsonPropertyName("refreshed_at")]
|
||||
public DateTimeOffset RefreshedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Half-life in days.
|
||||
/// Default: 14 days.
|
||||
/// </summary>
|
||||
[JsonPropertyName("half_life_days")]
|
||||
public double HalfLifeDays { get; init; } = 14.0;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum confidence floor.
|
||||
/// Default: 0.35 (consistent with FreshnessCalculator).
|
||||
/// </summary>
|
||||
[JsonPropertyName("floor")]
|
||||
public double Floor { get; init; } = 0.35;
|
||||
|
||||
/// <summary>
|
||||
/// Staleness threshold (0.0-1.0).
|
||||
/// If decay multiplier drops below this, observation becomes stale.
|
||||
/// Default: 0.50
|
||||
/// </summary>
|
||||
[JsonPropertyName("staleness_threshold")]
|
||||
public double StalenessThreshold { get; init; } = 0.50;
|
||||
|
||||
/// <summary>
|
||||
/// Last signal update time (alias for RefreshedAt for convenience).
|
||||
/// </summary>
|
||||
[JsonPropertyName("last_signal_update")]
|
||||
public DateTimeOffset LastSignalUpdate { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Age in days since last refresh.
|
||||
/// </summary>
|
||||
[JsonPropertyName("age_days")]
|
||||
public double AgeDays { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Pre-computed decay multiplier (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("decayed_multiplier")]
|
||||
public double DecayedMultiplier { get; init; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the observation is considered stale.
|
||||
/// </summary>
|
||||
[JsonPropertyName("is_stale")]
|
||||
public bool IsStale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the current decay multiplier.
|
||||
/// </summary>
|
||||
public double CalculateDecay(DateTimeOffset now)
|
||||
{
|
||||
var ageDays = (now - RefreshedAt).TotalDays;
|
||||
if (ageDays <= 0)
|
||||
return 1.0;
|
||||
|
||||
var decay = Math.Exp(-Math.Log(2) * ageDays / HalfLifeDays);
|
||||
return Math.Max(Floor, decay);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the observation is stale (decay below threshold).
|
||||
/// </summary>
|
||||
public bool CheckIsStale(DateTimeOffset now) =>
|
||||
CalculateDecay(now) < StalenessThreshold;
|
||||
|
||||
/// <summary>
|
||||
/// Creates ObservationDecay with default settings.
|
||||
/// </summary>
|
||||
public static ObservationDecay Create(DateTimeOffset observedAt, DateTimeOffset? refreshedAt = null) => new()
|
||||
{
|
||||
ObservedAt = observedAt,
|
||||
RefreshedAt = refreshedAt ?? observedAt,
|
||||
HalfLifeDays = 14.0,
|
||||
Floor = 0.35,
|
||||
StalenessThreshold = 0.50
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a fresh observation (just recorded).
|
||||
/// </summary>
|
||||
public static ObservationDecay Fresh(DateTimeOffset now) =>
|
||||
Create(now, now);
|
||||
|
||||
/// <summary>
|
||||
/// Creates ObservationDecay with custom settings.
|
||||
/// </summary>
|
||||
public static ObservationDecay WithSettings(
|
||||
DateTimeOffset observedAt,
|
||||
DateTimeOffset refreshedAt,
|
||||
double halfLifeDays,
|
||||
double floor,
|
||||
double stalenessThreshold) => new()
|
||||
{
|
||||
ObservedAt = observedAt,
|
||||
RefreshedAt = refreshedAt,
|
||||
HalfLifeDays = halfLifeDays,
|
||||
Floor = floor,
|
||||
StalenessThreshold = stalenessThreshold
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace StellaOps.Policy.Determinization.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Observation state for CVE tracking, independent of VEX status.
|
||||
/// Allows a CVE to be "Affected" (VEX) but "PendingDeterminization" (observation).
|
||||
/// </summary>
|
||||
public enum ObservationState
|
||||
{
|
||||
/// <summary>
|
||||
/// Initial state: CVE discovered but evidence incomplete.
|
||||
/// Triggers guardrail-based policy evaluation.
|
||||
/// </summary>
|
||||
PendingDeterminization = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Evidence sufficient for confident determination.
|
||||
/// Normal policy evaluation applies.
|
||||
/// </summary>
|
||||
Determined = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Multiple signals conflict (K4 Conflict state).
|
||||
/// Requires human review regardless of confidence.
|
||||
/// </summary>
|
||||
Disputed = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Evidence decayed below threshold; needs refresh.
|
||||
/// Auto-triggered when decay > threshold.
|
||||
/// </summary>
|
||||
StaleRequiresRefresh = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Manually flagged for review.
|
||||
/// Bypasses automatic determinization.
|
||||
/// </summary>
|
||||
ManualReviewRequired = 4,
|
||||
|
||||
/// <summary>
|
||||
/// CVE suppressed/ignored by policy exception.
|
||||
/// Evidence tracking continues but decisions skip.
|
||||
/// </summary>
|
||||
Suppressed = 5
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Describes a missing signal that contributes to uncertainty.
|
||||
/// </summary>
|
||||
public sealed record SignalGap
|
||||
{
|
||||
/// <summary>
|
||||
/// Signal name (e.g., "epss", "vex", "reachability").
|
||||
/// </summary>
|
||||
[JsonPropertyName("signal")]
|
||||
public required string Signal { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason the signal is missing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reason")]
|
||||
public required SignalGapReason Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Prior assumption used in absence of signal.
|
||||
/// </summary>
|
||||
[JsonPropertyName("prior")]
|
||||
public double? Prior { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Weight this signal contributes to total uncertainty.
|
||||
/// </summary>
|
||||
[JsonPropertyName("weight")]
|
||||
public double Weight { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reason a signal is missing.
|
||||
/// </summary>
|
||||
public enum SignalGapReason
|
||||
{
|
||||
/// <summary>Signal not yet queried.</summary>
|
||||
NotQueried,
|
||||
|
||||
/// <summary>Signal legitimately does not exist (e.g., EPSS not published yet).</summary>
|
||||
NotAvailable,
|
||||
|
||||
/// <summary>Signal query failed due to external error.</summary>
|
||||
QueryFailed,
|
||||
|
||||
/// <summary>Signal not applicable for this artifact type.</summary>
|
||||
NotApplicable
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace StellaOps.Policy.Determinization.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Query status for a signal.
|
||||
/// Distinguishes between "not yet queried", "queried with result", and "query failed".
|
||||
/// </summary>
|
||||
public enum SignalQueryStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Signal has not been queried yet.
|
||||
/// Default state before any lookup attempt.
|
||||
/// </summary>
|
||||
NotQueried = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Signal query succeeded.
|
||||
/// Value may be present or null (signal legitimately absent).
|
||||
/// </summary>
|
||||
Queried = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Signal query failed due to error (network, API timeout, etc.).
|
||||
/// Value is null but reason is external failure, not absence.
|
||||
/// </summary>
|
||||
Failed = 2
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Policy.Determinization.Evidence;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Point-in-time snapshot of all signals for a CVE observation.
|
||||
/// Used as input to uncertainty scoring.
|
||||
/// </summary>
|
||||
public sealed record SignalSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// CVE identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("cve")]
|
||||
public required string Cve { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component PURL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("purl")]
|
||||
public required string Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EPSS signal.
|
||||
/// </summary>
|
||||
[JsonPropertyName("epss")]
|
||||
public required SignalState<EpssEvidence> Epss { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX signal.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vex")]
|
||||
public required SignalState<VexClaimSummary> Vex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability signal.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reachability")]
|
||||
public required SignalState<ReachabilityEvidence> Reachability { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Runtime signal.
|
||||
/// </summary>
|
||||
[JsonPropertyName("runtime")]
|
||||
public required SignalState<RuntimeEvidence> Runtime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Backport signal.
|
||||
/// </summary>
|
||||
[JsonPropertyName("backport")]
|
||||
public required SignalState<BackportEvidence> Backport { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SBOM lineage signal.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sbom")]
|
||||
public required SignalState<SbomLineageEvidence> Sbom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVSS signal.
|
||||
/// </summary>
|
||||
[JsonPropertyName("cvss")]
|
||||
public required SignalState<CvssEvidence> Cvss { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this snapshot was captured (UTC).
|
||||
/// </summary>
|
||||
[JsonPropertyName("snapshot_at")]
|
||||
public required DateTimeOffset SnapshotAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty snapshot with all signals NotQueried.
|
||||
/// </summary>
|
||||
public static SignalSnapshot Empty(string cve, string purl, DateTimeOffset snapshotAt) => new()
|
||||
{
|
||||
Cve = cve,
|
||||
Purl = purl,
|
||||
Epss = SignalState<EpssEvidence>.NotQueried(),
|
||||
Vex = SignalState<VexClaimSummary>.NotQueried(),
|
||||
Reachability = SignalState<ReachabilityEvidence>.NotQueried(),
|
||||
Runtime = SignalState<RuntimeEvidence>.NotQueried(),
|
||||
Backport = SignalState<BackportEvidence>.NotQueried(),
|
||||
Sbom = SignalState<SbomLineageEvidence>.NotQueried(),
|
||||
Cvss = SignalState<CvssEvidence>.NotQueried(),
|
||||
SnapshotAt = snapshotAt
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a signal value with query status metadata.
|
||||
/// Distinguishes between: not queried, queried with value, queried but absent, query failed.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The signal value type.</typeparam>
|
||||
public sealed record SignalState<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// Query status for this signal.
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public required SignalQueryStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signal value, if queried and present.
|
||||
/// Null can mean: not queried, legitimately absent, or query failed.
|
||||
/// Check Status to disambiguate.
|
||||
/// </summary>
|
||||
[JsonPropertyName("value")]
|
||||
public T? Value { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this signal was last queried (UTC).
|
||||
/// Null if never queried.
|
||||
/// </summary>
|
||||
[JsonPropertyName("queried_at")]
|
||||
public DateTimeOffset? QueriedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if Status == Failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a SignalState in NotQueried status.
|
||||
/// </summary>
|
||||
public static SignalState<T> NotQueried() => new()
|
||||
{
|
||||
Status = SignalQueryStatus.NotQueried,
|
||||
Value = default,
|
||||
QueriedAt = null,
|
||||
Error = null
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a SignalState with a successful query result.
|
||||
/// Value may be null if the signal legitimately does not exist.
|
||||
/// </summary>
|
||||
public static SignalState<T> Queried(T? value, DateTimeOffset queriedAt) => new()
|
||||
{
|
||||
Status = SignalQueryStatus.Queried,
|
||||
Value = value,
|
||||
QueriedAt = queriedAt,
|
||||
Error = null
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a SignalState representing a failed query.
|
||||
/// </summary>
|
||||
public static SignalState<T> Failed(string error, DateTimeOffset attemptedAt) => new()
|
||||
{
|
||||
Status = SignalQueryStatus.Failed,
|
||||
Value = default,
|
||||
QueriedAt = attemptedAt,
|
||||
Error = error
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the signal was queried and has a non-null value.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool HasValue => Status == SignalQueryStatus.Queried && Value is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the signal query failed.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsFailed => Status == SignalQueryStatus.Failed;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the signal has not been queried yet.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsNotQueried => Status == SignalQueryStatus.NotQueried;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Uncertainty tier classification based on entropy score.
|
||||
/// </summary>
|
||||
public enum UncertaintyTier
|
||||
{
|
||||
/// <summary>
|
||||
/// Very high confidence (entropy < 0.2).
|
||||
/// All or most key signals present and consistent.
|
||||
/// </summary>
|
||||
Minimal = 0,
|
||||
|
||||
/// <summary>
|
||||
/// High confidence (entropy 0.2-0.4).
|
||||
/// Most key signals present.
|
||||
/// </summary>
|
||||
Low = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Moderate confidence (entropy 0.4-0.6).
|
||||
/// Some signals missing or conflicting.
|
||||
/// </summary>
|
||||
Moderate = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Low confidence (entropy 0.6-0.8).
|
||||
/// Many signals missing or conflicting.
|
||||
/// </summary>
|
||||
High = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Very low confidence (entropy >= 0.8).
|
||||
/// Critical signals missing or heavily conflicting.
|
||||
/// </summary>
|
||||
Critical = 4
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quantifies knowledge completeness (not code entropy).
|
||||
/// Calculated from signal presence/absence weighted by importance.
|
||||
/// Formula: entropy = 1 - (sum of weighted present signals / max possible weight)
|
||||
/// </summary>
|
||||
public sealed record UncertaintyScore
|
||||
{
|
||||
/// <summary>
|
||||
/// Entropy value [0.0, 1.0].
|
||||
/// 0 = complete knowledge, 1 = complete uncertainty.
|
||||
/// </summary>
|
||||
[JsonPropertyName("entropy")]
|
||||
public required double Entropy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Uncertainty tier derived from entropy.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tier")]
|
||||
public required UncertaintyTier Tier { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Missing signals contributing to uncertainty.
|
||||
/// </summary>
|
||||
[JsonPropertyName("gaps")]
|
||||
public required IReadOnlyList<SignalGap> Gaps { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total weight of present signals.
|
||||
/// </summary>
|
||||
[JsonPropertyName("present_weight")]
|
||||
public required double PresentWeight { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum possible weight (sum of all signal weights).
|
||||
/// </summary>
|
||||
[JsonPropertyName("max_weight")]
|
||||
public required double MaxWeight { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this score was calculated (UTC).
|
||||
/// </summary>
|
||||
[JsonPropertyName("calculated_at")]
|
||||
public required DateTimeOffset CalculatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Completeness ratio (present_weight / max_weight).
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public double Completeness => MaxWeight > 0 ? PresentWeight / MaxWeight : 0.0;
|
||||
|
||||
/// <summary>
|
||||
/// Creates an UncertaintyScore with calculated tier.
|
||||
/// </summary>
|
||||
public static UncertaintyScore Create(
|
||||
double entropy,
|
||||
IReadOnlyList<SignalGap> gaps,
|
||||
double presentWeight,
|
||||
double maxWeight,
|
||||
DateTimeOffset calculatedAt)
|
||||
{
|
||||
if (entropy < 0.0 || entropy > 1.0)
|
||||
throw new ArgumentOutOfRangeException(nameof(entropy), "Entropy must be in [0.0, 1.0]");
|
||||
|
||||
var tier = entropy switch
|
||||
{
|
||||
< 0.2 => UncertaintyTier.Minimal,
|
||||
< 0.4 => UncertaintyTier.Low,
|
||||
< 0.6 => UncertaintyTier.Moderate,
|
||||
< 0.8 => UncertaintyTier.High,
|
||||
_ => UncertaintyTier.Critical
|
||||
};
|
||||
|
||||
return new UncertaintyScore
|
||||
{
|
||||
Entropy = entropy,
|
||||
Tier = tier,
|
||||
Gaps = gaps,
|
||||
PresentWeight = presentWeight,
|
||||
MaxWeight = maxWeight,
|
||||
CalculatedAt = calculatedAt
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a zero-entropy score (complete knowledge).
|
||||
/// </summary>
|
||||
public static UncertaintyScore Zero(double maxWeight, DateTimeOffset calculatedAt) =>
|
||||
Create(0.0, Array.Empty<SignalGap>(), maxWeight, maxWeight, calculatedAt);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Calculates decayed confidence scores using exponential half-life decay.
|
||||
/// </summary>
|
||||
public sealed class DecayedConfidenceCalculator : IDecayedConfidenceCalculator
|
||||
{
|
||||
private static readonly Meter Meter = new("StellaOps.Policy.Determinization");
|
||||
private static readonly Histogram<double> DecayMultiplierHistogram = Meter.CreateHistogram<double>(
|
||||
"stellaops_determinization_decay_multiplier",
|
||||
unit: "ratio",
|
||||
description: "Confidence decay multiplier based on observation age and half-life");
|
||||
|
||||
private readonly ILogger<DecayedConfidenceCalculator> _logger;
|
||||
|
||||
public DecayedConfidenceCalculator(ILogger<DecayedConfidenceCalculator> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public double Calculate(
|
||||
double baseConfidence,
|
||||
double ageDays,
|
||||
double halfLifeDays = 14.0,
|
||||
double floor = 0.1)
|
||||
{
|
||||
if (baseConfidence < 0.0 || baseConfidence > 1.0)
|
||||
throw new ArgumentOutOfRangeException(nameof(baseConfidence), "Must be between 0.0 and 1.0");
|
||||
|
||||
if (ageDays < 0.0)
|
||||
throw new ArgumentOutOfRangeException(nameof(ageDays), "Cannot be negative");
|
||||
|
||||
if (halfLifeDays <= 0.0)
|
||||
throw new ArgumentOutOfRangeException(nameof(halfLifeDays), "Must be positive");
|
||||
|
||||
if (floor < 0.0 || floor > 1.0)
|
||||
throw new ArgumentOutOfRangeException(nameof(floor), "Must be between 0.0 and 1.0");
|
||||
|
||||
var decayFactor = CalculateDecayFactor(ageDays, halfLifeDays);
|
||||
var decayed = baseConfidence * decayFactor;
|
||||
var result = Math.Max(floor, decayed);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Decayed confidence from {Base:F4} to {Result:F4} (age={AgeDays:F2}d, half-life={HalfLife:F2}d, floor={Floor:F2})",
|
||||
baseConfidence,
|
||||
result,
|
||||
ageDays,
|
||||
halfLifeDays,
|
||||
floor);
|
||||
|
||||
// Emit metric for decay multiplier (factor before floor is applied)
|
||||
DecayMultiplierHistogram.Record(decayFactor,
|
||||
new KeyValuePair<string, object?>("half_life_days", halfLifeDays),
|
||||
new KeyValuePair<string, object?>("age_days", ageDays));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public double CalculateDecayFactor(double ageDays, double halfLifeDays = 14.0)
|
||||
{
|
||||
if (ageDays < 0.0)
|
||||
throw new ArgumentOutOfRangeException(nameof(ageDays), "Cannot be negative");
|
||||
|
||||
if (halfLifeDays <= 0.0)
|
||||
throw new ArgumentOutOfRangeException(nameof(halfLifeDays), "Must be positive");
|
||||
|
||||
// Formula: exp(-ln(2) * age_days / half_life_days)
|
||||
var exponent = -Math.Log(2.0) * ageDays / halfLifeDays;
|
||||
var factor = Math.Exp(exponent);
|
||||
|
||||
return Math.Clamp(factor, 0.0, 1.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace StellaOps.Policy.Determinization.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Calculates decayed confidence scores using exponential half-life decay.
|
||||
/// </summary>
|
||||
public interface IDecayedConfidenceCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculate decayed confidence from observation age.
|
||||
/// Formula: decayed = max(floor, exp(-ln(2) * age_days / half_life_days))
|
||||
/// </summary>
|
||||
/// <param name="baseConfidence">Original confidence score (0.0-1.0)</param>
|
||||
/// <param name="ageDays">Age of observation in days</param>
|
||||
/// <param name="halfLifeDays">Half-life period (default: 14 days)</param>
|
||||
/// <param name="floor">Minimum confidence floor (default: 0.1)</param>
|
||||
/// <returns>Decayed confidence score</returns>
|
||||
double Calculate(
|
||||
double baseConfidence,
|
||||
double ageDays,
|
||||
double halfLifeDays = 14.0,
|
||||
double floor = 0.1);
|
||||
|
||||
/// <summary>
|
||||
/// Calculate decay factor only (without applying to base confidence).
|
||||
/// </summary>
|
||||
double CalculateDecayFactor(double ageDays, double halfLifeDays = 14.0);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using StellaOps.Policy.Determinization.Models;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Calculates uncertainty scores based on signal completeness (entropy).
|
||||
/// </summary>
|
||||
public interface IUncertaintyScoreCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculate uncertainty score from a signal snapshot.
|
||||
/// Formula: entropy = 1 - (weighted_present_signals / max_possible_weight)
|
||||
/// </summary>
|
||||
/// <param name="snapshot">Signal snapshot containing presence indicators</param>
|
||||
/// <param name="weights">Signal weights (optional, uses defaults if null)</param>
|
||||
/// <returns>Uncertainty score with tier classification</returns>
|
||||
UncertaintyScore Calculate(SignalSnapshot snapshot, SignalWeights? weights = null);
|
||||
|
||||
/// <summary>
|
||||
/// Calculate raw entropy value (0.0 = complete knowledge, 1.0 = no knowledge).
|
||||
/// </summary>
|
||||
double CalculateEntropy(SignalSnapshot snapshot, SignalWeights? weights = null);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace StellaOps.Policy.Determinization.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Prior distribution for missing signals (Bayesian approach).
|
||||
/// </summary>
|
||||
public sealed record PriorDistribution
|
||||
{
|
||||
/// <summary>Conservative prior: assume affected until proven otherwise.</summary>
|
||||
public static readonly PriorDistribution Conservative = new()
|
||||
{
|
||||
AffectedProbability = 0.70,
|
||||
NotAffectedProbability = 0.20,
|
||||
UnknownProbability = 0.10
|
||||
};
|
||||
|
||||
/// <summary>Neutral prior: equal weighting for affected/not-affected.</summary>
|
||||
public static readonly PriorDistribution Neutral = new()
|
||||
{
|
||||
AffectedProbability = 0.40,
|
||||
NotAffectedProbability = 0.40,
|
||||
UnknownProbability = 0.20
|
||||
};
|
||||
|
||||
/// <summary>Probability of "Affected" status (default: 0.70 conservative).</summary>
|
||||
public required double AffectedProbability { get; init; }
|
||||
|
||||
/// <summary>Probability of "Not Affected" status (default: 0.20).</summary>
|
||||
public required double NotAffectedProbability { get; init; }
|
||||
|
||||
/// <summary>Probability of "Unknown" status (default: 0.10).</summary>
|
||||
public required double UnknownProbability { get; init; }
|
||||
|
||||
/// <summary>Sum of all probabilities (should equal 1.0).</summary>
|
||||
public double Total =>
|
||||
AffectedProbability + NotAffectedProbability + UnknownProbability;
|
||||
|
||||
/// <summary>Validates that probabilities sum to approximately 1.0.</summary>
|
||||
public bool IsNormalized(double tolerance = 0.001) =>
|
||||
Math.Abs(Total - 1.0) < tolerance;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace StellaOps.Policy.Determinization.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Configurable signal weights for entropy calculation.
|
||||
/// </summary>
|
||||
public sealed record SignalWeights
|
||||
{
|
||||
/// <summary>Default weights following advisory recommendations.</summary>
|
||||
public static readonly SignalWeights Default = new()
|
||||
{
|
||||
VexWeight = 0.25,
|
||||
EpssWeight = 0.15,
|
||||
ReachabilityWeight = 0.25,
|
||||
RuntimeWeight = 0.15,
|
||||
BackportWeight = 0.10,
|
||||
SbomLineageWeight = 0.10
|
||||
};
|
||||
|
||||
/// <summary>Weight for VEX claim signals (default: 0.25).</summary>
|
||||
public required double VexWeight { get; init; }
|
||||
|
||||
/// <summary>Weight for EPSS signals (default: 0.15).</summary>
|
||||
public required double EpssWeight { get; init; }
|
||||
|
||||
/// <summary>Weight for Reachability signals (default: 0.25).</summary>
|
||||
public required double ReachabilityWeight { get; init; }
|
||||
|
||||
/// <summary>Weight for Runtime detection signals (default: 0.15).</summary>
|
||||
public required double RuntimeWeight { get; init; }
|
||||
|
||||
/// <summary>Weight for Backport evidence signals (default: 0.10).</summary>
|
||||
public required double BackportWeight { get; init; }
|
||||
|
||||
/// <summary>Weight for SBOM lineage signals (default: 0.10).</summary>
|
||||
public required double SbomLineageWeight { get; init; }
|
||||
|
||||
/// <summary>Sum of all weights (should equal 1.0 for normalized calculations).</summary>
|
||||
public double TotalWeight =>
|
||||
VexWeight + EpssWeight + ReachabilityWeight +
|
||||
RuntimeWeight + BackportWeight + SbomLineageWeight;
|
||||
|
||||
/// <summary>Validates that weights sum to approximately 1.0.</summary>
|
||||
public bool IsNormalized(double tolerance = 0.001) =>
|
||||
Math.Abs(TotalWeight - 1.0) < tolerance;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using StellaOps.Policy.Determinization.Evidence;
|
||||
using StellaOps.Policy.Determinization.Models;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregates individual signal scores into a final trust/confidence score.
|
||||
/// </summary>
|
||||
public sealed class TrustScoreAggregator
|
||||
{
|
||||
private readonly ILogger<TrustScoreAggregator> _logger;
|
||||
|
||||
public TrustScoreAggregator(ILogger<TrustScoreAggregator> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate signal scores using weighted average with uncertainty penalty.
|
||||
/// </summary>
|
||||
/// <param name="snapshot">Signal snapshot with all available signals</param>
|
||||
/// <param name="uncertaintyScore">Uncertainty score from entropy calculation</param>
|
||||
/// <param name="weights">Signal weights (optional)</param>
|
||||
/// <returns>Aggregated trust score (0.0-1.0)</returns>
|
||||
public double Aggregate(
|
||||
SignalSnapshot snapshot,
|
||||
UncertaintyScore uncertaintyScore,
|
||||
SignalWeights? weights = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
ArgumentNullException.ThrowIfNull(uncertaintyScore);
|
||||
|
||||
var effectiveWeights = weights ?? SignalWeights.Default;
|
||||
|
||||
// Calculate weighted sum of present signals
|
||||
var weightedSum = 0.0;
|
||||
var totalWeight = 0.0;
|
||||
var presentCount = 0;
|
||||
|
||||
if (!snapshot.Vex.IsNotQueried && snapshot.Vex.Value is not null)
|
||||
{
|
||||
var score = CalculateVexScore(snapshot.Vex.Value);
|
||||
weightedSum += score * effectiveWeights.VexWeight;
|
||||
totalWeight += effectiveWeights.VexWeight;
|
||||
presentCount++;
|
||||
}
|
||||
|
||||
if (!snapshot.Epss.IsNotQueried && snapshot.Epss.Value is not null)
|
||||
{
|
||||
var score = snapshot.Epss.Value.Epss; // EPSS score is the risk score
|
||||
weightedSum += score * effectiveWeights.EpssWeight;
|
||||
totalWeight += effectiveWeights.EpssWeight;
|
||||
presentCount++;
|
||||
}
|
||||
|
||||
if (!snapshot.Reachability.IsNotQueried && snapshot.Reachability.Value is not null)
|
||||
{
|
||||
var score = snapshot.Reachability.Value.Status == ReachabilityStatus.Reachable ? 1.0 : 0.0;
|
||||
weightedSum += score * effectiveWeights.ReachabilityWeight;
|
||||
totalWeight += effectiveWeights.ReachabilityWeight;
|
||||
presentCount++;
|
||||
}
|
||||
|
||||
if (!snapshot.Runtime.IsNotQueried && snapshot.Runtime.Value is not null)
|
||||
{
|
||||
var score = snapshot.Runtime.Value.Detected ? 1.0 : 0.0;
|
||||
weightedSum += score * effectiveWeights.RuntimeWeight;
|
||||
totalWeight += effectiveWeights.RuntimeWeight;
|
||||
presentCount++;
|
||||
}
|
||||
|
||||
if (!snapshot.Backport.IsNotQueried && snapshot.Backport.Value is not null)
|
||||
{
|
||||
var score = snapshot.Backport.Value.Detected ? 0.0 : 1.0; // Inverted: backport = lower risk
|
||||
weightedSum += score * effectiveWeights.BackportWeight;
|
||||
totalWeight += effectiveWeights.BackportWeight;
|
||||
presentCount++;
|
||||
}
|
||||
|
||||
if (!snapshot.Sbom.IsNotQueried && snapshot.Sbom.Value is not null)
|
||||
{
|
||||
// For now, just check if SBOM exists (conservative scoring)
|
||||
var score = 0.5; // Neutral score for SBOM lineage
|
||||
weightedSum += score * effectiveWeights.SbomLineageWeight;
|
||||
totalWeight += effectiveWeights.SbomLineageWeight;
|
||||
presentCount++;
|
||||
}
|
||||
|
||||
// If no signals present, return 0.5 (neutral) penalized by uncertainty
|
||||
if (totalWeight == 0.0)
|
||||
{
|
||||
_logger.LogWarning("No signals present for aggregation; returning neutral score penalized by uncertainty");
|
||||
return 0.5 * (1.0 - uncertaintyScore.Entropy);
|
||||
}
|
||||
|
||||
// Weighted average
|
||||
var baseScore = weightedSum / totalWeight;
|
||||
|
||||
// Apply uncertainty penalty: lower confidence when entropy is high
|
||||
var confidenceFactor = 1.0 - uncertaintyScore.Entropy;
|
||||
var adjustedScore = baseScore * confidenceFactor;
|
||||
|
||||
_logger.LogDebug(
|
||||
"Aggregated trust score {Score:F4} from {PresentSignals} signals (base={Base:F4}, confidence={Confidence:F4})",
|
||||
adjustedScore,
|
||||
presentCount,
|
||||
baseScore,
|
||||
confidenceFactor);
|
||||
|
||||
return Math.Clamp(adjustedScore, 0.0, 1.0);
|
||||
}
|
||||
|
||||
private static double CalculateVexScore(VexClaimSummary vex)
|
||||
{
|
||||
// Map VEX status to risk score
|
||||
return vex.Status.ToLowerInvariant() switch
|
||||
{
|
||||
"affected" => 1.0,
|
||||
"under_investigation" => 0.7,
|
||||
"not_affected" => 0.0,
|
||||
"fixed" => 0.1,
|
||||
_ => 0.5
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using StellaOps.Policy.Determinization.Models;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Calculates uncertainty scores based on signal completeness using entropy formula.
|
||||
/// </summary>
|
||||
public sealed class UncertaintyScoreCalculator : IUncertaintyScoreCalculator
|
||||
{
|
||||
private static readonly Meter Meter = new("StellaOps.Policy.Determinization");
|
||||
private static readonly Histogram<double> EntropyHistogram = Meter.CreateHistogram<double>(
|
||||
"stellaops_determinization_uncertainty_entropy",
|
||||
unit: "ratio",
|
||||
description: "Uncertainty entropy score (0.0 = complete knowledge, 1.0 = no knowledge)");
|
||||
|
||||
private readonly ILogger<UncertaintyScoreCalculator> _logger;
|
||||
|
||||
public UncertaintyScoreCalculator(ILogger<UncertaintyScoreCalculator> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public UncertaintyScore Calculate(SignalSnapshot snapshot, SignalWeights? weights = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
|
||||
var effectiveWeights = weights ?? SignalWeights.Default;
|
||||
var entropy = CalculateEntropy(snapshot, effectiveWeights);
|
||||
|
||||
// Calculate present weight
|
||||
var presentWeight = effectiveWeights.TotalWeight * (1.0 - entropy);
|
||||
|
||||
// Calculate gaps (missing signals)
|
||||
var gaps = new List<SignalGap>();
|
||||
if (snapshot.Vex.IsNotQueried)
|
||||
gaps.Add(new SignalGap { Signal = "VEX", Reason = SignalGapReason.NotQueried, Weight = effectiveWeights.VexWeight });
|
||||
if (snapshot.Epss.IsNotQueried)
|
||||
gaps.Add(new SignalGap { Signal = "EPSS", Reason = SignalGapReason.NotQueried, Weight = effectiveWeights.EpssWeight });
|
||||
if (snapshot.Reachability.IsNotQueried)
|
||||
gaps.Add(new SignalGap { Signal = "Reachability", Reason = SignalGapReason.NotQueried, Weight = effectiveWeights.ReachabilityWeight });
|
||||
if (snapshot.Runtime.IsNotQueried)
|
||||
gaps.Add(new SignalGap { Signal = "Runtime", Reason = SignalGapReason.NotQueried, Weight = effectiveWeights.RuntimeWeight });
|
||||
if (snapshot.Backport.IsNotQueried)
|
||||
gaps.Add(new SignalGap { Signal = "Backport", Reason = SignalGapReason.NotQueried, Weight = effectiveWeights.BackportWeight });
|
||||
if (snapshot.Sbom.IsNotQueried)
|
||||
gaps.Add(new SignalGap { Signal = "SBOMLineage", Reason = SignalGapReason.NotQueried, Weight = effectiveWeights.SbomLineageWeight });
|
||||
|
||||
return UncertaintyScore.Create(
|
||||
entropy,
|
||||
gaps,
|
||||
presentWeight,
|
||||
effectiveWeights.TotalWeight,
|
||||
snapshot.SnapshotAt);
|
||||
}
|
||||
|
||||
public double CalculateEntropy(SignalSnapshot snapshot, SignalWeights? weights = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
|
||||
var effectiveWeights = weights ?? SignalWeights.Default;
|
||||
|
||||
// Calculate total weight of present signals
|
||||
var presentWeight = 0.0;
|
||||
|
||||
if (!snapshot.Vex.IsNotQueried)
|
||||
presentWeight += effectiveWeights.VexWeight;
|
||||
|
||||
if (!snapshot.Epss.IsNotQueried)
|
||||
presentWeight += effectiveWeights.EpssWeight;
|
||||
|
||||
if (!snapshot.Reachability.IsNotQueried)
|
||||
presentWeight += effectiveWeights.ReachabilityWeight;
|
||||
|
||||
if (!snapshot.Runtime.IsNotQueried)
|
||||
presentWeight += effectiveWeights.RuntimeWeight;
|
||||
|
||||
if (!snapshot.Backport.IsNotQueried)
|
||||
presentWeight += effectiveWeights.BackportWeight;
|
||||
|
||||
if (!snapshot.Sbom.IsNotQueried)
|
||||
presentWeight += effectiveWeights.SbomLineageWeight;
|
||||
|
||||
// Entropy = 1 - (present / total_possible)
|
||||
var totalPossibleWeight = effectiveWeights.TotalWeight;
|
||||
var entropy = 1.0 - (presentWeight / totalPossibleWeight);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Calculated entropy {Entropy:F4} from {PresentWeight:F2}/{TotalWeight:F2} signal weight",
|
||||
entropy,
|
||||
presentWeight,
|
||||
totalPossibleWeight);
|
||||
|
||||
var clampedEntropy = Math.Clamp(entropy, 0.0, 1.0);
|
||||
|
||||
// Emit metric
|
||||
EntropyHistogram.Record(clampedEntropy,
|
||||
new KeyValuePair<string, object?>("cve", snapshot.Cve),
|
||||
new KeyValuePair<string, object?>("purl", snapshot.Purl));
|
||||
|
||||
return clampedEntropy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Determinization.Scoring;
|
||||
|
||||
namespace StellaOps.Policy.Determinization;
|
||||
|
||||
/// <summary>
|
||||
/// Service registration for Determinization subsystem.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers determinization services with default options.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection</param>
|
||||
/// <returns>Service collection for chaining</returns>
|
||||
public static IServiceCollection AddDeterminization(this IServiceCollection services)
|
||||
{
|
||||
return services.AddDeterminization(_ => { });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers determinization services with the DI container.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection</param>
|
||||
/// <param name="configuration">Configuration root (for options binding)</param>
|
||||
/// <returns>Service collection for chaining</returns>
|
||||
public static IServiceCollection AddDeterminization(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// Register options
|
||||
services.AddOptions<DeterminizationOptions>()
|
||||
.Bind(configuration.GetSection(DeterminizationOptions.SectionName))
|
||||
.ValidateOnStart();
|
||||
|
||||
// Register scoring calculators (both interface and concrete for flexibility)
|
||||
services.TryAddSingleton<UncertaintyScoreCalculator>();
|
||||
services.TryAddSingleton<IUncertaintyScoreCalculator>(sp => sp.GetRequiredService<UncertaintyScoreCalculator>());
|
||||
|
||||
services.TryAddSingleton<DecayedConfidenceCalculator>();
|
||||
services.TryAddSingleton<IDecayedConfidenceCalculator>(sp => sp.GetRequiredService<DecayedConfidenceCalculator>());
|
||||
|
||||
services.TryAddSingleton<TrustScoreAggregator>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers determinization services with custom options.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddDeterminization(
|
||||
this IServiceCollection services,
|
||||
Action<DeterminizationOptions> configureOptions)
|
||||
{
|
||||
services.AddOptions<DeterminizationOptions>()
|
||||
.Configure(configureOptions)
|
||||
.ValidateOnStart();
|
||||
|
||||
// Register scoring calculators (both interface and concrete for flexibility)
|
||||
services.TryAddSingleton<UncertaintyScoreCalculator>();
|
||||
services.TryAddSingleton<IUncertaintyScoreCalculator>(sp => sp.GetRequiredService<UncertaintyScoreCalculator>());
|
||||
|
||||
services.TryAddSingleton<DecayedConfidenceCalculator>();
|
||||
services.TryAddSingleton<IDecayedConfidenceCalculator>(sp => sp.GetRequiredService<DecayedConfidenceCalculator>());
|
||||
|
||||
services.TryAddSingleton<TrustScoreAggregator>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Concelier.Models\StellaOps.Concelier.Models.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Determinism.Abstractions;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Policy.Exceptions.Models;
|
||||
|
||||
@@ -121,17 +121,15 @@ public sealed record ExceptionEvent
|
||||
public static ExceptionEvent ForCreated(
|
||||
string exceptionId,
|
||||
string actorId,
|
||||
TimeProvider timeProvider,
|
||||
IGuidProvider guidProvider,
|
||||
string? description = null,
|
||||
string? clientInfo = null) => new()
|
||||
{
|
||||
EventId = guidProvider.NewGuid(),
|
||||
EventId = Guid.NewGuid(),
|
||||
ExceptionId = exceptionId,
|
||||
SequenceNumber = 1,
|
||||
EventType = ExceptionEventType.Created,
|
||||
ActorId = actorId,
|
||||
OccurredAt = timeProvider.GetUtcNow(),
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
PreviousStatus = null,
|
||||
NewStatus = ExceptionStatus.Proposed,
|
||||
NewVersion = 1,
|
||||
@@ -147,17 +145,15 @@ public sealed record ExceptionEvent
|
||||
int sequenceNumber,
|
||||
string actorId,
|
||||
int newVersion,
|
||||
TimeProvider timeProvider,
|
||||
IGuidProvider guidProvider,
|
||||
string? description = null,
|
||||
string? clientInfo = null) => new()
|
||||
{
|
||||
EventId = guidProvider.NewGuid(),
|
||||
EventId = Guid.NewGuid(),
|
||||
ExceptionId = exceptionId,
|
||||
SequenceNumber = sequenceNumber,
|
||||
EventType = ExceptionEventType.Approved,
|
||||
ActorId = actorId,
|
||||
OccurredAt = timeProvider.GetUtcNow(),
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
PreviousStatus = ExceptionStatus.Proposed,
|
||||
NewStatus = ExceptionStatus.Approved,
|
||||
NewVersion = newVersion,
|
||||
@@ -174,17 +170,15 @@ public sealed record ExceptionEvent
|
||||
string actorId,
|
||||
int newVersion,
|
||||
ExceptionStatus previousStatus,
|
||||
TimeProvider timeProvider,
|
||||
IGuidProvider guidProvider,
|
||||
string? description = null,
|
||||
string? clientInfo = null) => new()
|
||||
{
|
||||
EventId = guidProvider.NewGuid(),
|
||||
EventId = Guid.NewGuid(),
|
||||
ExceptionId = exceptionId,
|
||||
SequenceNumber = sequenceNumber,
|
||||
EventType = ExceptionEventType.Activated,
|
||||
ActorId = actorId,
|
||||
OccurredAt = timeProvider.GetUtcNow(),
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
PreviousStatus = previousStatus,
|
||||
NewStatus = ExceptionStatus.Active,
|
||||
NewVersion = newVersion,
|
||||
@@ -202,16 +196,14 @@ public sealed record ExceptionEvent
|
||||
int newVersion,
|
||||
ExceptionStatus previousStatus,
|
||||
string reason,
|
||||
TimeProvider timeProvider,
|
||||
IGuidProvider guidProvider,
|
||||
string? clientInfo = null) => new()
|
||||
{
|
||||
EventId = guidProvider.NewGuid(),
|
||||
EventId = Guid.NewGuid(),
|
||||
ExceptionId = exceptionId,
|
||||
SequenceNumber = sequenceNumber,
|
||||
EventType = ExceptionEventType.Revoked,
|
||||
ActorId = actorId,
|
||||
OccurredAt = timeProvider.GetUtcNow(),
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
PreviousStatus = previousStatus,
|
||||
NewStatus = ExceptionStatus.Revoked,
|
||||
NewVersion = newVersion,
|
||||
@@ -226,16 +218,14 @@ public sealed record ExceptionEvent
|
||||
public static ExceptionEvent ForExpired(
|
||||
string exceptionId,
|
||||
int sequenceNumber,
|
||||
int newVersion,
|
||||
TimeProvider timeProvider,
|
||||
IGuidProvider guidProvider) => new()
|
||||
int newVersion) => new()
|
||||
{
|
||||
EventId = guidProvider.NewGuid(),
|
||||
EventId = Guid.NewGuid(),
|
||||
ExceptionId = exceptionId,
|
||||
SequenceNumber = sequenceNumber,
|
||||
EventType = ExceptionEventType.Expired,
|
||||
ActorId = "system",
|
||||
OccurredAt = timeProvider.GetUtcNow(),
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
PreviousStatus = ExceptionStatus.Active,
|
||||
NewStatus = ExceptionStatus.Expired,
|
||||
NewVersion = newVersion,
|
||||
@@ -252,24 +242,22 @@ public sealed record ExceptionEvent
|
||||
int newVersion,
|
||||
DateTimeOffset previousExpiry,
|
||||
DateTimeOffset newExpiry,
|
||||
TimeProvider timeProvider,
|
||||
IGuidProvider guidProvider,
|
||||
string? reason = null,
|
||||
string? clientInfo = null) => new()
|
||||
{
|
||||
EventId = guidProvider.NewGuid(),
|
||||
EventId = Guid.NewGuid(),
|
||||
ExceptionId = exceptionId,
|
||||
SequenceNumber = sequenceNumber,
|
||||
EventType = ExceptionEventType.Extended,
|
||||
ActorId = actorId,
|
||||
OccurredAt = timeProvider.GetUtcNow(),
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
PreviousStatus = ExceptionStatus.Active,
|
||||
NewStatus = ExceptionStatus.Active,
|
||||
NewVersion = newVersion,
|
||||
Description = reason ?? $"Exception extended from {previousExpiry:O} to {newExpiry:O}",
|
||||
Details = ImmutableDictionary<string, string>.Empty
|
||||
.Add("previous_expiry", previousExpiry.ToString("O"))
|
||||
.Add("new_expiry", newExpiry.ToString("O")),
|
||||
.Add("previous_expiry", previousExpiry.ToString("O", CultureInfo.InvariantCulture))
|
||||
.Add("new_expiry", newExpiry.ToString("O", CultureInfo.InvariantCulture)),
|
||||
ClientInfo = clientInfo
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,6 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0443-M | DONE | Maintainability audit for StellaOps.Policy.Exceptions. |
|
||||
| AUDIT-0443-T | DONE | Test coverage audit for StellaOps.Policy.Exceptions. |
|
||||
| AUDIT-0443-A | TODO | APPLY pending approval for StellaOps.Policy.Exceptions. |
|
||||
| AUDIT-0443-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.Policy.Exceptions. |
|
||||
| AUDIT-0443-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Policy.Exceptions. |
|
||||
| AUDIT-0443-A | TODO | Revalidated 2026-01-07 (open findings). |
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
global using System;
|
||||
global using System.Collections.Generic;
|
||||
global using System.Collections.Immutable;
|
||||
global using System.Linq;
|
||||
global using System.Text;
|
||||
global using System.Text.Json;
|
||||
global using System.Text.Json.Serialization;
|
||||
global using Microsoft.Extensions.Logging;
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace StellaOps.Policy.Explainability;
|
||||
|
||||
/// <summary>
|
||||
/// Renders verdict rationales in multiple formats.
|
||||
/// </summary>
|
||||
public interface IVerdictRationaleRenderer
|
||||
{
|
||||
/// <summary>
|
||||
/// Renders a complete verdict rationale from verdict components.
|
||||
/// </summary>
|
||||
VerdictRationale Render(VerdictRationaleInput input);
|
||||
|
||||
/// <summary>
|
||||
/// Renders rationale as plain text (4-line format).
|
||||
/// </summary>
|
||||
string RenderPlainText(VerdictRationale rationale);
|
||||
|
||||
/// <summary>
|
||||
/// Renders rationale as Markdown.
|
||||
/// </summary>
|
||||
string RenderMarkdown(VerdictRationale rationale);
|
||||
|
||||
/// <summary>
|
||||
/// Renders rationale as canonical JSON (RFC 8785).
|
||||
/// </summary>
|
||||
string RenderJson(VerdictRationale rationale);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input for verdict rationale rendering.
|
||||
/// </summary>
|
||||
public sealed record VerdictRationaleInput
|
||||
{
|
||||
public required VerdictReference VerdictRef { get; init; }
|
||||
public required string Cve { get; init; }
|
||||
public required ComponentIdentity Component { get; init; }
|
||||
public ReachabilityDetail? Reachability { get; init; }
|
||||
public required string PolicyClauseId { get; init; }
|
||||
public required string PolicyRuleDescription { get; init; }
|
||||
public required IReadOnlyList<string> PolicyConditions { get; init; }
|
||||
public AttestationReference? PathWitness { get; init; }
|
||||
public IReadOnlyList<AttestationReference>? VexStatements { get; init; }
|
||||
public AttestationReference? Provenance { get; init; }
|
||||
public required string Verdict { get; init; }
|
||||
public double? Score { get; init; }
|
||||
public required string Recommendation { get; init; }
|
||||
public MitigationGuidance? Mitigation { get; init; }
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
public required string VerdictDigest { get; init; }
|
||||
public string? PolicyDigest { get; init; }
|
||||
public string? EvidenceDigest { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Policy.Explainability;
|
||||
|
||||
public static class ExplainabilityServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddVerdictExplainability(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IVerdictRationaleRenderer, VerdictRationaleRenderer>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Canonical.Json/StellaOps.Canonical.Json.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,197 @@
|
||||
namespace StellaOps.Policy.Explainability;
|
||||
|
||||
/// <summary>
|
||||
/// Structured verdict rationale following the 4-line template.
|
||||
/// Line 1: Evidence summary
|
||||
/// Line 2: Policy clause that triggered the decision
|
||||
/// Line 3: Attestations and proofs supporting the verdict
|
||||
/// Line 4: Final decision with score and recommendation
|
||||
/// </summary>
|
||||
public sealed record VerdictRationale
|
||||
{
|
||||
/// <summary>Schema version for forward compatibility.</summary>
|
||||
[JsonPropertyName("schema_version")]
|
||||
public string SchemaVersion { get; init; } = "1.0";
|
||||
|
||||
/// <summary>Unique rationale ID (content-addressed).</summary>
|
||||
[JsonPropertyName("rationale_id")]
|
||||
public required string RationaleId { get; init; }
|
||||
|
||||
/// <summary>Reference to the verdict being explained.</summary>
|
||||
[JsonPropertyName("verdict_ref")]
|
||||
public required VerdictReference VerdictRef { get; init; }
|
||||
|
||||
/// <summary>Line 1: Evidence summary.</summary>
|
||||
[JsonPropertyName("evidence")]
|
||||
public required RationaleEvidence Evidence { get; init; }
|
||||
|
||||
/// <summary>Line 2: Policy clause that triggered the decision.</summary>
|
||||
[JsonPropertyName("policy_clause")]
|
||||
public required RationalePolicyClause PolicyClause { get; init; }
|
||||
|
||||
/// <summary>Line 3: Attestations and proofs supporting the verdict.</summary>
|
||||
[JsonPropertyName("attestations")]
|
||||
public required RationaleAttestations Attestations { get; init; }
|
||||
|
||||
/// <summary>Line 4: Final decision with score and recommendation.</summary>
|
||||
[JsonPropertyName("decision")]
|
||||
public required RationaleDecision Decision { get; init; }
|
||||
|
||||
/// <summary>Generation timestamp (UTC).</summary>
|
||||
[JsonPropertyName("generated_at")]
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
/// <summary>Input digests for reproducibility.</summary>
|
||||
[JsonPropertyName("input_digests")]
|
||||
public required RationaleInputDigests InputDigests { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Reference to the verdict being explained.</summary>
|
||||
public sealed record VerdictReference
|
||||
{
|
||||
[JsonPropertyName("attestation_id")]
|
||||
public required string AttestationId { get; init; }
|
||||
|
||||
[JsonPropertyName("artifact_digest")]
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("policy_id")]
|
||||
public required string PolicyId { get; init; }
|
||||
|
||||
[JsonPropertyName("cve")]
|
||||
public string? Cve { get; init; }
|
||||
|
||||
[JsonPropertyName("component_purl")]
|
||||
public string? ComponentPurl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Line 1: Evidence summary.</summary>
|
||||
public sealed record RationaleEvidence
|
||||
{
|
||||
[JsonPropertyName("cve")]
|
||||
public required string Cve { get; init; }
|
||||
|
||||
[JsonPropertyName("component")]
|
||||
public required ComponentIdentity Component { get; init; }
|
||||
|
||||
[JsonPropertyName("reachability")]
|
||||
public ReachabilityDetail? Reachability { get; init; }
|
||||
|
||||
[JsonPropertyName("formatted_text")]
|
||||
public required string FormattedText { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ComponentIdentity
|
||||
{
|
||||
[JsonPropertyName("purl")]
|
||||
public required string Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("ecosystem")]
|
||||
public string? Ecosystem { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ReachabilityDetail
|
||||
{
|
||||
[JsonPropertyName("vulnerable_function")]
|
||||
public string? VulnerableFunction { get; init; }
|
||||
|
||||
[JsonPropertyName("entry_point")]
|
||||
public string? EntryPoint { get; init; }
|
||||
|
||||
[JsonPropertyName("path_summary")]
|
||||
public string? PathSummary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Line 2: Policy clause reference.</summary>
|
||||
public sealed record RationalePolicyClause
|
||||
{
|
||||
[JsonPropertyName("clause_id")]
|
||||
public required string ClauseId { get; init; }
|
||||
|
||||
[JsonPropertyName("rule_description")]
|
||||
public required string RuleDescription { get; init; }
|
||||
|
||||
[JsonPropertyName("conditions")]
|
||||
public required IReadOnlyList<string> Conditions { get; init; }
|
||||
|
||||
[JsonPropertyName("formatted_text")]
|
||||
public required string FormattedText { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Line 3: Attestations and proofs.</summary>
|
||||
public sealed record RationaleAttestations
|
||||
{
|
||||
[JsonPropertyName("path_witness")]
|
||||
public AttestationReference? PathWitness { get; init; }
|
||||
|
||||
[JsonPropertyName("vex_statements")]
|
||||
public IReadOnlyList<AttestationReference>? VexStatements { get; init; }
|
||||
|
||||
[JsonPropertyName("provenance")]
|
||||
public AttestationReference? Provenance { get; init; }
|
||||
|
||||
[JsonPropertyName("formatted_text")]
|
||||
public required string FormattedText { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AttestationReference
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public string? Summary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Line 4: Final decision.</summary>
|
||||
public sealed record RationaleDecision
|
||||
{
|
||||
[JsonPropertyName("verdict")]
|
||||
public required string Verdict { get; init; }
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public double? Score { get; init; }
|
||||
|
||||
[JsonPropertyName("recommendation")]
|
||||
public required string Recommendation { get; init; }
|
||||
|
||||
[JsonPropertyName("mitigation")]
|
||||
public MitigationGuidance? Mitigation { get; init; }
|
||||
|
||||
[JsonPropertyName("formatted_text")]
|
||||
public required string FormattedText { get; init; }
|
||||
}
|
||||
|
||||
public sealed record MitigationGuidance
|
||||
{
|
||||
[JsonPropertyName("action")]
|
||||
public required string Action { get; init; }
|
||||
|
||||
[JsonPropertyName("details")]
|
||||
public string? Details { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Input digests for reproducibility.</summary>
|
||||
public sealed record RationaleInputDigests
|
||||
{
|
||||
[JsonPropertyName("verdict_digest")]
|
||||
public required string VerdictDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("policy_digest")]
|
||||
public string? PolicyDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("evidence_digest")]
|
||||
public string? EvidenceDigest { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.Canonical.Json;
|
||||
|
||||
namespace StellaOps.Policy.Explainability;
|
||||
|
||||
/// <summary>
|
||||
/// Renders verdict rationales in multiple formats following the 4-line template.
|
||||
/// </summary>
|
||||
public sealed class VerdictRationaleRenderer : IVerdictRationaleRenderer
|
||||
{
|
||||
private readonly ILogger<VerdictRationaleRenderer> _logger;
|
||||
|
||||
public VerdictRationaleRenderer(ILogger<VerdictRationaleRenderer> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public VerdictRationale Render(VerdictRationaleInput input)
|
||||
{
|
||||
var evidence = RenderEvidence(input);
|
||||
var policyClause = RenderPolicyClause(input);
|
||||
var attestations = RenderAttestations(input);
|
||||
var decision = RenderDecision(input);
|
||||
|
||||
var inputDigests = new RationaleInputDigests
|
||||
{
|
||||
VerdictDigest = input.VerdictDigest,
|
||||
PolicyDigest = input.PolicyDigest,
|
||||
EvidenceDigest = input.EvidenceDigest
|
||||
};
|
||||
|
||||
var rationale = new VerdictRationale
|
||||
{
|
||||
RationaleId = string.Empty, // Will be computed below
|
||||
VerdictRef = input.VerdictRef,
|
||||
Evidence = evidence,
|
||||
PolicyClause = policyClause,
|
||||
Attestations = attestations,
|
||||
Decision = decision,
|
||||
GeneratedAt = input.GeneratedAt,
|
||||
InputDigests = inputDigests
|
||||
};
|
||||
|
||||
// Compute content-addressed ID
|
||||
var rationaleId = ComputeRationaleId(rationale);
|
||||
return rationale with { RationaleId = rationaleId };
|
||||
}
|
||||
|
||||
public string RenderPlainText(VerdictRationale rationale)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine(rationale.Evidence.FormattedText);
|
||||
sb.AppendLine(rationale.PolicyClause.FormattedText);
|
||||
sb.AppendLine(rationale.Attestations.FormattedText);
|
||||
sb.AppendLine(rationale.Decision.FormattedText);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public string RenderMarkdown(VerdictRationale rationale)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"## Verdict Rationale: {rationale.Evidence.Cve}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("### Evidence");
|
||||
sb.AppendLine(rationale.Evidence.FormattedText);
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("### Policy Clause");
|
||||
sb.AppendLine(rationale.PolicyClause.FormattedText);
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("### Attestations");
|
||||
sb.AppendLine(rationale.Attestations.FormattedText);
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("### Decision");
|
||||
sb.AppendLine(rationale.Decision.FormattedText);
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"*Rationale ID: `{rationale.RationaleId}`*");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public string RenderJson(VerdictRationale rationale)
|
||||
{
|
||||
return CanonJson.Serialize(rationale);
|
||||
}
|
||||
|
||||
private RationaleEvidence RenderEvidence(VerdictRationaleInput input)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
text.Append($"CVE-{input.Cve.Replace("CVE-", "")} in `{input.Component.Name ?? input.Component.Purl}` {input.Component.Version}");
|
||||
|
||||
if (input.Reachability != null)
|
||||
{
|
||||
text.Append($"; symbol `{input.Reachability.VulnerableFunction}` reachable from `{input.Reachability.EntryPoint}`");
|
||||
if (!string.IsNullOrEmpty(input.Reachability.PathSummary))
|
||||
{
|
||||
text.Append($" ({input.Reachability.PathSummary})");
|
||||
}
|
||||
}
|
||||
|
||||
text.Append('.');
|
||||
|
||||
return new RationaleEvidence
|
||||
{
|
||||
Cve = input.Cve,
|
||||
Component = input.Component,
|
||||
Reachability = input.Reachability,
|
||||
FormattedText = text.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private RationalePolicyClause RenderPolicyClause(VerdictRationaleInput input)
|
||||
{
|
||||
var text = $"Policy {input.PolicyClauseId}: {input.PolicyRuleDescription}";
|
||||
if (input.PolicyConditions.Any())
|
||||
{
|
||||
text += $" ({string.Join(", ", input.PolicyConditions)})";
|
||||
}
|
||||
text += ".";
|
||||
|
||||
return new RationalePolicyClause
|
||||
{
|
||||
ClauseId = input.PolicyClauseId,
|
||||
RuleDescription = input.PolicyRuleDescription,
|
||||
Conditions = input.PolicyConditions,
|
||||
FormattedText = text
|
||||
};
|
||||
}
|
||||
|
||||
private RationaleAttestations RenderAttestations(VerdictRationaleInput input)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
if (input.PathWitness != null)
|
||||
{
|
||||
parts.Add($"Path witness: {input.PathWitness.Summary ?? input.PathWitness.Id}");
|
||||
}
|
||||
|
||||
if (input.VexStatements?.Any() == true)
|
||||
{
|
||||
var vexSummary = string.Join(", ", input.VexStatements.Select(v => v.Summary ?? v.Id));
|
||||
parts.Add($"VEX statements: {vexSummary}");
|
||||
}
|
||||
|
||||
if (input.Provenance != null)
|
||||
{
|
||||
parts.Add($"Provenance: {input.Provenance.Summary ?? input.Provenance.Id}");
|
||||
}
|
||||
|
||||
var text = parts.Any()
|
||||
? string.Join("; ", parts) + "."
|
||||
: "No attestations available.";
|
||||
|
||||
return new RationaleAttestations
|
||||
{
|
||||
PathWitness = input.PathWitness,
|
||||
VexStatements = input.VexStatements,
|
||||
Provenance = input.Provenance,
|
||||
FormattedText = text
|
||||
};
|
||||
}
|
||||
|
||||
private RationaleDecision RenderDecision(VerdictRationaleInput input)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
text.Append($"{input.Verdict}");
|
||||
|
||||
if (input.Score.HasValue)
|
||||
{
|
||||
text.Append($" (score {input.Score.Value:F2})");
|
||||
}
|
||||
|
||||
text.Append($". {input.Recommendation}");
|
||||
|
||||
if (input.Mitigation != null)
|
||||
{
|
||||
text.Append($": {input.Mitigation.Action}");
|
||||
if (!string.IsNullOrEmpty(input.Mitigation.Details))
|
||||
{
|
||||
text.Append($" ({input.Mitigation.Details})");
|
||||
}
|
||||
}
|
||||
|
||||
text.Append('.');
|
||||
|
||||
return new RationaleDecision
|
||||
{
|
||||
Verdict = input.Verdict,
|
||||
Score = input.Score,
|
||||
Recommendation = input.Recommendation,
|
||||
Mitigation = input.Mitigation,
|
||||
FormattedText = text.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private string ComputeRationaleId(VerdictRationale rationale)
|
||||
{
|
||||
var canonicalJson = CanonJson.Serialize(rationale with { RationaleId = string.Empty });
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonicalJson));
|
||||
return $"rat:sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,6 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0448-M | DONE | Maintainability audit for StellaOps.Policy.Persistence. |
|
||||
| AUDIT-0448-T | DONE | Test coverage audit for StellaOps.Policy.Persistence. |
|
||||
| AUDIT-0448-A | TODO | APPLY pending approval for StellaOps.Policy.Persistence. |
|
||||
| AUDIT-0448-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.Policy.Persistence. |
|
||||
| AUDIT-0448-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Policy.Persistence. |
|
||||
| AUDIT-0448-A | TODO | Revalidated 2026-01-07 (open findings). |
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Policy.Unknowns.Models;
|
||||
|
||||
@@ -81,7 +82,7 @@ public static class BudgetExceededEventFactory
|
||||
["action"] = payload.Action,
|
||||
["totalUnknowns"] = payload.TotalUnknowns,
|
||||
["violationCount"] = payload.ViolationCount,
|
||||
["timestamp"] = payload.Timestamp.ToString("O")
|
||||
["timestamp"] = payload.Timestamp.ToString("O", CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
if (payload.TotalLimit.HasValue)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -147,7 +148,7 @@ public sealed class BudgetThresholdNotifier
|
||||
["percentageUsed"] = budget.PercentageUsed,
|
||||
["status"] = budget.Status.ToString().ToLowerInvariant(),
|
||||
["severity"] = severity,
|
||||
["timestamp"] = timeProvider.GetUtcNow().ToString("O")
|
||||
["timestamp"] = timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
229
src/Policy/__Libraries/StellaOps.Policy/Gates/FacetQuotaGate.cs
Normal file
229
src/Policy/__Libraries/StellaOps.Policy/Gates/FacetQuotaGate.cs
Normal file
@@ -0,0 +1,229 @@
|
||||
// <copyright file="FacetQuotaGate.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Facet;
|
||||
using StellaOps.Policy.TrustLattice;
|
||||
|
||||
namespace StellaOps.Policy.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for <see cref="FacetQuotaGate"/>.
|
||||
/// </summary>
|
||||
public sealed record FacetQuotaGateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the gate is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the action to take when no facet seal is available for comparison.
|
||||
/// </summary>
|
||||
public NoSealAction NoSealAction { get; init; } = NoSealAction.Pass;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default quota to apply when no facet-specific quota is configured.
|
||||
/// </summary>
|
||||
public FacetQuota DefaultQuota { get; init; } = FacetQuota.Default;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets per-facet quota overrides.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, FacetQuota> FacetQuotas { get; init; } =
|
||||
ImmutableDictionary<string, FacetQuota>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the action when no baseline seal is available.
|
||||
/// </summary>
|
||||
public enum NoSealAction
|
||||
{
|
||||
/// <summary>
|
||||
/// Pass the gate when no seal is available (first scan).
|
||||
/// </summary>
|
||||
Pass,
|
||||
|
||||
/// <summary>
|
||||
/// Warn when no seal is available.
|
||||
/// </summary>
|
||||
Warn,
|
||||
|
||||
/// <summary>
|
||||
/// Block when no seal is available.
|
||||
/// </summary>
|
||||
Block
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy gate that enforces per-facet drift quotas.
|
||||
/// This gate evaluates facet drift reports and enforces quotas configured per facet.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The FacetQuotaGate operates on pre-computed <see cref="FacetDriftReport"/> instances,
|
||||
/// which should be attached to the <see cref="PolicyGateContext"/> before evaluation.
|
||||
/// If no drift report is available, the gate behavior is determined by <see cref="FacetQuotaGateOptions.NoSealAction"/>.
|
||||
/// </remarks>
|
||||
public sealed class FacetQuotaGate : IPolicyGate
|
||||
{
|
||||
private readonly FacetQuotaGateOptions _options;
|
||||
private readonly IFacetDriftDetector _driftDetector;
|
||||
private readonly ILogger<FacetQuotaGate> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FacetQuotaGate"/> class.
|
||||
/// </summary>
|
||||
/// <param name="options">Gate configuration options.</param>
|
||||
/// <param name="driftDetector">The facet drift detector.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public FacetQuotaGate(
|
||||
FacetQuotaGateOptions? options = null,
|
||||
IFacetDriftDetector? driftDetector = null,
|
||||
ILogger<FacetQuotaGate>? logger = null)
|
||||
{
|
||||
_options = options ?? new FacetQuotaGateOptions();
|
||||
_driftDetector = driftDetector ?? throw new ArgumentNullException(nameof(driftDetector));
|
||||
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<FacetQuotaGate>.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<GateResult> EvaluateAsync(
|
||||
MergeResult mergeResult,
|
||||
PolicyGateContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(mergeResult);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
// Check if gate is enabled
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return Task.FromResult(Pass("Gate disabled"));
|
||||
}
|
||||
|
||||
// Check for drift report in metadata
|
||||
var driftReport = GetDriftReportFromContext(context);
|
||||
if (driftReport is null)
|
||||
{
|
||||
return Task.FromResult(HandleNoSeal());
|
||||
}
|
||||
|
||||
// Evaluate drift report against quotas
|
||||
var result = EvaluateDriftReport(driftReport);
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
private static FacetDriftReport? GetDriftReportFromContext(PolicyGateContext context)
|
||||
{
|
||||
// Drift report is expected to be in metadata under a well-known key
|
||||
if (context.Metadata?.TryGetValue("FacetDriftReport", out var value) == true &&
|
||||
value is string json)
|
||||
{
|
||||
// In a real implementation, deserialize from JSON
|
||||
// For now, return null to trigger the no-seal path
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private GateResult HandleNoSeal()
|
||||
{
|
||||
return _options.NoSealAction switch
|
||||
{
|
||||
NoSealAction.Pass => Pass("No baseline seal available - first scan"),
|
||||
NoSealAction.Warn => new GateResult
|
||||
{
|
||||
GateName = nameof(FacetQuotaGate),
|
||||
Passed = true,
|
||||
Reason = "no_baseline_seal",
|
||||
Details = ImmutableDictionary<string, object>.Empty
|
||||
.Add("action", "warn")
|
||||
.Add("message", "No baseline seal available for comparison")
|
||||
},
|
||||
NoSealAction.Block => new GateResult
|
||||
{
|
||||
GateName = nameof(FacetQuotaGate),
|
||||
Passed = false,
|
||||
Reason = "no_baseline_seal",
|
||||
Details = ImmutableDictionary<string, object>.Empty
|
||||
.Add("action", "block")
|
||||
.Add("message", "Baseline seal required but not available")
|
||||
},
|
||||
_ => Pass("Unknown NoSealAction - defaulting to pass")
|
||||
};
|
||||
}
|
||||
|
||||
private GateResult EvaluateDriftReport(FacetDriftReport report)
|
||||
{
|
||||
// Find worst verdict across all facets
|
||||
var worstVerdict = report.OverallVerdict;
|
||||
var breachedFacets = report.FacetDrifts
|
||||
.Where(d => d.QuotaVerdict != QuotaVerdict.Ok)
|
||||
.ToList();
|
||||
|
||||
if (breachedFacets.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("All facets within quota limits");
|
||||
return Pass("All facets within quota limits");
|
||||
}
|
||||
|
||||
// Build details
|
||||
var details = ImmutableDictionary<string, object>.Empty
|
||||
.Add("overallVerdict", worstVerdict.ToString())
|
||||
.Add("breachedFacets", breachedFacets.Select(f => f.FacetId).ToArray())
|
||||
.Add("totalChangedFiles", report.TotalChangedFiles)
|
||||
.Add("imageDigest", report.ImageDigest);
|
||||
|
||||
foreach (var facet in breachedFacets)
|
||||
{
|
||||
details = details.Add(
|
||||
$"facet:{facet.FacetId}",
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
["verdict"] = facet.QuotaVerdict.ToString(),
|
||||
["churnPercent"] = facet.ChurnPercent,
|
||||
["added"] = facet.Added.Length,
|
||||
["removed"] = facet.Removed.Length,
|
||||
["modified"] = facet.Modified.Length
|
||||
});
|
||||
}
|
||||
|
||||
return worstVerdict switch
|
||||
{
|
||||
QuotaVerdict.Ok => Pass("All quotas satisfied"),
|
||||
QuotaVerdict.Warning => new GateResult
|
||||
{
|
||||
GateName = nameof(FacetQuotaGate),
|
||||
Passed = true,
|
||||
Reason = "quota_warning",
|
||||
Details = details
|
||||
},
|
||||
QuotaVerdict.Blocked => new GateResult
|
||||
{
|
||||
GateName = nameof(FacetQuotaGate),
|
||||
Passed = false,
|
||||
Reason = "quota_exceeded",
|
||||
Details = details
|
||||
},
|
||||
QuotaVerdict.RequiresVex => new GateResult
|
||||
{
|
||||
GateName = nameof(FacetQuotaGate),
|
||||
Passed = false,
|
||||
Reason = "requires_vex_authorization",
|
||||
Details = details.Add("vexRequired", true)
|
||||
},
|
||||
_ => Pass("Unknown verdict - defaulting to pass")
|
||||
};
|
||||
}
|
||||
|
||||
private static GateResult Pass(string reason) => new()
|
||||
{
|
||||
GateName = nameof(FacetQuotaGate),
|
||||
Passed = true,
|
||||
Reason = reason,
|
||||
Details = ImmutableDictionary<string, object>.Empty
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// <copyright file="FacetQuotaGateServiceCollectionExtensions.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Facet;
|
||||
|
||||
namespace StellaOps.Policy.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering <see cref="FacetQuotaGate"/> with dependency injection.
|
||||
/// </summary>
|
||||
public static class FacetQuotaGateServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the <see cref="FacetQuotaGate"/> to the service collection with default options.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddFacetQuotaGate(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
return services.AddFacetQuotaGate(_ => { });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the <see cref="FacetQuotaGate"/> to the service collection with custom configuration.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configure">Action to configure <see cref="FacetQuotaGateOptions"/>.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddFacetQuotaGate(
|
||||
this IServiceCollection services,
|
||||
Action<FacetQuotaGateOptions> configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
var options = new FacetQuotaGateOptions();
|
||||
configure(options);
|
||||
|
||||
// Ensure facet drift detector is registered
|
||||
services.TryAddSingleton<IFacetDriftDetector>(sp =>
|
||||
{
|
||||
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
|
||||
return new FacetDriftDetector(timeProvider);
|
||||
});
|
||||
|
||||
// Register the gate options
|
||||
services.AddSingleton(options);
|
||||
|
||||
// Register the gate
|
||||
services.TryAddSingleton<FacetQuotaGate>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the <see cref="FacetQuotaGate"/> with a <see cref="IPolicyGateRegistry"/>.
|
||||
/// </summary>
|
||||
/// <param name="registry">The policy gate registry.</param>
|
||||
/// <param name="gateName">Optional custom gate name. Defaults to "facet-quota".</param>
|
||||
/// <returns>The registry for chaining.</returns>
|
||||
public static IPolicyGateRegistry RegisterFacetQuotaGate(
|
||||
this IPolicyGateRegistry registry,
|
||||
string gateName = "facet-quota")
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register<FacetQuotaGate>(gateName);
|
||||
return registry;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
@@ -133,7 +134,7 @@ public static class PolicyDigest
|
||||
|
||||
if (rule.Expires is DateTimeOffset expires)
|
||||
{
|
||||
writer.WriteString("expires", expires.ToUniversalTime().ToString("O"));
|
||||
writer.WriteString("expires", expires.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(rule.Justification))
|
||||
@@ -159,7 +160,7 @@ public static class PolicyDigest
|
||||
{
|
||||
if (ignore.Until is DateTimeOffset until)
|
||||
{
|
||||
writer.WriteString("until", until.ToUniversalTime().ToString("O"));
|
||||
writer.WriteString("until", until.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ignore.Justification))
|
||||
|
||||
@@ -1,17 +1,43 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Policy.Determinization.Models;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
// NOTE: GuardRails type has been consolidated into StellaOps.Policy.Determinization.Models.GuardRails.
|
||||
// Use that type for runtime monitoring requirements in GuardedPass verdicts.
|
||||
|
||||
/// <summary>
|
||||
/// Status outcomes for policy verdicts.
|
||||
/// </summary>
|
||||
public enum PolicyVerdictStatus
|
||||
{
|
||||
Pass,
|
||||
Blocked,
|
||||
Ignored,
|
||||
Warned,
|
||||
Deferred,
|
||||
Escalated,
|
||||
RequiresVex,
|
||||
/// <summary>Finding meets policy requirements.</summary>
|
||||
Pass = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Finding allowed with runtime monitoring enabled.
|
||||
/// Used for uncertain observations that don't exceed risk thresholds.
|
||||
/// </summary>
|
||||
GuardedPass = 1,
|
||||
|
||||
/// <summary>Finding fails policy checks; must be remediated.</summary>
|
||||
Blocked = 2,
|
||||
|
||||
/// <summary>Finding deliberately ignored via exception.</summary>
|
||||
Ignored = 3,
|
||||
|
||||
/// <summary>Finding passes but with warnings.</summary>
|
||||
Warned = 4,
|
||||
|
||||
/// <summary>Decision deferred; needs additional evidence.</summary>
|
||||
Deferred = 5,
|
||||
|
||||
/// <summary>Decision escalated for human review.</summary>
|
||||
Escalated = 6,
|
||||
|
||||
/// <summary>VEX statement required to make decision.</summary>
|
||||
RequiresVex = 7
|
||||
}
|
||||
|
||||
public sealed record PolicyVerdict(
|
||||
@@ -29,8 +55,20 @@ public sealed record PolicyVerdict(
|
||||
string? ConfidenceBand = null,
|
||||
double? UnknownAgeDays = null,
|
||||
string? SourceTrust = null,
|
||||
string? Reachability = null)
|
||||
string? Reachability = null,
|
||||
GuardRails? GuardRails = null,
|
||||
UncertaintyScore? UncertaintyScore = null,
|
||||
ObservationState? SuggestedObservationState = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether this verdict allows the finding to proceed (Pass or GuardedPass).
|
||||
/// </summary>
|
||||
public bool IsAllowing => Status is PolicyVerdictStatus.Pass or PolicyVerdictStatus.GuardedPass;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this verdict requires monitoring (GuardedPass only).
|
||||
/// </summary>
|
||||
public bool RequiresMonitoring => Status == PolicyVerdictStatus.GuardedPass;
|
||||
public static PolicyVerdict CreateBaseline(string findingId, PolicyScoringConfig scoringConfig)
|
||||
{
|
||||
var inputs = ImmutableDictionary<string, double>.Empty;
|
||||
@@ -49,7 +87,10 @@ public sealed record PolicyVerdict(
|
||||
ConfidenceBand: null,
|
||||
UnknownAgeDays: null,
|
||||
SourceTrust: null,
|
||||
Reachability: null);
|
||||
Reachability: null,
|
||||
GuardRails: null,
|
||||
UncertaintyScore: null,
|
||||
SuggestedObservationState: null);
|
||||
}
|
||||
|
||||
public ImmutableDictionary<string, double> GetInputs()
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Description: Deterministic hashing for proof nodes and root hash computation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -110,7 +111,7 @@ public static class ProofHashing
|
||||
["ruleId"] = node.RuleId,
|
||||
["seed"] = Convert.ToBase64String(node.Seed),
|
||||
["total"] = node.Total,
|
||||
["tsUtc"] = node.TsUtc.ToUniversalTime().ToString("O")
|
||||
["tsUtc"] = node.TsUtc.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
return SerializeCanonical(obj);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user