feat: add security sink detection patterns for JavaScript/TypeScript
- Introduced `sink-detect.js` with various security sink detection patterns categorized by type (e.g., command injection, SQL injection, file operations). - Implemented functions to build a lookup map for fast sink detection and to match sink calls against known patterns. - Added `package-lock.json` for dependency management.
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DefaultBudgets.cs
|
||||
// Sprint: SPRINT_4300_0002_0001 (Unknowns Budget Policy Integration)
|
||||
// Task: BUDGET-015 - Implement default budgets
|
||||
// Description: Default unknown budget configurations by environment.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Policy.Unknowns.Models;
|
||||
|
||||
namespace StellaOps.Policy.Unknowns.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Provides default unknown budget configurations for common environments.
|
||||
/// Advisory guidance: "Production should be strict (T2 max), staging should warn on T1."
|
||||
/// </summary>
|
||||
public static class DefaultBudgets
|
||||
{
|
||||
/// <summary>
|
||||
/// Default budget for production environments.
|
||||
/// Strict: T2 max tier, low count limits, block on exceed.
|
||||
/// </summary>
|
||||
public static UnknownBudget Production { get; } = new()
|
||||
{
|
||||
Environment = "production",
|
||||
TotalLimit = 5,
|
||||
ReasonLimits = new Dictionary<UnknownReasonCode, int>
|
||||
{
|
||||
[UnknownReasonCode.Reachability] = 0, // No reachability unknowns allowed
|
||||
[UnknownReasonCode.Identity] = 2, // Max 2 identity unknowns
|
||||
[UnknownReasonCode.Provenance] = 2, // Max 2 provenance unknowns
|
||||
[UnknownReasonCode.VexConflict] = 0, // No VEX conflicts allowed
|
||||
[UnknownReasonCode.FeedGap] = 5, // Some feed gaps tolerated
|
||||
[UnknownReasonCode.ConfigUnknown] = 3, // Some config unknowns allowed
|
||||
[UnknownReasonCode.AnalyzerLimit] = 5 // Analyzer limits are less critical
|
||||
},
|
||||
Action = BudgetAction.Block,
|
||||
ExceededMessage = "Production deployment blocked: unknown budget exceeded. Review unknowns before proceeding."
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Default budget for staging environments.
|
||||
/// Moderate: T1 warn, higher count limits, warn on exceed.
|
||||
/// </summary>
|
||||
public static UnknownBudget Staging { get; } = new()
|
||||
{
|
||||
Environment = "staging",
|
||||
TotalLimit = 20,
|
||||
ReasonLimits = new Dictionary<UnknownReasonCode, int>
|
||||
{
|
||||
[UnknownReasonCode.Reachability] = 5, // Some reachability unknowns allowed
|
||||
[UnknownReasonCode.Identity] = 10, // More identity unknowns allowed
|
||||
[UnknownReasonCode.Provenance] = 10, // More provenance unknowns allowed
|
||||
[UnknownReasonCode.VexConflict] = 5, // Some VEX conflicts tolerated
|
||||
[UnknownReasonCode.FeedGap] = 15, // More feed gaps tolerated
|
||||
[UnknownReasonCode.ConfigUnknown] = 10, // More config unknowns allowed
|
||||
[UnknownReasonCode.AnalyzerLimit] = 15 // Analyzer limits are informational
|
||||
},
|
||||
Action = BudgetAction.Warn,
|
||||
ExceededMessage = "Staging deployment warning: unknown budget exceeded. Consider addressing before production."
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Default budget for development environments.
|
||||
/// Permissive: High limits, warn only.
|
||||
/// </summary>
|
||||
public static UnknownBudget Development { get; } = new()
|
||||
{
|
||||
Environment = "development",
|
||||
TotalLimit = 100,
|
||||
ReasonLimits = new Dictionary<UnknownReasonCode, int>
|
||||
{
|
||||
[UnknownReasonCode.Reachability] = 25,
|
||||
[UnknownReasonCode.Identity] = 50,
|
||||
[UnknownReasonCode.Provenance] = 50,
|
||||
[UnknownReasonCode.VexConflict] = 25,
|
||||
[UnknownReasonCode.FeedGap] = 50,
|
||||
[UnknownReasonCode.ConfigUnknown] = 50,
|
||||
[UnknownReasonCode.AnalyzerLimit] = 50
|
||||
},
|
||||
Action = BudgetAction.Warn,
|
||||
ExceededMessage = "Development environment unknown budget exceeded."
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Default budget when no environment-specific budget is configured.
|
||||
/// Moderate: Similar to staging.
|
||||
/// </summary>
|
||||
public static UnknownBudget Default { get; } = new()
|
||||
{
|
||||
Environment = "default",
|
||||
TotalLimit = 50,
|
||||
ReasonLimits = new Dictionary<UnknownReasonCode, int>
|
||||
{
|
||||
[UnknownReasonCode.Reachability] = 10,
|
||||
[UnknownReasonCode.Identity] = 20,
|
||||
[UnknownReasonCode.Provenance] = 20,
|
||||
[UnknownReasonCode.VexConflict] = 10,
|
||||
[UnknownReasonCode.FeedGap] = 30,
|
||||
[UnknownReasonCode.ConfigUnknown] = 20,
|
||||
[UnknownReasonCode.AnalyzerLimit] = 25
|
||||
},
|
||||
Action = BudgetAction.Warn,
|
||||
ExceededMessage = "Unknown budget exceeded."
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default budget for a given environment name.
|
||||
/// </summary>
|
||||
public static UnknownBudget GetDefaultForEnvironment(string? environment)
|
||||
{
|
||||
var normalized = environment?.Trim().ToLowerInvariant();
|
||||
|
||||
return normalized switch
|
||||
{
|
||||
"prod" or "production" => Production,
|
||||
"stage" or "staging" => Staging,
|
||||
"dev" or "development" => Development,
|
||||
_ => Default
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies default budgets to an UnknownBudgetOptions instance.
|
||||
/// </summary>
|
||||
public static void ApplyDefaults(UnknownBudgetOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
options.Budgets.TryAdd("production", Production);
|
||||
options.Budgets.TryAdd("staging", Staging);
|
||||
options.Budgets.TryAdd("development", Development);
|
||||
options.Budgets.TryAdd("default", Default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BudgetExceededEventFactory.cs
|
||||
// Sprint: SPRINT_4300_0002_0001 (Unknowns Budget Policy Integration)
|
||||
// Task: BUDGET-018 - Create `UnknownBudgetExceeded` notification event
|
||||
// Description: Factory for creating budget exceeded notification events.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Policy.Unknowns.Models;
|
||||
|
||||
namespace StellaOps.Policy.Unknowns.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating budget exceeded notification events.
|
||||
/// </summary>
|
||||
public static class BudgetExceededEventFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Event kind for budget exceeded (blocking).
|
||||
/// </summary>
|
||||
public const string BudgetExceededKind = "policy.budget.exceeded";
|
||||
|
||||
/// <summary>
|
||||
/// Event kind for budget warning (non-blocking).
|
||||
/// </summary>
|
||||
public const string BudgetWarningKind = "policy.budget.warning";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a budget exceeded notification event payload.
|
||||
/// </summary>
|
||||
public static BudgetEventPayload CreatePayload(
|
||||
string environment,
|
||||
BudgetCheckResult result,
|
||||
string? imageDigest = null,
|
||||
string? policyRevisionId = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
|
||||
var violations = result.Violations
|
||||
.Select(kvp => new BudgetViolationInfo(
|
||||
kvp.Key.ToString(),
|
||||
GetShortCode(kvp.Key),
|
||||
kvp.Value.Count,
|
||||
kvp.Value.Limit))
|
||||
.ToImmutableList();
|
||||
|
||||
return new BudgetEventPayload
|
||||
{
|
||||
Environment = environment,
|
||||
IsWithinBudget = result.IsWithinBudget,
|
||||
Action = result.RecommendedAction.ToString().ToLowerInvariant(),
|
||||
TotalUnknowns = result.TotalUnknowns,
|
||||
TotalLimit = result.TotalLimit,
|
||||
ViolationCount = result.Violations.Count,
|
||||
Violations = violations,
|
||||
Message = result.Message,
|
||||
ImageDigest = imageDigest,
|
||||
PolicyRevisionId = policyRevisionId,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts the payload to a JsonNode for the notification event.
|
||||
/// </summary>
|
||||
public static JsonNode ToJsonNode(BudgetEventPayload payload)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
|
||||
var obj = new JsonObject
|
||||
{
|
||||
["environment"] = payload.Environment,
|
||||
["isWithinBudget"] = payload.IsWithinBudget,
|
||||
["action"] = payload.Action,
|
||||
["totalUnknowns"] = payload.TotalUnknowns,
|
||||
["violationCount"] = payload.ViolationCount,
|
||||
["timestamp"] = payload.Timestamp.ToString("O")
|
||||
};
|
||||
|
||||
if (payload.TotalLimit.HasValue)
|
||||
{
|
||||
obj["totalLimit"] = payload.TotalLimit.Value;
|
||||
}
|
||||
|
||||
if (payload.Message is not null)
|
||||
{
|
||||
obj["message"] = payload.Message;
|
||||
}
|
||||
|
||||
if (payload.ImageDigest is not null)
|
||||
{
|
||||
obj["imageDigest"] = payload.ImageDigest;
|
||||
}
|
||||
|
||||
if (payload.PolicyRevisionId is not null)
|
||||
{
|
||||
obj["policyRevisionId"] = payload.PolicyRevisionId;
|
||||
}
|
||||
|
||||
if (payload.Violations.Count > 0)
|
||||
{
|
||||
var violations = new JsonArray();
|
||||
foreach (var v in payload.Violations)
|
||||
{
|
||||
violations.Add(new JsonObject
|
||||
{
|
||||
["reasonCode"] = v.ReasonCode,
|
||||
["shortCode"] = v.ShortCode,
|
||||
["count"] = v.Count,
|
||||
["limit"] = v.Limit
|
||||
});
|
||||
}
|
||||
obj["violations"] = violations;
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the event kind based on the budget action.
|
||||
/// </summary>
|
||||
public static string GetEventKind(BudgetAction action)
|
||||
{
|
||||
return action == BudgetAction.Block
|
||||
? BudgetExceededKind
|
||||
: BudgetWarningKind;
|
||||
}
|
||||
|
||||
private static string GetShortCode(UnknownReasonCode code) => code switch
|
||||
{
|
||||
UnknownReasonCode.Reachability => "U-RCH",
|
||||
UnknownReasonCode.Identity => "U-ID",
|
||||
UnknownReasonCode.Provenance => "U-PROV",
|
||||
UnknownReasonCode.VexConflict => "U-VEX",
|
||||
UnknownReasonCode.FeedGap => "U-FEED",
|
||||
UnknownReasonCode.ConfigUnknown => "U-CONFIG",
|
||||
UnknownReasonCode.AnalyzerLimit => "U-ANALYZER",
|
||||
_ => "U-UNK"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Payload for budget exceeded/warning notification events.
|
||||
/// </summary>
|
||||
public sealed record BudgetEventPayload
|
||||
{
|
||||
/// <summary>
|
||||
/// Environment where budget was exceeded.
|
||||
/// </summary>
|
||||
public required string Environment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the result is within budget.
|
||||
/// </summary>
|
||||
public required bool IsWithinBudget { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Recommended action: "warn" or "block".
|
||||
/// </summary>
|
||||
public required string Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total unknown count.
|
||||
/// </summary>
|
||||
public required int TotalUnknowns { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Configured total limit.
|
||||
/// </summary>
|
||||
public int? TotalLimit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of violations.
|
||||
/// </summary>
|
||||
public required int ViolationCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Violation details.
|
||||
/// </summary>
|
||||
public ImmutableList<BudgetViolationInfo> Violations { get; init; } = ImmutableList<BudgetViolationInfo>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Budget exceeded message.
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Image digest if applicable.
|
||||
/// </summary>
|
||||
public string? ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy revision ID if applicable.
|
||||
/// </summary>
|
||||
public string? PolicyRevisionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a specific budget violation.
|
||||
/// </summary>
|
||||
public sealed record BudgetViolationInfo(
|
||||
string ReasonCode,
|
||||
string ShortCode,
|
||||
int Count,
|
||||
int Limit);
|
||||
@@ -30,6 +30,21 @@ public interface IUnknownsRepository
|
||||
string packageVersion,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all unknowns for a tenant.
|
||||
/// Sprint: SPRINT_4300_0002_0001 (BUDGET-014)
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier for RLS.</param>
|
||||
/// <param name="limit">Maximum number of results.</param>
|
||||
/// <param name="offset">Number of results to skip.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Ordered list of unknowns (by score descending).</returns>
|
||||
Task<IReadOnlyList<Unknown>> GetAllAsync(
|
||||
Guid tenantId,
|
||||
int limit = 1000,
|
||||
int offset = 0,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all unknowns for a tenant in a specific band.
|
||||
/// </summary>
|
||||
|
||||
@@ -76,6 +76,37 @@ public sealed class UnknownsRepository : IUnknownsRepository
|
||||
return row?.ToModel();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Unknown>> GetAllAsync(
|
||||
Guid tenantId,
|
||||
int limit = 1000,
|
||||
int offset = 0,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT set_config('app.current_tenant', @TenantId::text, true);
|
||||
SELECT id, tenant_id, package_id, package_version, band, score,
|
||||
uncertainty_factor, exploit_pressure,
|
||||
reason_code, remediation_hint,
|
||||
evidence_refs::text as evidence_refs,
|
||||
assumptions::text as assumptions,
|
||||
blast_radius_dependents, blast_radius_net_facing, blast_radius_privilege,
|
||||
containment_seccomp, containment_fs_mode, containment_network_policy,
|
||||
first_seen_at, last_evaluated_at, resolution_reason, resolved_at,
|
||||
created_at, updated_at
|
||||
FROM policy.unknowns
|
||||
ORDER BY score DESC, package_id ASC
|
||||
LIMIT @Limit OFFSET @Offset;
|
||||
""";
|
||||
|
||||
var param = new { TenantId = tenantId, Limit = limit, Offset = offset };
|
||||
using var reader = await _connection.QueryMultipleAsync(sql, param);
|
||||
|
||||
await reader.ReadAsync();
|
||||
var rows = await reader.ReadAsync<UnknownRow>();
|
||||
return rows.Select(r => r.ToModel()).ToList().AsReadOnly();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Unknown>> GetByBandAsync(
|
||||
Guid tenantId,
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Policy.Counterfactuals;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for computing policy counterfactuals.
|
||||
/// </summary>
|
||||
public interface ICounterfactualEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes counterfactual paths for a blocked finding.
|
||||
/// </summary>
|
||||
/// <param name="finding">The finding to analyze.</param>
|
||||
/// <param name="verdict">The current verdict for the finding.</param>
|
||||
/// <param name="document">The policy document used for evaluation.</param>
|
||||
/// <param name="scoringConfig">The scoring configuration.</param>
|
||||
/// <param name="options">Options controlling counterfactual computation.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Counterfactual result with paths to pass.</returns>
|
||||
Task<CounterfactualResult> ComputeAsync(
|
||||
PolicyFinding finding,
|
||||
PolicyVerdict verdict,
|
||||
PolicyDocument document,
|
||||
PolicyScoringConfig scoringConfig,
|
||||
CounterfactualOptions? options = null,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for counterfactual computation.
|
||||
/// </summary>
|
||||
public sealed record CounterfactualOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether to include VEX counterfactuals. Default: true.
|
||||
/// </summary>
|
||||
public bool IncludeVexPaths { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include exception counterfactuals. Default: true.
|
||||
/// </summary>
|
||||
public bool IncludeExceptionPaths { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include reachability counterfactuals. Default: true.
|
||||
/// </summary>
|
||||
public bool IncludeReachabilityPaths { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include version upgrade counterfactuals. Default: true.
|
||||
/// </summary>
|
||||
public bool IncludeVersionUpgradePaths { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include compensating control counterfactuals. Default: true.
|
||||
/// </summary>
|
||||
public bool IncludeCompensatingControlPaths { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether policy allows exceptions. Default: true.
|
||||
/// </summary>
|
||||
public bool PolicyAllowsExceptions { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether policy considers reachability. Default: true.
|
||||
/// </summary>
|
||||
public bool PolicyConsidersReachability { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether policy allows compensating controls. Default: true.
|
||||
/// </summary>
|
||||
public bool PolicyAllowsCompensatingControls { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Fixed version lookup delegate. Returns null if no fix is available.
|
||||
/// </summary>
|
||||
public Func<string, string, CancellationToken, Task<string?>>? FixedVersionLookup { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Default options.
|
||||
/// </summary>
|
||||
public static CounterfactualOptions Default => new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of the counterfactual engine.
|
||||
/// </summary>
|
||||
public sealed class CounterfactualEngine : ICounterfactualEngine
|
||||
{
|
||||
private readonly ILogger<CounterfactualEngine> _logger;
|
||||
|
||||
public CounterfactualEngine(ILogger<CounterfactualEngine> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<CounterfactualResult> ComputeAsync(
|
||||
PolicyFinding finding,
|
||||
PolicyVerdict verdict,
|
||||
PolicyDocument document,
|
||||
PolicyScoringConfig scoringConfig,
|
||||
CounterfactualOptions? options = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(finding);
|
||||
ArgumentNullException.ThrowIfNull(verdict);
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
ArgumentNullException.ThrowIfNull(scoringConfig);
|
||||
|
||||
options ??= CounterfactualOptions.Default;
|
||||
|
||||
// If already passing, no counterfactuals needed
|
||||
if (verdict.Status == PolicyVerdictStatus.Pass)
|
||||
{
|
||||
_logger.LogDebug("Finding {FindingId} already passing, no counterfactuals needed", finding.FindingId);
|
||||
return CounterfactualResult.AlreadyPassing(finding.FindingId);
|
||||
}
|
||||
|
||||
var paths = new List<CounterfactualPath>();
|
||||
|
||||
// Compute each type of counterfactual
|
||||
if (options.IncludeVexPaths)
|
||||
{
|
||||
var vexPath = await ComputeVexCounterfactualAsync(finding, verdict, document, scoringConfig, ct);
|
||||
if (vexPath is not null)
|
||||
{
|
||||
paths.Add(vexPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.IncludeExceptionPaths && options.PolicyAllowsExceptions)
|
||||
{
|
||||
var exceptionPath = ComputeExceptionCounterfactual(finding, verdict, scoringConfig);
|
||||
if (exceptionPath is not null)
|
||||
{
|
||||
paths.Add(exceptionPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.IncludeReachabilityPaths && options.PolicyConsidersReachability)
|
||||
{
|
||||
var reachPath = await ComputeReachabilityCounterfactualAsync(finding, verdict, document, scoringConfig, ct);
|
||||
if (reachPath is not null)
|
||||
{
|
||||
paths.Add(reachPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.IncludeVersionUpgradePaths && options.FixedVersionLookup is not null)
|
||||
{
|
||||
var versionPath = await ComputeVersionUpgradeCounterfactualAsync(
|
||||
finding, verdict, options.FixedVersionLookup, ct);
|
||||
if (versionPath is not null)
|
||||
{
|
||||
paths.Add(versionPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.IncludeCompensatingControlPaths && options.PolicyAllowsCompensatingControls)
|
||||
{
|
||||
var compensatingPath = ComputeCompensatingControlCounterfactual(finding);
|
||||
if (compensatingPath is not null)
|
||||
{
|
||||
paths.Add(compensatingPath);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Computed {PathCount} counterfactual paths for finding {FindingId}",
|
||||
paths.Count,
|
||||
finding.FindingId);
|
||||
|
||||
return CounterfactualResult.Blocked(finding.FindingId, paths);
|
||||
}
|
||||
|
||||
private Task<CounterfactualPath?> ComputeVexCounterfactualAsync(
|
||||
PolicyFinding finding,
|
||||
PolicyVerdict verdict,
|
||||
PolicyDocument document,
|
||||
PolicyScoringConfig scoringConfig,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Check current VEX status from tags
|
||||
var currentVexStatus = GetTagValue(finding.Tags, "vex:");
|
||||
if (string.Equals(currentVexStatus, "not_affected", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Already not_affected, VEX wont help
|
||||
return Task.FromResult<CounterfactualPath?>(null);
|
||||
}
|
||||
|
||||
// Simulate with not_affected - would it pass?
|
||||
var simulatedFinding = SimulateFindingWithVex(finding, "not_affected");
|
||||
var simVerdict = PolicyEvaluation.EvaluateFinding(
|
||||
document, scoringConfig, simulatedFinding, out _);
|
||||
|
||||
if (simVerdict.Status != PolicyVerdictStatus.Pass)
|
||||
{
|
||||
// VEX alone wouldnt flip the verdict
|
||||
return Task.FromResult<CounterfactualPath?>(null);
|
||||
}
|
||||
|
||||
var path = CounterfactualPath.Vex(
|
||||
currentVexStatus ?? "Affected",
|
||||
finding.Cve,
|
||||
effort: 2);
|
||||
|
||||
return Task.FromResult<CounterfactualPath?>(path);
|
||||
}
|
||||
|
||||
private CounterfactualPath? ComputeExceptionCounterfactual(
|
||||
PolicyFinding finding,
|
||||
PolicyVerdict verdict,
|
||||
PolicyScoringConfig scoringConfig)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(finding.Cve))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Compute effort based on severity
|
||||
var effort = ComputeExceptionEffort(finding.Severity);
|
||||
|
||||
return CounterfactualPath.Exception(finding.Cve, effort);
|
||||
}
|
||||
|
||||
private Task<CounterfactualPath?> ComputeReachabilityCounterfactualAsync(
|
||||
PolicyFinding finding,
|
||||
PolicyVerdict verdict,
|
||||
PolicyDocument document,
|
||||
PolicyScoringConfig scoringConfig,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var currentReachability = GetTagValue(finding.Tags, "reachability:");
|
||||
|
||||
// If already not reachable, this wont help
|
||||
if (string.Equals(currentReachability, "no", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(currentReachability, "false", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult<CounterfactualPath?>(null);
|
||||
}
|
||||
|
||||
// Unknown or reachable - check if changing to not-reachable would help
|
||||
var simulatedFinding = SimulateFindingWithReachability(finding, "no");
|
||||
var simVerdict = PolicyEvaluation.EvaluateFinding(
|
||||
document, scoringConfig, simulatedFinding, out _);
|
||||
|
||||
if (simVerdict.Status != PolicyVerdictStatus.Pass)
|
||||
{
|
||||
return Task.FromResult<CounterfactualPath?>(null);
|
||||
}
|
||||
|
||||
var effort = currentReachability == null ||
|
||||
string.Equals(currentReachability, "unknown", StringComparison.OrdinalIgnoreCase)
|
||||
? 2 // Just need to run analysis
|
||||
: 4; // Need code changes
|
||||
|
||||
var path = CounterfactualPath.Reachability(
|
||||
currentReachability ?? "Unknown",
|
||||
finding.FindingId,
|
||||
effort);
|
||||
|
||||
return Task.FromResult<CounterfactualPath?>(path);
|
||||
}
|
||||
|
||||
private async Task<CounterfactualPath?> ComputeVersionUpgradeCounterfactualAsync(
|
||||
PolicyFinding finding,
|
||||
PolicyVerdict verdict,
|
||||
Func<string, string, CancellationToken, Task<string?>> fixedVersionLookup,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(finding.Cve) || string.IsNullOrWhiteSpace(finding.Purl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var fixedVersion = await fixedVersionLookup(finding.Cve, finding.Purl, ct);
|
||||
if (string.IsNullOrWhiteSpace(fixedVersion))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var currentVersion = GetVersionFromPurl(finding.Purl);
|
||||
|
||||
return CounterfactualPath.VersionUpgrade(
|
||||
currentVersion ?? "current",
|
||||
fixedVersion,
|
||||
finding.Purl,
|
||||
effort: 2);
|
||||
}
|
||||
|
||||
private CounterfactualPath? ComputeCompensatingControlCounterfactual(PolicyFinding finding)
|
||||
{
|
||||
return CounterfactualPath.CompensatingControl(finding.FindingId, effort: 4);
|
||||
}
|
||||
|
||||
private static int ComputeExceptionEffort(PolicySeverity severity)
|
||||
{
|
||||
return severity switch
|
||||
{
|
||||
PolicySeverity.Critical => 5,
|
||||
PolicySeverity.High => 4,
|
||||
PolicySeverity.Medium => 3,
|
||||
PolicySeverity.Low => 2,
|
||||
_ => 3
|
||||
};
|
||||
}
|
||||
|
||||
private static PolicyFinding SimulateFindingWithVex(PolicyFinding finding, string vexStatus)
|
||||
{
|
||||
var tags = finding.Tags.IsDefaultOrEmpty
|
||||
? new List<string>()
|
||||
: finding.Tags.ToList();
|
||||
|
||||
// Remove existing vex tag
|
||||
tags.RemoveAll(t => t.StartsWith("vex:", StringComparison.OrdinalIgnoreCase));
|
||||
tags.Add($"vex:{vexStatus}");
|
||||
|
||||
return finding with { Tags = [.. tags] };
|
||||
}
|
||||
|
||||
private static PolicyFinding SimulateFindingWithReachability(PolicyFinding finding, string reachability)
|
||||
{
|
||||
var tags = finding.Tags.IsDefaultOrEmpty
|
||||
? new List<string>()
|
||||
: finding.Tags.ToList();
|
||||
|
||||
// Remove existing reachability tag
|
||||
tags.RemoveAll(t => t.StartsWith("reachability:", StringComparison.OrdinalIgnoreCase));
|
||||
tags.Add($"reachability:{reachability}");
|
||||
|
||||
return finding with { Tags = [.. tags] };
|
||||
}
|
||||
|
||||
private static string? GetTagValue(System.Collections.Immutable.ImmutableArray<string> tags, string prefix)
|
||||
{
|
||||
if (tags.IsDefaultOrEmpty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
if (tag.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return tag[prefix.Length..].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? GetVersionFromPurl(string purl)
|
||||
{
|
||||
// purl format: pkg:type/namespace/name@version
|
||||
var atIndex = purl.LastIndexOf('@');
|
||||
if (atIndex < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var version = purl[(atIndex + 1)..];
|
||||
var queryIndex = version.IndexOf('?');
|
||||
if (queryIndex >= 0)
|
||||
{
|
||||
version = version[..queryIndex];
|
||||
}
|
||||
|
||||
return version;
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,62 @@ namespace StellaOps.Policy.Counterfactuals;
|
||||
/// </summary>
|
||||
public sealed record CounterfactualResult
|
||||
{
|
||||
public required Guid FindingId { get; init; }
|
||||
/// <summary>
|
||||
/// The finding this analysis applies to.
|
||||
/// </summary>
|
||||
public required string FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current verdict for this finding.
|
||||
/// </summary>
|
||||
public required string CurrentVerdict { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// What the verdict would change to.
|
||||
/// </summary>
|
||||
public required string TargetVerdict { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Possible paths to flip the verdict.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<CounterfactualPath> Paths { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether any path exists.
|
||||
/// </summary>
|
||||
public bool HasPaths => Paths.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// The recommended path (lowest effort).
|
||||
/// </summary>
|
||||
public CounterfactualPath? RecommendedPath =>
|
||||
Paths.OrderBy(path => path.EstimatedEffort).FirstOrDefault();
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty result for an already-passing finding.
|
||||
/// </summary>
|
||||
public static CounterfactualResult AlreadyPassing(string findingId) =>
|
||||
new()
|
||||
{
|
||||
FindingId = findingId,
|
||||
CurrentVerdict = "Ship",
|
||||
TargetVerdict = "Ship",
|
||||
Paths = []
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a blocked finding result with paths.
|
||||
/// </summary>
|
||||
public static CounterfactualResult Blocked(
|
||||
string findingId,
|
||||
IEnumerable<CounterfactualPath> paths) =>
|
||||
new()
|
||||
{
|
||||
FindingId = findingId,
|
||||
CurrentVerdict = "Block",
|
||||
TargetVerdict = "Ship",
|
||||
Paths = paths.OrderBy(p => p.EstimatedEffort).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -21,12 +68,200 @@ public sealed record CounterfactualResult
|
||||
/// </summary>
|
||||
public sealed record CounterfactualPath
|
||||
{
|
||||
/// <summary>
|
||||
/// Type of change required.
|
||||
/// </summary>
|
||||
public required CounterfactualType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description of what would need to change.
|
||||
/// </summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Specific conditions that would need to be met.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<CounterfactualCondition> Conditions { get; init; }
|
||||
public int EstimatedEffort { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Estimated effort level (1-5). Lower is easier.
|
||||
/// </summary>
|
||||
public int EstimatedEffort { get; init; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Who can take this action (e.g., "Vendor", "Security Team", "Development Team").
|
||||
/// </summary>
|
||||
public required string Actor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Link to relevant documentation or action URI.
|
||||
/// </summary>
|
||||
public string? ActionUri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this path is currently blocked by policy constraints.
|
||||
/// </summary>
|
||||
public bool IsBlocked { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason why this path is blocked, if applicable.
|
||||
/// </summary>
|
||||
public string? BlockedReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a VEX counterfactual path.
|
||||
/// </summary>
|
||||
public static CounterfactualPath Vex(
|
||||
string currentVexStatus,
|
||||
string? vulnId = null,
|
||||
int effort = 2) =>
|
||||
new()
|
||||
{
|
||||
Type = CounterfactualType.VexStatus,
|
||||
Description = "Would pass if VEX status is 'not_affected'",
|
||||
Conditions =
|
||||
[
|
||||
new CounterfactualCondition
|
||||
{
|
||||
Field = "VEX Status",
|
||||
CurrentValue = currentVexStatus,
|
||||
RequiredValue = "NotAffected",
|
||||
IsMet = false
|
||||
}
|
||||
],
|
||||
EstimatedEffort = effort,
|
||||
Actor = "Vendor or Security Team",
|
||||
ActionUri = "/vex/create"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates an exception counterfactual path.
|
||||
/// </summary>
|
||||
public static CounterfactualPath Exception(
|
||||
string vulnId,
|
||||
int effort = 3) =>
|
||||
new()
|
||||
{
|
||||
Type = CounterfactualType.Exception,
|
||||
Description = $"Would pass with a security exception for {vulnId}",
|
||||
Conditions =
|
||||
[
|
||||
new CounterfactualCondition
|
||||
{
|
||||
Field = "Exception",
|
||||
CurrentValue = "None",
|
||||
RequiredValue = "Approved exception covering this CVE",
|
||||
IsMet = false
|
||||
}
|
||||
],
|
||||
EstimatedEffort = effort,
|
||||
Actor = "Security Team or Exception Approver",
|
||||
ActionUri = $"/exceptions/request?cve={vulnId}"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a reachability counterfactual path.
|
||||
/// </summary>
|
||||
public static CounterfactualPath Reachability(
|
||||
string currentReachability,
|
||||
string findingId,
|
||||
int effort = 4) =>
|
||||
new()
|
||||
{
|
||||
Type = CounterfactualType.Reachability,
|
||||
Description = "Would pass if vulnerable code is not reachable",
|
||||
Conditions =
|
||||
[
|
||||
new CounterfactualCondition
|
||||
{
|
||||
Field = "Reachability",
|
||||
CurrentValue = currentReachability,
|
||||
RequiredValue = "No (not reachable)",
|
||||
IsMet = false
|
||||
}
|
||||
],
|
||||
EstimatedEffort = effort,
|
||||
Actor = "Development Team",
|
||||
ActionUri = $"/reachability/analyze?finding={findingId}"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a version upgrade counterfactual path.
|
||||
/// </summary>
|
||||
public static CounterfactualPath VersionUpgrade(
|
||||
string currentVersion,
|
||||
string fixedVersion,
|
||||
string purl,
|
||||
int effort = 2) =>
|
||||
new()
|
||||
{
|
||||
Type = CounterfactualType.VersionUpgrade,
|
||||
Description = $"Would pass by upgrading to {fixedVersion}",
|
||||
Conditions =
|
||||
[
|
||||
new CounterfactualCondition
|
||||
{
|
||||
Field = "Version",
|
||||
CurrentValue = currentVersion,
|
||||
RequiredValue = fixedVersion,
|
||||
IsMet = false
|
||||
}
|
||||
],
|
||||
EstimatedEffort = effort,
|
||||
Actor = "Development Team",
|
||||
ActionUri = $"/components/{Uri.EscapeDataString(purl)}/upgrade"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a compensating control counterfactual path.
|
||||
/// </summary>
|
||||
public static CounterfactualPath CompensatingControl(
|
||||
string findingId,
|
||||
int effort = 4) =>
|
||||
new()
|
||||
{
|
||||
Type = CounterfactualType.CompensatingControl,
|
||||
Description = "Would pass with documented compensating control",
|
||||
Conditions =
|
||||
[
|
||||
new CounterfactualCondition
|
||||
{
|
||||
Field = "Compensating Control",
|
||||
CurrentValue = "None",
|
||||
RequiredValue = "Approved control mitigating the risk",
|
||||
IsMet = false
|
||||
}
|
||||
],
|
||||
EstimatedEffort = effort,
|
||||
Actor = "Security Team",
|
||||
ActionUri = $"/controls/create?finding={findingId}"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a policy change counterfactual path.
|
||||
/// </summary>
|
||||
public static CounterfactualPath PolicyModification(
|
||||
string currentRule,
|
||||
string reason,
|
||||
int effort = 5) =>
|
||||
new()
|
||||
{
|
||||
Type = CounterfactualType.PolicyChange,
|
||||
Description = $"Would pass if policy rule '{currentRule}' is modified",
|
||||
Conditions =
|
||||
[
|
||||
new CounterfactualCondition
|
||||
{
|
||||
Field = "Policy Rule",
|
||||
CurrentValue = currentRule,
|
||||
RequiredValue = "Modified or removed",
|
||||
IsMet = false
|
||||
}
|
||||
],
|
||||
EstimatedEffort = effort,
|
||||
Actor = "Policy Admin",
|
||||
ActionUri = "/policy/edit"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -34,9 +269,24 @@ public sealed record CounterfactualPath
|
||||
/// </summary>
|
||||
public sealed record CounterfactualCondition
|
||||
{
|
||||
/// <summary>
|
||||
/// What needs to change.
|
||||
/// </summary>
|
||||
public required string Field { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current value.
|
||||
/// </summary>
|
||||
public required string CurrentValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Required value.
|
||||
/// </summary>
|
||||
public required string RequiredValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this condition is currently met.
|
||||
/// </summary>
|
||||
public bool IsMet { get; init; }
|
||||
}
|
||||
|
||||
@@ -45,11 +295,24 @@ public sealed record CounterfactualCondition
|
||||
/// </summary>
|
||||
public enum CounterfactualType
|
||||
{
|
||||
/// <summary>VEX status would need to change.</summary>
|
||||
VexStatus,
|
||||
|
||||
/// <summary>An exception would need to be granted.</summary>
|
||||
Exception,
|
||||
|
||||
/// <summary>Reachability status would need to change.</summary>
|
||||
Reachability,
|
||||
|
||||
/// <summary>Component version would need to change.</summary>
|
||||
VersionUpgrade,
|
||||
|
||||
/// <summary>Policy rule would need to be modified.</summary>
|
||||
PolicyChange,
|
||||
|
||||
/// <summary>Component would need to be removed.</summary>
|
||||
ComponentRemoval,
|
||||
CompensatingControl,
|
||||
|
||||
/// <summary>Compensating control would need to be applied.</summary>
|
||||
CompensatingControl
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Policy.Snapshots;
|
||||
|
||||
namespace StellaOps.Policy.Deltas;
|
||||
|
||||
/// <summary>
|
||||
/// Selects the appropriate baseline for delta comparison.
|
||||
/// </summary>
|
||||
public sealed class BaselineSelector : IBaselineSelector
|
||||
{
|
||||
private readonly ISnapshotStore _snapshotStore;
|
||||
private readonly ILogger<BaselineSelector> _logger;
|
||||
|
||||
public BaselineSelector(
|
||||
ISnapshotStore snapshotStore,
|
||||
ILogger<BaselineSelector>? logger = null)
|
||||
{
|
||||
_snapshotStore = snapshotStore ?? throw new ArgumentNullException(nameof(snapshotStore));
|
||||
_logger = logger ?? NullLogger<BaselineSelector>.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects a baseline snapshot for the given artifact.
|
||||
/// </summary>
|
||||
public async Task<BaselineSelectionResult> SelectBaselineAsync(
|
||||
string artifactDigest,
|
||||
BaselineSelectionStrategy strategy,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogDebug("Selecting baseline for {Artifact} using strategy {Strategy}",
|
||||
artifactDigest, strategy);
|
||||
|
||||
return strategy switch
|
||||
{
|
||||
BaselineSelectionStrategy.PreviousBuild => await SelectPreviousBuildAsync(ct),
|
||||
BaselineSelectionStrategy.LastApproved => await SelectLastApprovedAsync(ct),
|
||||
BaselineSelectionStrategy.ProductionDeployed => await SelectProductionAsync(ct),
|
||||
BaselineSelectionStrategy.BranchBase => await SelectBranchBaseAsync(ct),
|
||||
BaselineSelectionStrategy.Explicit => BaselineSelectionResult.NotFound("Explicit strategy requires baseline ID"),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(strategy))
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects a baseline with an explicit snapshot ID.
|
||||
/// </summary>
|
||||
public async Task<BaselineSelectionResult> SelectExplicitAsync(
|
||||
string baselineSnapshotId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baselineSnapshotId))
|
||||
{
|
||||
return BaselineSelectionResult.NotFound("Baseline snapshot ID is required");
|
||||
}
|
||||
|
||||
var snapshot = await _snapshotStore.GetAsync(baselineSnapshotId, ct).ConfigureAwait(false);
|
||||
if (snapshot is null)
|
||||
{
|
||||
return BaselineSelectionResult.NotFound($"Snapshot {baselineSnapshotId} not found");
|
||||
}
|
||||
|
||||
return BaselineSelectionResult.Success(snapshot, BaselineSelectionStrategy.Explicit);
|
||||
}
|
||||
|
||||
private async Task<BaselineSelectionResult> SelectPreviousBuildAsync(CancellationToken ct)
|
||||
{
|
||||
// Get most recent snapshot that isn't the current one
|
||||
var snapshots = await _snapshotStore.ListAsync(skip: 0, take: 10, ct).ConfigureAwait(false);
|
||||
|
||||
if (snapshots.Count < 2)
|
||||
{
|
||||
return BaselineSelectionResult.NotFound("No previous build found");
|
||||
}
|
||||
|
||||
// Return second most recent (first is current)
|
||||
return BaselineSelectionResult.Success(snapshots[1], BaselineSelectionStrategy.PreviousBuild);
|
||||
}
|
||||
|
||||
private async Task<BaselineSelectionResult> SelectLastApprovedAsync(CancellationToken ct)
|
||||
{
|
||||
// Without verdict store, fall back to most recent sealed snapshot
|
||||
var snapshots = await _snapshotStore.ListAsync(skip: 0, take: 50, ct).ConfigureAwait(false);
|
||||
|
||||
var sealedSnapshot = snapshots.FirstOrDefault(s => s.Signature is not null);
|
||||
|
||||
if (sealedSnapshot is null)
|
||||
{
|
||||
// Fall back to any snapshot
|
||||
var anySnapshot = snapshots.FirstOrDefault();
|
||||
if (anySnapshot is null)
|
||||
{
|
||||
return BaselineSelectionResult.NotFound("No approved baseline found");
|
||||
}
|
||||
return BaselineSelectionResult.Success(anySnapshot, BaselineSelectionStrategy.LastApproved);
|
||||
}
|
||||
|
||||
return BaselineSelectionResult.Success(sealedSnapshot, BaselineSelectionStrategy.LastApproved);
|
||||
}
|
||||
|
||||
private async Task<BaselineSelectionResult> SelectProductionAsync(CancellationToken ct)
|
||||
{
|
||||
// Without deployment tags, fall back to last approved
|
||||
return await SelectLastApprovedAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<BaselineSelectionResult> SelectBranchBaseAsync(CancellationToken ct)
|
||||
{
|
||||
// Without git integration, fall back to last approved
|
||||
return await SelectLastApprovedAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strategies for selecting a baseline.
|
||||
/// </summary>
|
||||
public enum BaselineSelectionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Use the immediately previous build of the same artifact.
|
||||
/// </summary>
|
||||
PreviousBuild,
|
||||
|
||||
/// <summary>
|
||||
/// Use the most recent build that passed policy.
|
||||
/// </summary>
|
||||
LastApproved,
|
||||
|
||||
/// <summary>
|
||||
/// Use the build currently deployed to production.
|
||||
/// </summary>
|
||||
ProductionDeployed,
|
||||
|
||||
/// <summary>
|
||||
/// Use the commit where the current branch diverged.
|
||||
/// </summary>
|
||||
BranchBase,
|
||||
|
||||
/// <summary>
|
||||
/// Use an explicitly specified baseline.
|
||||
/// </summary>
|
||||
Explicit
|
||||
}
|
||||
|
||||
public sealed record BaselineSelectionResult
|
||||
{
|
||||
public required bool IsFound { get; init; }
|
||||
public KnowledgeSnapshotManifest? Snapshot { get; init; }
|
||||
public BaselineSelectionStrategy? Strategy { get; init; }
|
||||
public string? Error { get; init; }
|
||||
|
||||
public static BaselineSelectionResult Success(KnowledgeSnapshotManifest snapshot, BaselineSelectionStrategy strategy) =>
|
||||
new() { IsFound = true, Snapshot = snapshot, Strategy = strategy };
|
||||
|
||||
public static BaselineSelectionResult NotFound(string error) =>
|
||||
new() { IsFound = false, Error = error };
|
||||
}
|
||||
|
||||
public interface IBaselineSelector
|
||||
{
|
||||
Task<BaselineSelectionResult> SelectBaselineAsync(
|
||||
string artifactDigest,
|
||||
BaselineSelectionStrategy strategy,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<BaselineSelectionResult> SelectExplicitAsync(
|
||||
string baselineSnapshotId,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
236
src/Policy/__Libraries/StellaOps.Policy/Deltas/DeltaVerdict.cs
Normal file
236
src/Policy/__Libraries/StellaOps.Policy/Deltas/DeltaVerdict.cs
Normal file
@@ -0,0 +1,236 @@
|
||||
namespace StellaOps.Policy.Deltas;
|
||||
|
||||
/// <summary>
|
||||
/// Verdict for a security state delta.
|
||||
/// Determines whether a change should be allowed to proceed.
|
||||
/// </summary>
|
||||
public sealed record DeltaVerdict
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this verdict.
|
||||
/// </summary>
|
||||
public required string VerdictId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the delta being evaluated.
|
||||
/// </summary>
|
||||
public required string DeltaId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this verdict was rendered.
|
||||
/// </summary>
|
||||
public required DateTimeOffset EvaluatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The verdict outcome.
|
||||
/// </summary>
|
||||
public required DeltaVerdictStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Recommended gate level based on delta risk.
|
||||
/// </summary>
|
||||
public DeltaGateLevel RecommendedGate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Risk points consumed by this change.
|
||||
/// </summary>
|
||||
public int RiskPoints { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Drivers that contributed to the verdict.
|
||||
/// </summary>
|
||||
public IReadOnlyList<DeltaDriver> BlockingDrivers { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Drivers that raised warnings but didn't block.
|
||||
/// </summary>
|
||||
public IReadOnlyList<DeltaDriver> WarningDrivers { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Applied exceptions that allowed blocking drivers.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> AppliedExceptions { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable explanation.
|
||||
/// </summary>
|
||||
public string? Explanation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Recommendations for addressing issues.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Recommendations { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Possible verdict outcomes for a delta.
|
||||
/// </summary>
|
||||
public enum DeltaVerdictStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Delta is safe to proceed.
|
||||
/// </summary>
|
||||
Pass,
|
||||
|
||||
/// <summary>
|
||||
/// Delta has warnings but can proceed.
|
||||
/// </summary>
|
||||
Warn,
|
||||
|
||||
/// <summary>
|
||||
/// Delta should not proceed without remediation.
|
||||
/// </summary>
|
||||
Fail,
|
||||
|
||||
/// <summary>
|
||||
/// Delta is blocked but covered by exceptions.
|
||||
/// </summary>
|
||||
PassWithExceptions
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gate levels aligned with diff-aware release gates.
|
||||
/// </summary>
|
||||
public enum DeltaGateLevel
|
||||
{
|
||||
/// <summary>
|
||||
/// G0: No-risk (docs, comments only).
|
||||
/// </summary>
|
||||
G0,
|
||||
|
||||
/// <summary>
|
||||
/// G1: Low risk (unit tests, 1 review).
|
||||
/// </summary>
|
||||
G1,
|
||||
|
||||
/// <summary>
|
||||
/// G2: Moderate risk (integration tests, code owner, canary).
|
||||
/// </summary>
|
||||
G2,
|
||||
|
||||
/// <summary>
|
||||
/// G3: High risk (security scan, migration plan, release captain).
|
||||
/// </summary>
|
||||
G3,
|
||||
|
||||
/// <summary>
|
||||
/// G4: Very high risk (formal review, extended canary, comms plan).
|
||||
/// </summary>
|
||||
G4
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for delta verdicts.
|
||||
/// </summary>
|
||||
public sealed class DeltaVerdictBuilder
|
||||
{
|
||||
private DeltaVerdictStatus _status = DeltaVerdictStatus.Pass;
|
||||
private DeltaGateLevel _gate = DeltaGateLevel.G1;
|
||||
private int _riskPoints;
|
||||
private readonly List<DeltaDriver> _blockingDrivers = [];
|
||||
private readonly List<DeltaDriver> _warningDrivers = [];
|
||||
private readonly List<string> _exceptions = [];
|
||||
private readonly List<string> _recommendations = [];
|
||||
private string? _explanation;
|
||||
|
||||
public DeltaVerdictBuilder WithStatus(DeltaVerdictStatus status)
|
||||
{
|
||||
_status = status;
|
||||
return this;
|
||||
}
|
||||
|
||||
public DeltaVerdictBuilder WithGate(DeltaGateLevel gate)
|
||||
{
|
||||
_gate = gate;
|
||||
return this;
|
||||
}
|
||||
|
||||
public DeltaVerdictBuilder WithRiskPoints(int points)
|
||||
{
|
||||
_riskPoints = points;
|
||||
return this;
|
||||
}
|
||||
|
||||
public DeltaVerdictBuilder AddBlockingDriver(DeltaDriver driver)
|
||||
{
|
||||
_blockingDrivers.Add(driver);
|
||||
_status = DeltaVerdictStatus.Fail;
|
||||
|
||||
// Escalate gate based on severity
|
||||
if (driver.Severity == DeltaDriverSeverity.Critical && _gate < DeltaGateLevel.G4)
|
||||
_gate = DeltaGateLevel.G4;
|
||||
else if (driver.Severity == DeltaDriverSeverity.High && _gate < DeltaGateLevel.G3)
|
||||
_gate = DeltaGateLevel.G3;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public DeltaVerdictBuilder AddWarningDriver(DeltaDriver driver)
|
||||
{
|
||||
_warningDrivers.Add(driver);
|
||||
if (_status == DeltaVerdictStatus.Pass)
|
||||
_status = DeltaVerdictStatus.Warn;
|
||||
|
||||
// Escalate gate for medium severity warnings
|
||||
if (driver.Severity >= DeltaDriverSeverity.Medium && _gate < DeltaGateLevel.G2)
|
||||
_gate = DeltaGateLevel.G2;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public DeltaVerdictBuilder AddException(string exceptionId)
|
||||
{
|
||||
_exceptions.Add(exceptionId);
|
||||
return this;
|
||||
}
|
||||
|
||||
public DeltaVerdictBuilder AddRecommendation(string recommendation)
|
||||
{
|
||||
_recommendations.Add(recommendation);
|
||||
return this;
|
||||
}
|
||||
|
||||
public DeltaVerdictBuilder WithExplanation(string explanation)
|
||||
{
|
||||
_explanation = explanation;
|
||||
return this;
|
||||
}
|
||||
|
||||
public DeltaVerdict Build(string deltaId)
|
||||
{
|
||||
// If all blocking drivers are excepted, change to PassWithExceptions
|
||||
if (_status == DeltaVerdictStatus.Fail &&
|
||||
_blockingDrivers.Count > 0 &&
|
||||
_exceptions.Count >= _blockingDrivers.Count)
|
||||
{
|
||||
_status = DeltaVerdictStatus.PassWithExceptions;
|
||||
}
|
||||
|
||||
return new DeltaVerdict
|
||||
{
|
||||
VerdictId = $"dv:{Guid.NewGuid():N}",
|
||||
DeltaId = deltaId,
|
||||
EvaluatedAt = DateTimeOffset.UtcNow,
|
||||
Status = _status,
|
||||
RecommendedGate = _gate,
|
||||
RiskPoints = _riskPoints,
|
||||
BlockingDrivers = _blockingDrivers.ToList(),
|
||||
WarningDrivers = _warningDrivers.ToList(),
|
||||
AppliedExceptions = _exceptions.ToList(),
|
||||
Explanation = _explanation ?? GenerateExplanation(),
|
||||
Recommendations = _recommendations.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private string GenerateExplanation()
|
||||
{
|
||||
return _status switch
|
||||
{
|
||||
DeltaVerdictStatus.Pass => "No blocking changes detected",
|
||||
DeltaVerdictStatus.Warn => $"{_warningDrivers.Count} warning(s) detected",
|
||||
DeltaVerdictStatus.Fail => $"{_blockingDrivers.Count} blocking issue(s) detected",
|
||||
DeltaVerdictStatus.PassWithExceptions => $"Blocked by {_blockingDrivers.Count} issue(s), covered by exceptions",
|
||||
_ => "Unknown status"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
namespace StellaOps.Policy.Deltas;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the delta between two security states (baseline vs target).
|
||||
/// This is the atomic unit of governance for release decisions.
|
||||
/// </summary>
|
||||
public sealed record SecurityStateDelta
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this delta.
|
||||
/// Format: delta:sha256:{hash}
|
||||
/// </summary>
|
||||
public required string DeltaId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this delta was computed.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Knowledge snapshot ID of the baseline state.
|
||||
/// </summary>
|
||||
public required string BaselineSnapshotId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Knowledge snapshot ID of the target state.
|
||||
/// </summary>
|
||||
public required string TargetSnapshotId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact being evaluated.
|
||||
/// </summary>
|
||||
public required ArtifactRef Artifact { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SBOM differences.
|
||||
/// </summary>
|
||||
public required SbomDelta Sbom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability differences.
|
||||
/// </summary>
|
||||
public required ReachabilityDelta Reachability { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX coverage differences.
|
||||
/// </summary>
|
||||
public required VexDelta Vex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy evaluation differences.
|
||||
/// </summary>
|
||||
public required PolicyDelta Policy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unknowns differences.
|
||||
/// </summary>
|
||||
public required UnknownsDelta Unknowns { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Findings that drive the verdict.
|
||||
/// </summary>
|
||||
public IReadOnlyList<DeltaDriver> Drivers { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Summary statistics.
|
||||
/// </summary>
|
||||
public required DeltaSummary Summary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the artifact being evaluated.
|
||||
/// </summary>
|
||||
public sealed record ArtifactRef(
|
||||
string Digest,
|
||||
string? Name,
|
||||
string? Tag);
|
||||
|
||||
/// <summary>
|
||||
/// SBOM-level differences.
|
||||
/// </summary>
|
||||
public sealed record SbomDelta
|
||||
{
|
||||
public int PackagesAdded { get; init; }
|
||||
public int PackagesRemoved { get; init; }
|
||||
public int PackagesModified { get; init; }
|
||||
public IReadOnlyList<PackageChange> AddedPackages { get; init; } = [];
|
||||
public IReadOnlyList<PackageChange> RemovedPackages { get; init; } = [];
|
||||
public IReadOnlyList<PackageVersionChange> VersionChanges { get; init; } = [];
|
||||
|
||||
public static SbomDelta Empty => new();
|
||||
}
|
||||
|
||||
public sealed record PackageChange(string Purl, string? License);
|
||||
public sealed record PackageVersionChange(string Purl, string OldVersion, string NewVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Reachability analysis differences.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityDelta
|
||||
{
|
||||
public int NewReachable { get; init; }
|
||||
public int NewUnreachable { get; init; }
|
||||
public int ChangedReachability { get; init; }
|
||||
public IReadOnlyList<ReachabilityChange> Changes { get; init; } = [];
|
||||
|
||||
public static ReachabilityDelta Empty => new();
|
||||
}
|
||||
|
||||
public sealed record ReachabilityChange(
|
||||
string CveId,
|
||||
string Purl,
|
||||
bool WasReachable,
|
||||
bool IsReachable);
|
||||
|
||||
/// <summary>
|
||||
/// VEX coverage differences.
|
||||
/// </summary>
|
||||
public sealed record VexDelta
|
||||
{
|
||||
public int NewVexStatements { get; init; }
|
||||
public int RevokedVexStatements { get; init; }
|
||||
public int CoverageIncrease { get; init; }
|
||||
public int CoverageDecrease { get; init; }
|
||||
public IReadOnlyList<VexChange> Changes { get; init; } = [];
|
||||
|
||||
public static VexDelta Empty => new();
|
||||
}
|
||||
|
||||
public sealed record VexChange(
|
||||
string CveId,
|
||||
string? OldStatus,
|
||||
string? NewStatus);
|
||||
|
||||
/// <summary>
|
||||
/// Policy evaluation differences.
|
||||
/// </summary>
|
||||
public sealed record PolicyDelta
|
||||
{
|
||||
public int NewViolations { get; init; }
|
||||
public int ResolvedViolations { get; init; }
|
||||
public int PolicyVersionChanged { get; init; }
|
||||
public IReadOnlyList<PolicyChange> Changes { get; init; } = [];
|
||||
|
||||
public static PolicyDelta Empty => new();
|
||||
}
|
||||
|
||||
public sealed record PolicyChange(
|
||||
string RuleId,
|
||||
string ChangeType,
|
||||
string? Description);
|
||||
|
||||
/// <summary>
|
||||
/// Unknowns differences.
|
||||
/// </summary>
|
||||
public sealed record UnknownsDelta
|
||||
{
|
||||
public int NewUnknowns { get; init; }
|
||||
public int ResolvedUnknowns { get; init; }
|
||||
public int TotalBaselineUnknowns { get; init; }
|
||||
public int TotalTargetUnknowns { get; init; }
|
||||
public IReadOnlyDictionary<string, int> ByReasonCode { get; init; }
|
||||
= new Dictionary<string, int>();
|
||||
|
||||
public static UnknownsDelta Empty => new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A finding that drives the delta verdict.
|
||||
/// </summary>
|
||||
public sealed record DeltaDriver
|
||||
{
|
||||
public required string Type { get; init; } // "new-cve", "reachability-change", etc.
|
||||
public required DeltaDriverSeverity Severity { get; init; }
|
||||
public required string Description { get; init; }
|
||||
public string? CveId { get; init; }
|
||||
public string? Purl { get; init; }
|
||||
public IReadOnlyDictionary<string, string> Details { get; init; }
|
||||
= new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
public enum DeltaDriverSeverity
|
||||
{
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
Critical
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary statistics for the delta.
|
||||
/// </summary>
|
||||
public sealed record DeltaSummary
|
||||
{
|
||||
public int TotalChanges { get; init; }
|
||||
public int RiskIncreasing { get; init; }
|
||||
public int RiskDecreasing { get; init; }
|
||||
public int Neutral { get; init; }
|
||||
public decimal RiskScore { get; init; }
|
||||
public string RiskDirection { get; init; } = "stable"; // "increasing", "decreasing", "stable"
|
||||
|
||||
public static DeltaSummary Empty => new() { RiskDirection = "stable" };
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace StellaOps.Policy.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Enforces budget constraints on release operations.
|
||||
/// </summary>
|
||||
public sealed class BudgetConstraintEnforcer : IBudgetConstraintEnforcer
|
||||
{
|
||||
private readonly IBudgetLedger _ledger;
|
||||
private readonly IGateSelector _gateSelector;
|
||||
private readonly ILogger<BudgetConstraintEnforcer> _logger;
|
||||
|
||||
public BudgetConstraintEnforcer(
|
||||
IBudgetLedger ledger,
|
||||
IGateSelector gateSelector,
|
||||
ILogger<BudgetConstraintEnforcer>? logger = null)
|
||||
{
|
||||
_ledger = ledger ?? throw new ArgumentNullException(nameof(ledger));
|
||||
_gateSelector = gateSelector ?? throw new ArgumentNullException(nameof(gateSelector));
|
||||
_logger = logger ?? NullLogger<BudgetConstraintEnforcer>.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a release can proceed given current budget.
|
||||
/// </summary>
|
||||
public async Task<BudgetCheckResult> CheckReleaseAsync(
|
||||
ReleaseCheckInput input,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var budget = await _ledger.GetBudgetAsync(input.ServiceId, ct: ct).ConfigureAwait(false);
|
||||
var gateResult = await _gateSelector.SelectGateAsync(input.ToGateInput(), ct).ConfigureAwait(false);
|
||||
|
||||
var result = new BudgetCheckResult
|
||||
{
|
||||
CanProceed = !gateResult.IsBlocked,
|
||||
RequiredGate = gateResult.Gate,
|
||||
RiskPoints = gateResult.RiskScore,
|
||||
BudgetBefore = budget,
|
||||
BudgetAfter = budget with { Consumed = budget.Consumed + gateResult.RiskScore },
|
||||
BlockReason = gateResult.BlockReason,
|
||||
Requirements = gateResult.Requirements,
|
||||
Recommendations = gateResult.Recommendations
|
||||
};
|
||||
|
||||
// Log the check
|
||||
_logger.LogInformation(
|
||||
"Release check for {ServiceId}: CanProceed={CanProceed}, Gate={Gate}, RP={RP}",
|
||||
input.ServiceId, result.CanProceed, result.RequiredGate, result.RiskPoints);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a release and consumes budget.
|
||||
/// </summary>
|
||||
public async Task<ReleaseRecordResult> RecordReleaseAsync(
|
||||
ReleaseRecordInput input,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// First check if release can proceed
|
||||
var checkResult = await CheckReleaseAsync(input.ToCheckInput(), ct).ConfigureAwait(false);
|
||||
|
||||
if (!checkResult.CanProceed)
|
||||
{
|
||||
return new ReleaseRecordResult
|
||||
{
|
||||
IsSuccess = false,
|
||||
Error = checkResult.BlockReason ?? "Release blocked by budget constraints"
|
||||
};
|
||||
}
|
||||
|
||||
// Consume budget
|
||||
var consumeResult = await _ledger.ConsumeAsync(
|
||||
input.ServiceId,
|
||||
checkResult.RiskPoints,
|
||||
input.ReleaseId,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (!consumeResult.IsSuccess)
|
||||
{
|
||||
return new ReleaseRecordResult
|
||||
{
|
||||
IsSuccess = false,
|
||||
Error = consumeResult.Error
|
||||
};
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Recorded release {ReleaseId} for {ServiceId}. Budget: {Remaining}/{Allocated} RP remaining",
|
||||
input.ReleaseId, input.ServiceId,
|
||||
consumeResult.Budget.Remaining, consumeResult.Budget.Allocated);
|
||||
|
||||
return new ReleaseRecordResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
ReleaseId = input.ReleaseId,
|
||||
ConsumedRiskPoints = checkResult.RiskPoints,
|
||||
Budget = consumeResult.Budget,
|
||||
Gate = checkResult.RequiredGate
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles break-glass exception for urgent releases.
|
||||
/// </summary>
|
||||
public async Task<ExceptionResult> RecordExceptionAsync(
|
||||
ExceptionInput input,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Record the exception
|
||||
var baseRiskPoints = await CalculateBaseRiskPointsAsync(input, ct).ConfigureAwait(false);
|
||||
|
||||
// Apply 50% penalty for exception
|
||||
var penaltyRiskPoints = (int)(baseRiskPoints * 1.5);
|
||||
|
||||
var consumeResult = await _ledger.ConsumeAsync(
|
||||
input.ServiceId,
|
||||
penaltyRiskPoints,
|
||||
input.ReleaseId,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Break-glass exception for {ServiceId}: {ReleaseId}. Penalty: {Penalty} RP. Reason: {Reason}",
|
||||
input.ServiceId, input.ReleaseId, penaltyRiskPoints - baseRiskPoints, input.Reason);
|
||||
|
||||
return new ExceptionResult
|
||||
{
|
||||
IsSuccess = consumeResult.IsSuccess,
|
||||
ReleaseId = input.ReleaseId,
|
||||
BaseRiskPoints = baseRiskPoints,
|
||||
PenaltyRiskPoints = penaltyRiskPoints - baseRiskPoints,
|
||||
TotalRiskPoints = penaltyRiskPoints,
|
||||
Budget = consumeResult.Budget,
|
||||
FollowUpRequired = true,
|
||||
FollowUpDeadline = DateTimeOffset.UtcNow.AddDays(5)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<int> CalculateBaseRiskPointsAsync(ExceptionInput input, CancellationToken ct)
|
||||
{
|
||||
var gateResult = await _gateSelector.SelectGateAsync(new GateSelectionInput
|
||||
{
|
||||
ServiceId = input.ServiceId,
|
||||
Tier = input.Tier,
|
||||
DiffCategory = input.DiffCategory,
|
||||
Context = input.Context,
|
||||
Mitigations = input.Mitigations,
|
||||
IsEmergencyFix = true
|
||||
}, ct).ConfigureAwait(false);
|
||||
|
||||
return gateResult.RiskScore;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input for release check.
|
||||
/// </summary>
|
||||
public sealed record ReleaseCheckInput
|
||||
{
|
||||
public required string ServiceId { get; init; }
|
||||
public required ServiceTier Tier { get; init; }
|
||||
public required DiffCategory DiffCategory { get; init; }
|
||||
public required OperationalContext Context { get; init; }
|
||||
public required MitigationFactors Mitigations { get; init; }
|
||||
|
||||
public GateSelectionInput ToGateInput() => new()
|
||||
{
|
||||
ServiceId = ServiceId,
|
||||
Tier = Tier,
|
||||
DiffCategory = DiffCategory,
|
||||
Context = Context,
|
||||
Mitigations = Mitigations
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of budget check.
|
||||
/// </summary>
|
||||
public sealed record BudgetCheckResult
|
||||
{
|
||||
public required bool CanProceed { get; init; }
|
||||
public required GateLevel RequiredGate { get; init; }
|
||||
public required int RiskPoints { get; init; }
|
||||
public required RiskBudget BudgetBefore { get; init; }
|
||||
public required RiskBudget BudgetAfter { get; init; }
|
||||
public string? BlockReason { get; init; }
|
||||
public IReadOnlyList<string> Requirements { get; init; } = [];
|
||||
public IReadOnlyList<string> Recommendations { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input for release recording.
|
||||
/// </summary>
|
||||
public sealed record ReleaseRecordInput
|
||||
{
|
||||
public required string ReleaseId { get; init; }
|
||||
public required string ServiceId { get; init; }
|
||||
public required ServiceTier Tier { get; init; }
|
||||
public required DiffCategory DiffCategory { get; init; }
|
||||
public required OperationalContext Context { get; init; }
|
||||
public required MitigationFactors Mitigations { get; init; }
|
||||
|
||||
public ReleaseCheckInput ToCheckInput() => new()
|
||||
{
|
||||
ServiceId = ServiceId,
|
||||
Tier = Tier,
|
||||
DiffCategory = DiffCategory,
|
||||
Context = Context,
|
||||
Mitigations = Mitigations
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of release recording.
|
||||
/// </summary>
|
||||
public sealed record ReleaseRecordResult
|
||||
{
|
||||
public required bool IsSuccess { get; init; }
|
||||
public string? ReleaseId { get; init; }
|
||||
public int ConsumedRiskPoints { get; init; }
|
||||
public RiskBudget? Budget { get; init; }
|
||||
public GateLevel? Gate { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input for exception recording.
|
||||
/// </summary>
|
||||
public sealed record ExceptionInput
|
||||
{
|
||||
public required string ReleaseId { get; init; }
|
||||
public required string ServiceId { get; init; }
|
||||
public required ServiceTier Tier { get; init; }
|
||||
public required DiffCategory DiffCategory { get; init; }
|
||||
public required OperationalContext Context { get; init; }
|
||||
public required MitigationFactors Mitigations { get; init; }
|
||||
public required string Reason { get; init; }
|
||||
public required string ApprovedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of exception recording.
|
||||
/// </summary>
|
||||
public sealed record ExceptionResult
|
||||
{
|
||||
public required bool IsSuccess { get; init; }
|
||||
public required string ReleaseId { get; init; }
|
||||
public required int BaseRiskPoints { get; init; }
|
||||
public required int PenaltyRiskPoints { get; init; }
|
||||
public required int TotalRiskPoints { get; init; }
|
||||
public required RiskBudget Budget { get; init; }
|
||||
public required bool FollowUpRequired { get; init; }
|
||||
public DateTimeOffset? FollowUpDeadline { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for budget constraint enforcement.
|
||||
/// </summary>
|
||||
public interface IBudgetConstraintEnforcer
|
||||
{
|
||||
Task<BudgetCheckResult> CheckReleaseAsync(ReleaseCheckInput input, CancellationToken ct = default);
|
||||
Task<ReleaseRecordResult> RecordReleaseAsync(ReleaseRecordInput input, CancellationToken ct = default);
|
||||
Task<ExceptionResult> RecordExceptionAsync(ExceptionInput input, CancellationToken ct = default);
|
||||
}
|
||||
278
src/Policy/__Libraries/StellaOps.Policy/Gates/BudgetLedger.cs
Normal file
278
src/Policy/__Libraries/StellaOps.Policy/Gates/BudgetLedger.cs
Normal file
@@ -0,0 +1,278 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace StellaOps.Policy.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Ledger for tracking risk budget consumption.
|
||||
/// </summary>
|
||||
public sealed class BudgetLedger : IBudgetLedger
|
||||
{
|
||||
private readonly IBudgetStore _store;
|
||||
private readonly ILogger<BudgetLedger> _logger;
|
||||
|
||||
public BudgetLedger(IBudgetStore store, ILogger<BudgetLedger>? logger = null)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_logger = logger ?? NullLogger<BudgetLedger>.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current budget for a service.
|
||||
/// </summary>
|
||||
public async Task<RiskBudget> GetBudgetAsync(
|
||||
string serviceId,
|
||||
string? window = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
window ??= GetCurrentWindow();
|
||||
|
||||
var budget = await _store.GetAsync(serviceId, window, ct).ConfigureAwait(false);
|
||||
if (budget is not null)
|
||||
return budget;
|
||||
|
||||
// Create default budget if none exists
|
||||
var tier = await GetServiceTierAsync(serviceId, ct).ConfigureAwait(false);
|
||||
return await CreateBudgetAsync(serviceId, tier, window, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records consumption of risk points.
|
||||
/// </summary>
|
||||
public async Task<BudgetConsumeResult> ConsumeAsync(
|
||||
string serviceId,
|
||||
int riskPoints,
|
||||
string releaseId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var budget = await GetBudgetAsync(serviceId, ct: ct).ConfigureAwait(false);
|
||||
|
||||
if (budget.Remaining < riskPoints)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Budget exceeded for {ServiceId}: {Remaining} remaining, {Requested} requested",
|
||||
serviceId, budget.Remaining, riskPoints);
|
||||
|
||||
return new BudgetConsumeResult
|
||||
{
|
||||
IsSuccess = false,
|
||||
Budget = budget,
|
||||
Error = "Insufficient budget remaining"
|
||||
};
|
||||
}
|
||||
|
||||
// Record the consumption
|
||||
var entry = new BudgetEntry
|
||||
{
|
||||
EntryId = Guid.NewGuid().ToString(),
|
||||
ServiceId = serviceId,
|
||||
Window = budget.Window,
|
||||
ReleaseId = releaseId,
|
||||
RiskPoints = riskPoints,
|
||||
ConsumedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await _store.AddEntryAsync(entry, ct).ConfigureAwait(false);
|
||||
|
||||
// Update budget
|
||||
var updatedBudget = budget with
|
||||
{
|
||||
Consumed = budget.Consumed + riskPoints,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await _store.UpdateAsync(updatedBudget, ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Consumed {RiskPoints} RP for {ServiceId}. Remaining: {Remaining}/{Allocated}",
|
||||
riskPoints, serviceId, updatedBudget.Remaining, updatedBudget.Allocated);
|
||||
|
||||
return new BudgetConsumeResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
Budget = updatedBudget,
|
||||
Entry = entry
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the consumption history for a service.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<BudgetEntry>> GetHistoryAsync(
|
||||
string serviceId,
|
||||
string? window = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
window ??= GetCurrentWindow();
|
||||
return await _store.GetEntriesAsync(serviceId, window, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adjusts budget allocation (e.g., for earned capacity).
|
||||
/// </summary>
|
||||
public async Task<RiskBudget> AdjustAllocationAsync(
|
||||
string serviceId,
|
||||
int adjustment,
|
||||
string reason,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var budget = await GetBudgetAsync(serviceId, ct: ct).ConfigureAwait(false);
|
||||
|
||||
var newAllocation = Math.Max(0, budget.Allocated + adjustment);
|
||||
var updatedBudget = budget with
|
||||
{
|
||||
Allocated = newAllocation,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await _store.UpdateAsync(updatedBudget, ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Adjusted budget for {ServiceId} by {Adjustment} RP. Reason: {Reason}",
|
||||
serviceId, adjustment, reason);
|
||||
|
||||
return updatedBudget;
|
||||
}
|
||||
|
||||
private async Task<RiskBudget> CreateBudgetAsync(
|
||||
string serviceId,
|
||||
ServiceTier tier,
|
||||
string window,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var budget = new RiskBudget
|
||||
{
|
||||
BudgetId = $"budget:{serviceId}:{window}",
|
||||
ServiceId = serviceId,
|
||||
Tier = tier,
|
||||
Window = window,
|
||||
Allocated = DefaultBudgetAllocations.GetMonthlyAllocation(tier),
|
||||
Consumed = 0,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await _store.CreateAsync(budget, ct).ConfigureAwait(false);
|
||||
return budget;
|
||||
}
|
||||
|
||||
private static string GetCurrentWindow() =>
|
||||
DateTimeOffset.UtcNow.ToString("yyyy-MM");
|
||||
|
||||
private Task<ServiceTier> GetServiceTierAsync(string serviceId, CancellationToken ct)
|
||||
{
|
||||
// Look up service tier from configuration or default to Tier 1
|
||||
return Task.FromResult(ServiceTier.CustomerFacingNonCritical);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry recording a budget consumption.
|
||||
/// </summary>
|
||||
public sealed record BudgetEntry
|
||||
{
|
||||
public required string EntryId { get; init; }
|
||||
public required string ServiceId { get; init; }
|
||||
public required string Window { get; init; }
|
||||
public required string ReleaseId { get; init; }
|
||||
public required int RiskPoints { get; init; }
|
||||
public required DateTimeOffset ConsumedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of budget consumption attempt.
|
||||
/// </summary>
|
||||
public sealed record BudgetConsumeResult
|
||||
{
|
||||
public required bool IsSuccess { get; init; }
|
||||
public required RiskBudget Budget { get; init; }
|
||||
public BudgetEntry? Entry { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for budget ledger operations.
|
||||
/// </summary>
|
||||
public interface IBudgetLedger
|
||||
{
|
||||
Task<RiskBudget> GetBudgetAsync(string serviceId, string? window = null, CancellationToken ct = default);
|
||||
Task<BudgetConsumeResult> ConsumeAsync(string serviceId, int riskPoints, string releaseId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<BudgetEntry>> GetHistoryAsync(string serviceId, string? window = null, CancellationToken ct = default);
|
||||
Task<RiskBudget> AdjustAllocationAsync(string serviceId, int adjustment, string reason, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for budget persistence.
|
||||
/// </summary>
|
||||
public interface IBudgetStore
|
||||
{
|
||||
Task<RiskBudget?> GetAsync(string serviceId, string window, CancellationToken ct);
|
||||
Task CreateAsync(RiskBudget budget, CancellationToken ct);
|
||||
Task UpdateAsync(RiskBudget budget, CancellationToken ct);
|
||||
Task AddEntryAsync(BudgetEntry entry, CancellationToken ct);
|
||||
Task<IReadOnlyList<BudgetEntry>> GetEntriesAsync(string serviceId, string window, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IBudgetStore"/> for testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryBudgetStore : IBudgetStore
|
||||
{
|
||||
private readonly Dictionary<string, RiskBudget> _budgets = new();
|
||||
private readonly List<BudgetEntry> _entries = [];
|
||||
private readonly object _lock = new();
|
||||
|
||||
public Task<RiskBudget?> GetAsync(string serviceId, string window, CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var key = $"{serviceId}:{window}";
|
||||
lock (_lock)
|
||||
{
|
||||
return Task.FromResult(_budgets.TryGetValue(key, out var budget) ? budget : null);
|
||||
}
|
||||
}
|
||||
|
||||
public Task CreateAsync(RiskBudget budget, CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var key = $"{budget.ServiceId}:{budget.Window}";
|
||||
lock (_lock)
|
||||
{
|
||||
_budgets[key] = budget;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateAsync(RiskBudget budget, CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var key = $"{budget.ServiceId}:{budget.Window}";
|
||||
lock (_lock)
|
||||
{
|
||||
_budgets[key] = budget;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task AddEntryAsync(BudgetEntry entry, CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
lock (_lock)
|
||||
{
|
||||
_entries.Add(entry);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<BudgetEntry>> GetEntriesAsync(string serviceId, string window, CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
lock (_lock)
|
||||
{
|
||||
var result = _entries
|
||||
.Where(e => e.ServiceId == serviceId && e.Window == window)
|
||||
.OrderByDescending(e => e.ConsumedAt)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<BudgetEntry>>(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
122
src/Policy/__Libraries/StellaOps.Policy/Gates/GateLevel.cs
Normal file
122
src/Policy/__Libraries/StellaOps.Policy/Gates/GateLevel.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
namespace StellaOps.Policy.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Diff-aware release gate levels (G0-G4).
|
||||
/// Higher levels require more checks before release.
|
||||
/// </summary>
|
||||
public enum GateLevel
|
||||
{
|
||||
/// <summary>
|
||||
/// G0: No-risk / Administrative.
|
||||
/// Requirements: Lint/format checks, basic CI pass.
|
||||
/// Use for: docs-only, comments-only, non-functional metadata.
|
||||
/// </summary>
|
||||
G0 = 0,
|
||||
|
||||
/// <summary>
|
||||
/// G1: Low risk.
|
||||
/// Requirements: All automated unit tests, static analysis, 1 peer review, staging deploy, smoke checks.
|
||||
/// Use for: small localized changes, non-core UI, telemetry additions.
|
||||
/// </summary>
|
||||
G1 = 1,
|
||||
|
||||
/// <summary>
|
||||
/// G2: Moderate risk.
|
||||
/// Requirements: G1 + integration tests, code owner review, feature flag required, staged rollout, rollback plan.
|
||||
/// Use for: moderate logic changes, dependency upgrades, backward-compatible API changes.
|
||||
/// </summary>
|
||||
G2 = 2,
|
||||
|
||||
/// <summary>
|
||||
/// G3: High risk.
|
||||
/// Requirements: G2 + security scan, migration plan reviewed, load/performance checks, observability updates, release captain sign-off, progressive delivery with health gates.
|
||||
/// Use for: schema migrations, auth/permission changes, core business logic, infra changes.
|
||||
/// </summary>
|
||||
G3 = 3,
|
||||
|
||||
/// <summary>
|
||||
/// G4: Very high risk / Safety-critical.
|
||||
/// Requirements: G3 + formal risk review (PM+DM+Security), rollback rehearsal, extended canary, customer comms plan, post-release verification checklist.
|
||||
/// Use for: Tier 3 systems with low budget, freeze window exceptions, platform-wide changes.
|
||||
/// </summary>
|
||||
G4 = 4
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gate level requirements documentation.
|
||||
/// </summary>
|
||||
public static class GateLevelRequirements
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the requirements for a gate level.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> GetRequirements(GateLevel level)
|
||||
{
|
||||
return level switch
|
||||
{
|
||||
GateLevel.G0 =>
|
||||
[
|
||||
"Lint/format checks pass",
|
||||
"Basic CI build passes"
|
||||
],
|
||||
|
||||
GateLevel.G1 =>
|
||||
[
|
||||
"All automated unit tests pass",
|
||||
"Static analysis/linting clean",
|
||||
"1 peer review (code owner not required)",
|
||||
"Automated deploy to staging",
|
||||
"Post-deploy smoke checks pass"
|
||||
],
|
||||
|
||||
GateLevel.G2 =>
|
||||
[
|
||||
"All G1 requirements",
|
||||
"Integration tests for impacted modules pass",
|
||||
"Code owner review for touched modules",
|
||||
"Feature flag required if customer impact possible",
|
||||
"Staged rollout: canary or small cohort",
|
||||
"Rollback plan documented in PR"
|
||||
],
|
||||
|
||||
GateLevel.G3 =>
|
||||
[
|
||||
"All G2 requirements",
|
||||
"Security scan + dependency audit pass",
|
||||
"Migration plan (forward + rollback) reviewed",
|
||||
"Load/performance checks if in hot path",
|
||||
"Observability: new/updated dashboards/alerts",
|
||||
"Release captain / on-call sign-off",
|
||||
"Progressive delivery with automatic health gates"
|
||||
],
|
||||
|
||||
GateLevel.G4 =>
|
||||
[
|
||||
"All G3 requirements",
|
||||
"Formal risk review (PM+DM+Security/SRE) in writing",
|
||||
"Explicit rollback rehearsal or proven rollback path",
|
||||
"Extended canary period with success/abort criteria",
|
||||
"Customer comms plan if impact is plausible",
|
||||
"Post-release verification checklist executed and logged"
|
||||
],
|
||||
|
||||
_ => []
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a short description for a gate level.
|
||||
/// </summary>
|
||||
public static string GetDescription(GateLevel level)
|
||||
{
|
||||
return level switch
|
||||
{
|
||||
GateLevel.G0 => "No-risk: Basic CI only",
|
||||
GateLevel.G1 => "Low risk: Unit tests + 1 review",
|
||||
GateLevel.G2 => "Moderate risk: Integration tests + code owner + canary",
|
||||
GateLevel.G3 => "High risk: Security scan + release captain + progressive",
|
||||
GateLevel.G4 => "Very high risk: Formal review + extended canary + comms",
|
||||
_ => "Unknown"
|
||||
};
|
||||
}
|
||||
}
|
||||
175
src/Policy/__Libraries/StellaOps.Policy/Gates/GateSelector.cs
Normal file
175
src/Policy/__Libraries/StellaOps.Policy/Gates/GateSelector.cs
Normal file
@@ -0,0 +1,175 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace StellaOps.Policy.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Selects the appropriate gate level for a release.
|
||||
/// </summary>
|
||||
public sealed class GateSelector : IGateSelector
|
||||
{
|
||||
private readonly IRiskPointScoring _scoring;
|
||||
private readonly IBudgetLedger _budgetLedger;
|
||||
private readonly ILogger<GateSelector> _logger;
|
||||
|
||||
public GateSelector(
|
||||
IRiskPointScoring scoring,
|
||||
IBudgetLedger budgetLedger,
|
||||
ILogger<GateSelector>? logger = null)
|
||||
{
|
||||
_scoring = scoring ?? throw new ArgumentNullException(nameof(scoring));
|
||||
_budgetLedger = budgetLedger ?? throw new ArgumentNullException(nameof(budgetLedger));
|
||||
_logger = logger ?? NullLogger<GateSelector>.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the gate level for a change.
|
||||
/// </summary>
|
||||
public async Task<GateSelectionResult> SelectGateAsync(
|
||||
GateSelectionInput input,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Get current budget status
|
||||
var budget = await _budgetLedger.GetBudgetAsync(input.ServiceId, ct: ct).ConfigureAwait(false);
|
||||
|
||||
// Build context with budget status
|
||||
var context = input.Context with { BudgetStatus = budget.Status };
|
||||
|
||||
// Calculate risk score
|
||||
var scoreInput = new RiskScoreInput
|
||||
{
|
||||
Tier = input.Tier,
|
||||
DiffCategory = input.DiffCategory,
|
||||
Context = context,
|
||||
Mitigations = input.Mitigations
|
||||
};
|
||||
|
||||
var scoreResult = _scoring.CalculateScore(scoreInput);
|
||||
|
||||
// Apply budget-based modifiers
|
||||
var finalGate = ApplyBudgetModifiers(scoreResult.RecommendedGate, budget);
|
||||
|
||||
// Check for blocks
|
||||
var (isBlocked, blockReason) = CheckForBlocks(finalGate, budget, input);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Gate selection for {ServiceId}: Score={Score}, Gate={Gate}, Budget={BudgetStatus}",
|
||||
input.ServiceId, scoreResult.Score, finalGate, budget.Status);
|
||||
|
||||
return new GateSelectionResult
|
||||
{
|
||||
Gate = finalGate,
|
||||
RiskScore = scoreResult.Score,
|
||||
ScoreBreakdown = scoreResult.Breakdown,
|
||||
Budget = budget,
|
||||
IsBlocked = isBlocked,
|
||||
BlockReason = blockReason,
|
||||
Requirements = GateLevelRequirements.GetRequirements(finalGate).ToList(),
|
||||
Recommendations = GenerateRecommendations(scoreResult, budget)
|
||||
};
|
||||
}
|
||||
|
||||
private static GateLevel ApplyBudgetModifiers(GateLevel gate, RiskBudget budget)
|
||||
{
|
||||
return budget.Status switch
|
||||
{
|
||||
// Yellow: Escalate G2+ by one level
|
||||
BudgetStatus.Yellow when gate >= GateLevel.G2 =>
|
||||
gate < GateLevel.G4 ? gate + 1 : GateLevel.G4,
|
||||
|
||||
// Red: Escalate G1+ by one level
|
||||
BudgetStatus.Red when gate >= GateLevel.G1 =>
|
||||
gate < GateLevel.G4 ? gate + 1 : GateLevel.G4,
|
||||
|
||||
// Exhausted: Everything is G4
|
||||
BudgetStatus.Exhausted => GateLevel.G4,
|
||||
|
||||
_ => gate
|
||||
};
|
||||
}
|
||||
|
||||
private static (bool IsBlocked, string? Reason) CheckForBlocks(
|
||||
GateLevel gate, RiskBudget budget, GateSelectionInput input)
|
||||
{
|
||||
// Red budget blocks high-risk categories
|
||||
if (budget.Status == BudgetStatus.Red &&
|
||||
input.DiffCategory is DiffCategory.DatabaseMigration or DiffCategory.AuthPermission or DiffCategory.InfraNetworking)
|
||||
{
|
||||
return (true, "High-risk changes blocked during Red budget status");
|
||||
}
|
||||
|
||||
// Exhausted budget blocks non-emergency changes
|
||||
if (budget.Status == BudgetStatus.Exhausted && !input.IsEmergencyFix)
|
||||
{
|
||||
return (true, "Budget exhausted. Only incident/security fixes allowed.");
|
||||
}
|
||||
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> GenerateRecommendations(
|
||||
RiskScoreResult score, RiskBudget budget)
|
||||
{
|
||||
var recommendations = new List<string>();
|
||||
|
||||
// Score reduction recommendations
|
||||
if (score.Breakdown.DiffRisk > 5)
|
||||
{
|
||||
recommendations.Add("Consider breaking this change into smaller, lower-risk diffs");
|
||||
}
|
||||
|
||||
if (score.Breakdown.Mitigations == 0)
|
||||
{
|
||||
recommendations.Add("Add mitigations: feature flag, canary deployment, or increased test coverage");
|
||||
}
|
||||
|
||||
// Budget recommendations
|
||||
if (budget.Status == BudgetStatus.Yellow)
|
||||
{
|
||||
recommendations.Add("Budget at Yellow status. Prioritize reliability work to restore capacity.");
|
||||
}
|
||||
|
||||
if (budget.Status == BudgetStatus.Red)
|
||||
{
|
||||
recommendations.Add("Budget at Red status. Defer high-risk changes or decompose into smaller diffs.");
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input for gate selection.
|
||||
/// </summary>
|
||||
public sealed record GateSelectionInput
|
||||
{
|
||||
public required string ServiceId { get; init; }
|
||||
public required ServiceTier Tier { get; init; }
|
||||
public required DiffCategory DiffCategory { get; init; }
|
||||
public required OperationalContext Context { get; init; }
|
||||
public required MitigationFactors Mitigations { get; init; }
|
||||
public bool IsEmergencyFix { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of gate selection.
|
||||
/// </summary>
|
||||
public sealed record GateSelectionResult
|
||||
{
|
||||
public required GateLevel Gate { get; init; }
|
||||
public required int RiskScore { get; init; }
|
||||
public required RiskScoreBreakdown ScoreBreakdown { get; init; }
|
||||
public required RiskBudget Budget { get; init; }
|
||||
public required bool IsBlocked { get; init; }
|
||||
public string? BlockReason { get; init; }
|
||||
public IReadOnlyList<string> Requirements { get; init; } = [];
|
||||
public IReadOnlyList<string> Recommendations { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for gate selection.
|
||||
/// </summary>
|
||||
public interface IGateSelector
|
||||
{
|
||||
Task<GateSelectionResult> SelectGateAsync(GateSelectionInput input, CancellationToken ct = default);
|
||||
}
|
||||
136
src/Policy/__Libraries/StellaOps.Policy/Gates/RiskBudget.cs
Normal file
136
src/Policy/__Libraries/StellaOps.Policy/Gates/RiskBudget.cs
Normal file
@@ -0,0 +1,136 @@
|
||||
namespace StellaOps.Policy.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a risk budget for a service/product.
|
||||
/// Tracks risk point allocation and consumption.
|
||||
/// </summary>
|
||||
public sealed record RiskBudget
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this budget.
|
||||
/// </summary>
|
||||
public required string BudgetId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Service or product this budget applies to.
|
||||
/// </summary>
|
||||
public required string ServiceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Criticality tier (0-3).
|
||||
/// </summary>
|
||||
public required ServiceTier Tier { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Budget window (e.g., "2025-01" for monthly).
|
||||
/// </summary>
|
||||
public required string Window { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total risk points allocated for this window.
|
||||
/// </summary>
|
||||
public required int Allocated { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Risk points consumed so far.
|
||||
/// </summary>
|
||||
public int Consumed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Risk points remaining.
|
||||
/// </summary>
|
||||
public int Remaining => Allocated - Consumed;
|
||||
|
||||
/// <summary>
|
||||
/// Percentage of budget used.
|
||||
/// </summary>
|
||||
public decimal PercentageUsed => Allocated > 0
|
||||
? (decimal)Consumed / Allocated * 100
|
||||
: 0;
|
||||
|
||||
/// <summary>
|
||||
/// Current operating status.
|
||||
/// </summary>
|
||||
public BudgetStatus Status => PercentageUsed switch
|
||||
{
|
||||
< 40 => BudgetStatus.Green,
|
||||
< 70 => BudgetStatus.Yellow,
|
||||
< 100 => BudgetStatus.Red,
|
||||
_ => BudgetStatus.Exhausted
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Last updated timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service criticality tiers.
|
||||
/// </summary>
|
||||
public enum ServiceTier
|
||||
{
|
||||
/// <summary>
|
||||
/// Tier 0: Internal only, low business impact.
|
||||
/// </summary>
|
||||
Internal = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Tier 1: Customer-facing non-critical.
|
||||
/// </summary>
|
||||
CustomerFacingNonCritical = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Tier 2: Customer-facing critical.
|
||||
/// </summary>
|
||||
CustomerFacingCritical = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Tier 3: Safety/financial/data-critical.
|
||||
/// </summary>
|
||||
SafetyCritical = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Budget operating status.
|
||||
/// </summary>
|
||||
public enum BudgetStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Green: >= 60% remaining. Normal operation.
|
||||
/// </summary>
|
||||
Green,
|
||||
|
||||
/// <summary>
|
||||
/// Yellow: 30-59% remaining. Increased caution.
|
||||
/// </summary>
|
||||
Yellow,
|
||||
|
||||
/// <summary>
|
||||
/// Red: Less than 30% remaining. Freeze high-risk diffs.
|
||||
/// </summary>
|
||||
Red,
|
||||
|
||||
/// <summary>
|
||||
/// Exhausted: 0% or less remaining. Incident/security fixes only.
|
||||
/// </summary>
|
||||
Exhausted
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default budget allocations by tier.
|
||||
/// </summary>
|
||||
public static class DefaultBudgetAllocations
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the default monthly allocation for a service tier.
|
||||
/// </summary>
|
||||
public static int GetMonthlyAllocation(ServiceTier tier) => tier switch
|
||||
{
|
||||
ServiceTier.Internal => 300,
|
||||
ServiceTier.CustomerFacingNonCritical => 200,
|
||||
ServiceTier.CustomerFacingCritical => 120,
|
||||
ServiceTier.SafetyCritical => 80,
|
||||
_ => 100
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Policy.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Calculates Release Risk Score (RRS) for changes.
|
||||
/// RRS = Base(criticality) + Diff Risk + Operational Context - Mitigations
|
||||
/// </summary>
|
||||
public sealed class RiskPointScoring : IRiskPointScoring
|
||||
{
|
||||
private readonly RiskScoringOptions _options;
|
||||
|
||||
public RiskPointScoring(IOptionsMonitor<RiskScoringOptions>? options = null)
|
||||
{
|
||||
_options = options?.CurrentValue ?? RiskScoringOptions.Default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the Release Risk Score for a change.
|
||||
/// </summary>
|
||||
public RiskScoreResult CalculateScore(RiskScoreInput input)
|
||||
{
|
||||
var breakdown = new RiskScoreBreakdown();
|
||||
|
||||
// Base score from service tier
|
||||
var baseScore = GetBaseScore(input.Tier);
|
||||
breakdown.Base = baseScore;
|
||||
|
||||
// Diff risk (additive)
|
||||
var diffRisk = CalculateDiffRisk(input.DiffCategory);
|
||||
breakdown.DiffRisk = diffRisk;
|
||||
|
||||
// Operational context (additive)
|
||||
var operationalContext = CalculateOperationalContext(input.Context);
|
||||
breakdown.OperationalContext = operationalContext;
|
||||
|
||||
// Mitigations (subtract)
|
||||
var mitigations = CalculateMitigations(input.Mitigations);
|
||||
breakdown.Mitigations = mitigations;
|
||||
|
||||
// Total (minimum 1)
|
||||
var total = Math.Max(1, baseScore + diffRisk + operationalContext - mitigations);
|
||||
breakdown.Total = total;
|
||||
|
||||
// Determine gate level
|
||||
var gate = DetermineGateLevel(total, input.Context.BudgetStatus);
|
||||
|
||||
return new RiskScoreResult
|
||||
{
|
||||
Score = total,
|
||||
Breakdown = breakdown,
|
||||
RecommendedGate = gate
|
||||
};
|
||||
}
|
||||
|
||||
private int GetBaseScore(ServiceTier tier)
|
||||
{
|
||||
return tier switch
|
||||
{
|
||||
ServiceTier.Internal => _options.BaseScores.Tier0,
|
||||
ServiceTier.CustomerFacingNonCritical => _options.BaseScores.Tier1,
|
||||
ServiceTier.CustomerFacingCritical => _options.BaseScores.Tier2,
|
||||
ServiceTier.SafetyCritical => _options.BaseScores.Tier3,
|
||||
_ => 1
|
||||
};
|
||||
}
|
||||
|
||||
private static int CalculateDiffRisk(DiffCategory category)
|
||||
{
|
||||
return category switch
|
||||
{
|
||||
DiffCategory.DocsOnly => 1,
|
||||
DiffCategory.UiNonCore => 3,
|
||||
DiffCategory.ApiBackwardCompatible => 6,
|
||||
DiffCategory.ApiBreaking => 12,
|
||||
DiffCategory.DatabaseMigration => 10,
|
||||
DiffCategory.AuthPermission => 10,
|
||||
DiffCategory.InfraNetworking => 15,
|
||||
DiffCategory.CryptoPayment => 15,
|
||||
DiffCategory.Other => 3,
|
||||
_ => 3
|
||||
};
|
||||
}
|
||||
|
||||
private static int CalculateOperationalContext(OperationalContext context)
|
||||
{
|
||||
var score = 0;
|
||||
|
||||
if (context.HasRecentIncident)
|
||||
score += 5;
|
||||
|
||||
if (context.ErrorBudgetBelow50Percent)
|
||||
score += 3;
|
||||
|
||||
if (context.HighOnCallLoad)
|
||||
score += 2;
|
||||
|
||||
if (context.InRestrictedWindow)
|
||||
score += 5;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static int CalculateMitigations(MitigationFactors mitigations)
|
||||
{
|
||||
var reduction = 0;
|
||||
|
||||
if (mitigations.HasFeatureFlag)
|
||||
reduction += 3;
|
||||
|
||||
if (mitigations.HasCanaryDeployment)
|
||||
reduction += 3;
|
||||
|
||||
if (mitigations.HasHighTestCoverage)
|
||||
reduction += 2;
|
||||
|
||||
if (mitigations.HasBackwardCompatibleMigration)
|
||||
reduction += 2;
|
||||
|
||||
if (mitigations.HasPermissionBoundary)
|
||||
reduction += 2;
|
||||
|
||||
return reduction;
|
||||
}
|
||||
|
||||
private static GateLevel DetermineGateLevel(int score, BudgetStatus budgetStatus)
|
||||
{
|
||||
var baseGate = score switch
|
||||
{
|
||||
<= 5 => GateLevel.G1,
|
||||
<= 12 => GateLevel.G2,
|
||||
<= 20 => GateLevel.G3,
|
||||
_ => GateLevel.G4
|
||||
};
|
||||
|
||||
// Escalate based on budget status
|
||||
return budgetStatus switch
|
||||
{
|
||||
BudgetStatus.Yellow when baseGate >= GateLevel.G2 => EscalateGate(baseGate),
|
||||
BudgetStatus.Red when baseGate >= GateLevel.G1 => EscalateGate(baseGate),
|
||||
BudgetStatus.Exhausted => GateLevel.G4,
|
||||
_ => baseGate
|
||||
};
|
||||
}
|
||||
|
||||
private static GateLevel EscalateGate(GateLevel gate) =>
|
||||
gate < GateLevel.G4 ? gate + 1 : GateLevel.G4;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input for risk score calculation.
|
||||
/// </summary>
|
||||
public sealed record RiskScoreInput
|
||||
{
|
||||
public required ServiceTier Tier { get; init; }
|
||||
public required DiffCategory DiffCategory { get; init; }
|
||||
public required OperationalContext Context { get; init; }
|
||||
public required MitigationFactors Mitigations { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Categories of diffs affecting risk score.
|
||||
/// </summary>
|
||||
public enum DiffCategory
|
||||
{
|
||||
DocsOnly,
|
||||
UiNonCore,
|
||||
ApiBackwardCompatible,
|
||||
ApiBreaking,
|
||||
DatabaseMigration,
|
||||
AuthPermission,
|
||||
InfraNetworking,
|
||||
CryptoPayment,
|
||||
Other
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Operational context affecting risk.
|
||||
/// </summary>
|
||||
public sealed record OperationalContext
|
||||
{
|
||||
public bool HasRecentIncident { get; init; }
|
||||
public bool ErrorBudgetBelow50Percent { get; init; }
|
||||
public bool HighOnCallLoad { get; init; }
|
||||
public bool InRestrictedWindow { get; init; }
|
||||
public BudgetStatus BudgetStatus { get; init; }
|
||||
|
||||
public static OperationalContext Default { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mitigation factors that reduce risk.
|
||||
/// </summary>
|
||||
public sealed record MitigationFactors
|
||||
{
|
||||
public bool HasFeatureFlag { get; init; }
|
||||
public bool HasCanaryDeployment { get; init; }
|
||||
public bool HasHighTestCoverage { get; init; }
|
||||
public bool HasBackwardCompatibleMigration { get; init; }
|
||||
public bool HasPermissionBoundary { get; init; }
|
||||
|
||||
public static MitigationFactors None { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of risk score calculation.
|
||||
/// </summary>
|
||||
public sealed record RiskScoreResult
|
||||
{
|
||||
public required int Score { get; init; }
|
||||
public required RiskScoreBreakdown Breakdown { get; init; }
|
||||
public required GateLevel RecommendedGate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Breakdown of score components.
|
||||
/// </summary>
|
||||
public sealed record RiskScoreBreakdown
|
||||
{
|
||||
public int Base { get; set; }
|
||||
public int DiffRisk { get; set; }
|
||||
public int OperationalContext { get; set; }
|
||||
public int Mitigations { get; set; }
|
||||
public int Total { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for risk scoring.
|
||||
/// </summary>
|
||||
public sealed record RiskScoringOptions
|
||||
{
|
||||
public BaseScoresByTier BaseScores { get; init; } = new();
|
||||
|
||||
public static RiskScoringOptions Default { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base scores by service tier.
|
||||
/// </summary>
|
||||
public sealed record BaseScoresByTier
|
||||
{
|
||||
public int Tier0 { get; init; } = 1;
|
||||
public int Tier1 { get; init; } = 3;
|
||||
public int Tier2 { get; init; } = 6;
|
||||
public int Tier3 { get; init; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for risk point scoring.
|
||||
/// </summary>
|
||||
public interface IRiskPointScoring
|
||||
{
|
||||
RiskScoreResult CalculateScore(RiskScoreInput input);
|
||||
}
|
||||
@@ -43,6 +43,12 @@ public sealed record PolicyExplanation(
|
||||
/// </summary>
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Counterfactual suggestions for what would flip this decision to Pass.
|
||||
/// Only populated for non-Pass decisions. Per SPRINT_4200_0002_0005.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> WouldPassIf { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
public static PolicyExplanation Allow(string findingId, string? ruleName, string reason, params PolicyExplanationNode[] nodes) =>
|
||||
new(findingId, PolicyVerdictStatus.Pass, ruleName, reason, nodes.ToImmutableArray());
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Policy.Snapshots;
|
||||
|
||||
namespace StellaOps.Policy.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves knowledge sources from snapshot descriptors.
|
||||
/// </summary>
|
||||
public sealed class KnowledgeSourceResolver : IKnowledgeSourceResolver
|
||||
{
|
||||
private readonly ISnapshotStore _snapshotStore;
|
||||
private readonly ILogger<KnowledgeSourceResolver> _logger;
|
||||
|
||||
public KnowledgeSourceResolver(
|
||||
ISnapshotStore snapshotStore,
|
||||
ILogger<KnowledgeSourceResolver>? logger = null)
|
||||
{
|
||||
_snapshotStore = snapshotStore ?? throw new ArgumentNullException(nameof(snapshotStore));
|
||||
_logger = logger ?? NullLogger<KnowledgeSourceResolver>.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a knowledge source to its actual content.
|
||||
/// </summary>
|
||||
public async Task<ResolvedSource?> ResolveAsync(
|
||||
KnowledgeSourceDescriptor descriptor,
|
||||
bool allowNetworkFetch,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogDebug("Resolving source {Name} ({Type})", descriptor.Name, descriptor.Type);
|
||||
|
||||
// Try bundled content first
|
||||
if (descriptor.InclusionMode != SourceInclusionMode.Referenced &&
|
||||
descriptor.BundlePath is not null)
|
||||
{
|
||||
var bundled = await ResolveBundledAsync(descriptor, ct).ConfigureAwait(false);
|
||||
if (bundled is not null)
|
||||
return bundled;
|
||||
}
|
||||
|
||||
// Try local store by digest
|
||||
var local = await ResolveFromLocalStoreAsync(descriptor, ct).ConfigureAwait(false);
|
||||
if (local is not null)
|
||||
return local;
|
||||
|
||||
// Network fetch not implemented yet (air-gap safe default)
|
||||
if (allowNetworkFetch && descriptor.Origin is not null)
|
||||
{
|
||||
_logger.LogWarning("Network fetch not implemented for {Name}", descriptor.Name);
|
||||
}
|
||||
|
||||
_logger.LogWarning("Failed to resolve source {Name} with digest {Digest}",
|
||||
descriptor.Name, descriptor.Digest);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<ResolvedSource?> ResolveBundledAsync(
|
||||
KnowledgeSourceDescriptor descriptor,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = await _snapshotStore.GetBundledContentAsync(descriptor.BundlePath!, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (content is null)
|
||||
return null;
|
||||
|
||||
// Verify digest
|
||||
var actualDigest = ComputeDigest(content);
|
||||
if (actualDigest != descriptor.Digest)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Bundled source {Name} digest mismatch: expected {Expected}, got {Actual}",
|
||||
descriptor.Name, descriptor.Digest, actualDigest);
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ResolvedSource(
|
||||
descriptor.Name,
|
||||
descriptor.Type,
|
||||
content,
|
||||
SourceResolutionMethod.Bundled);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to resolve bundled source {Name}", descriptor.Name);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ResolvedSource?> ResolveFromLocalStoreAsync(
|
||||
KnowledgeSourceDescriptor descriptor,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = await _snapshotStore.GetByDigestAsync(descriptor.Digest, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (content is null)
|
||||
return null;
|
||||
|
||||
return new ResolvedSource(
|
||||
descriptor.Name,
|
||||
descriptor.Type,
|
||||
content,
|
||||
SourceResolutionMethod.LocalStore);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to resolve source {Name} from local store", descriptor.Name);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeDigest(byte[] content)
|
||||
{
|
||||
var hash = SHA256.HashData(content);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolved knowledge source with content.
|
||||
/// </summary>
|
||||
public sealed record ResolvedSource(
|
||||
string Name,
|
||||
string Type,
|
||||
byte[] Content,
|
||||
SourceResolutionMethod Method);
|
||||
|
||||
/// <summary>
|
||||
/// Method used to resolve a source.
|
||||
/// </summary>
|
||||
public enum SourceResolutionMethod
|
||||
{
|
||||
Bundled,
|
||||
LocalStore,
|
||||
NetworkFetch
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for source resolution.
|
||||
/// </summary>
|
||||
public interface IKnowledgeSourceResolver
|
||||
{
|
||||
Task<ResolvedSource?> ResolveAsync(
|
||||
KnowledgeSourceDescriptor descriptor,
|
||||
bool allowNetworkFetch,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Frozen inputs for replay.
|
||||
/// </summary>
|
||||
public sealed class FrozenInputs
|
||||
{
|
||||
public Dictionary<string, ResolvedSource> ResolvedSources { get; } = new();
|
||||
public IReadOnlyList<string> MissingSources { get; init; } = [];
|
||||
public bool IsComplete => MissingSources.Count == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for frozen inputs.
|
||||
/// </summary>
|
||||
public sealed class FrozenInputsBuilder
|
||||
{
|
||||
private readonly Dictionary<string, ResolvedSource> _sources = new();
|
||||
|
||||
public FrozenInputsBuilder AddSource(string name, ResolvedSource source)
|
||||
{
|
||||
_sources[name] = source;
|
||||
return this;
|
||||
}
|
||||
|
||||
public FrozenInputs Build(IReadOnlyList<string> missingSources)
|
||||
{
|
||||
var inputs = new FrozenInputs
|
||||
{
|
||||
MissingSources = missingSources
|
||||
};
|
||||
|
||||
// Copy resolved sources
|
||||
foreach (var kvp in _sources)
|
||||
{
|
||||
inputs.ResolvedSources[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
return inputs;
|
||||
}
|
||||
}
|
||||
263
src/Policy/__Libraries/StellaOps.Policy/Replay/ReplayEngine.cs
Normal file
263
src/Policy/__Libraries/StellaOps.Policy/Replay/ReplayEngine.cs
Normal file
@@ -0,0 +1,263 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Policy.Snapshots;
|
||||
|
||||
namespace StellaOps.Policy.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// Engine for replaying policy evaluations with frozen inputs.
|
||||
/// </summary>
|
||||
public sealed class ReplayEngine : IReplayEngine
|
||||
{
|
||||
private readonly ISnapshotService _snapshotService;
|
||||
private readonly IKnowledgeSourceResolver _sourceResolver;
|
||||
private readonly IVerdictComparer _verdictComparer;
|
||||
private readonly ILogger<ReplayEngine> _logger;
|
||||
|
||||
public ReplayEngine(
|
||||
ISnapshotService snapshotService,
|
||||
IKnowledgeSourceResolver sourceResolver,
|
||||
IVerdictComparer verdictComparer,
|
||||
ILogger<ReplayEngine>? logger = null)
|
||||
{
|
||||
_snapshotService = snapshotService ?? throw new ArgumentNullException(nameof(snapshotService));
|
||||
_sourceResolver = sourceResolver ?? throw new ArgumentNullException(nameof(sourceResolver));
|
||||
_verdictComparer = verdictComparer ?? throw new ArgumentNullException(nameof(verdictComparer));
|
||||
_logger = logger ?? NullLogger<ReplayEngine>.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replays a policy evaluation with frozen inputs from a snapshot.
|
||||
/// </summary>
|
||||
public async Task<ReplayResult> ReplayAsync(
|
||||
ReplayRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting replay for artifact {Artifact} with snapshot {Snapshot}",
|
||||
request.ArtifactDigest, request.SnapshotId);
|
||||
|
||||
// Step 1: Load and verify snapshot
|
||||
var snapshot = await LoadAndVerifySnapshotAsync(request.SnapshotId, ct).ConfigureAwait(false);
|
||||
if (snapshot is null)
|
||||
{
|
||||
return ReplayResult.Failed(request.SnapshotId, "Snapshot not found or invalid");
|
||||
}
|
||||
|
||||
// Step 2: Resolve frozen inputs from snapshot
|
||||
var frozenInputs = await ResolveFrozenInputsAsync(snapshot, request.Options, ct).ConfigureAwait(false);
|
||||
if (!frozenInputs.IsComplete)
|
||||
{
|
||||
return ReplayResult.Failed(
|
||||
request.SnapshotId,
|
||||
$"Missing inputs: {string.Join(", ", frozenInputs.MissingSources)}");
|
||||
}
|
||||
|
||||
// Step 3: Execute evaluation with frozen inputs (simulated for now)
|
||||
var replayedVerdict = ExecuteWithFrozenInputs(request.ArtifactDigest, frozenInputs, snapshot);
|
||||
|
||||
// Step 4: Load original verdict for comparison (if available)
|
||||
ReplayedVerdict? originalVerdict = null;
|
||||
if (request.OriginalVerdictId is not null && request.Options.CompareWithOriginal)
|
||||
{
|
||||
originalVerdict = await LoadOriginalVerdictAsync(request.OriginalVerdictId, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Step 5: Compare and generate result
|
||||
var comparisonResult = originalVerdict is not null
|
||||
? _verdictComparer.Compare(replayedVerdict, originalVerdict, VerdictComparisonOptions.Default)
|
||||
: null;
|
||||
|
||||
var matchStatus = comparisonResult?.MatchStatus ?? ReplayMatchStatus.NoComparison;
|
||||
var deltaReport = matchStatus == ReplayMatchStatus.Mismatch && request.Options.GenerateDetailedReport
|
||||
? GenerateDeltaReport(replayedVerdict, originalVerdict!, comparisonResult!)
|
||||
: null;
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Replay completed for {Artifact}: Status={Status}, Duration={Duration}ms",
|
||||
request.ArtifactDigest, matchStatus, stopwatch.ElapsedMilliseconds);
|
||||
|
||||
return new ReplayResult
|
||||
{
|
||||
MatchStatus = matchStatus,
|
||||
ReplayedVerdict = replayedVerdict,
|
||||
OriginalVerdict = originalVerdict,
|
||||
DeltaReport = deltaReport,
|
||||
SnapshotId = request.SnapshotId,
|
||||
ReplayedAt = DateTimeOffset.UtcNow,
|
||||
Duration = stopwatch.Elapsed
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<KnowledgeSnapshotManifest?> LoadAndVerifySnapshotAsync(
|
||||
string snapshotId, CancellationToken ct)
|
||||
{
|
||||
var snapshot = await _snapshotService.GetSnapshotAsync(snapshotId, ct).ConfigureAwait(false);
|
||||
if (snapshot is null)
|
||||
{
|
||||
_logger.LogWarning("Snapshot {SnapshotId} not found", snapshotId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var verification = await _snapshotService.VerifySnapshotAsync(snapshot, ct).ConfigureAwait(false);
|
||||
if (!verification.IsValid)
|
||||
{
|
||||
_logger.LogWarning("Snapshot {SnapshotId} verification failed: {Error}",
|
||||
snapshotId, verification.Error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private async Task<FrozenInputs> ResolveFrozenInputsAsync(
|
||||
KnowledgeSnapshotManifest snapshot,
|
||||
ReplayOptions options,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var builder = new FrozenInputsBuilder();
|
||||
var missingSources = new List<string>();
|
||||
|
||||
foreach (var source in snapshot.Sources)
|
||||
{
|
||||
// Referenced sources are metadata-only and don't need resolution
|
||||
if (source.InclusionMode == SourceInclusionMode.Referenced)
|
||||
{
|
||||
_logger.LogDebug("Source {Name} is referenced-only, skipping resolution", source.Name);
|
||||
// Add a placeholder for deterministic hash computation
|
||||
builder.AddSource(source.Name, new ResolvedSource(
|
||||
source.Name,
|
||||
source.Type,
|
||||
System.Text.Encoding.UTF8.GetBytes(source.Digest),
|
||||
SourceResolutionMethod.LocalStore));
|
||||
continue;
|
||||
}
|
||||
|
||||
var resolved = await _sourceResolver.ResolveAsync(source, options.AllowNetworkFetch, ct)
|
||||
.ConfigureAwait(false);
|
||||
if (resolved is not null)
|
||||
{
|
||||
builder.AddSource(source.Name, resolved);
|
||||
}
|
||||
else
|
||||
{
|
||||
missingSources.Add($"{source.Name}:{source.Digest}");
|
||||
}
|
||||
}
|
||||
|
||||
return builder.Build(missingSources);
|
||||
}
|
||||
|
||||
private ReplayedVerdict ExecuteWithFrozenInputs(
|
||||
string artifactDigest,
|
||||
FrozenInputs frozenInputs,
|
||||
KnowledgeSnapshotManifest snapshot)
|
||||
{
|
||||
// Deterministic evaluation using frozen inputs
|
||||
// In a real implementation, this would call the policy evaluator with the frozen inputs
|
||||
// For now, produce a deterministic result based on input hashes
|
||||
|
||||
var inputHash = ComputeInputHash(frozenInputs);
|
||||
var score = ComputeDeterministicScore(inputHash);
|
||||
var decision = score >= 70 ? ReplayDecision.Pass : ReplayDecision.Fail;
|
||||
|
||||
return new ReplayedVerdict
|
||||
{
|
||||
ArtifactDigest = artifactDigest,
|
||||
Decision = decision,
|
||||
Score = score,
|
||||
KnowledgeSnapshotId = snapshot.SnapshotId,
|
||||
FindingIds = GenerateDeterministicFindings(inputHash)
|
||||
};
|
||||
}
|
||||
|
||||
private static int ComputeInputHash(FrozenInputs inputs)
|
||||
{
|
||||
// Deterministic hash based on resolved sources
|
||||
var hash = 17;
|
||||
foreach (var source in inputs.ResolvedSources.OrderBy(s => s.Key))
|
||||
{
|
||||
hash = hash * 31 + source.Key.GetHashCode(StringComparison.Ordinal);
|
||||
hash = hash * 31 + source.Value.Content.Length;
|
||||
}
|
||||
return Math.Abs(hash);
|
||||
}
|
||||
|
||||
private static decimal ComputeDeterministicScore(int inputHash)
|
||||
{
|
||||
// Produce deterministic score 0-100 based on hash
|
||||
return (inputHash % 10000) / 100m;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> GenerateDeterministicFindings(int inputHash)
|
||||
{
|
||||
// Generate deterministic finding count based on hash
|
||||
var count = inputHash % 5;
|
||||
var findings = new List<string>();
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
findings.Add($"CVE-2024-{(inputHash + i) % 10000:D4}");
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
private Task<ReplayedVerdict?> LoadOriginalVerdictAsync(string verdictId, CancellationToken ct)
|
||||
{
|
||||
// In a real implementation, load from verdict store
|
||||
// For now, return null to indicate no original available
|
||||
_logger.LogDebug("Original verdict {VerdictId} lookup not implemented", verdictId);
|
||||
return Task.FromResult<ReplayedVerdict?>(null);
|
||||
}
|
||||
|
||||
private static ReplayDeltaReport GenerateDeltaReport(
|
||||
ReplayedVerdict replayed,
|
||||
ReplayedVerdict original,
|
||||
VerdictComparisonResult comparison)
|
||||
{
|
||||
var fieldDeltas = new List<FieldDelta>();
|
||||
var findingDeltas = new List<FindingDelta>();
|
||||
var suspectedCauses = new List<string>();
|
||||
|
||||
// Convert comparison differences to field deltas
|
||||
foreach (var diff in comparison.Differences)
|
||||
{
|
||||
if (diff.Field.StartsWith("Finding:", StringComparison.Ordinal))
|
||||
{
|
||||
var findingId = diff.Field.Replace("Finding:", "", StringComparison.Ordinal);
|
||||
var type = diff.ReplayedValue == "absent" ? DeltaType.Removed : DeltaType.Added;
|
||||
findingDeltas.Add(new FindingDelta(findingId, type, null));
|
||||
}
|
||||
else
|
||||
{
|
||||
fieldDeltas.Add(new FieldDelta(diff.Field, diff.OriginalValue, diff.ReplayedValue));
|
||||
}
|
||||
}
|
||||
|
||||
if (findingDeltas.Count > 0)
|
||||
suspectedCauses.Add("Advisory data differences");
|
||||
|
||||
if (fieldDeltas.Any(d => d.FieldName == "Score"))
|
||||
suspectedCauses.Add("Scoring rule changes");
|
||||
|
||||
return new ReplayDeltaReport
|
||||
{
|
||||
Summary = $"{fieldDeltas.Count} field(s) and {findingDeltas.Count} finding(s) differ",
|
||||
FieldDeltas = fieldDeltas,
|
||||
FindingDeltas = findingDeltas,
|
||||
SuspectedCauses = suspectedCauses
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for replay engine.
|
||||
/// </summary>
|
||||
public interface IReplayEngine
|
||||
{
|
||||
Task<ReplayResult> ReplayAsync(ReplayRequest request, CancellationToken ct = default);
|
||||
}
|
||||
216
src/Policy/__Libraries/StellaOps.Policy/Replay/ReplayReport.cs
Normal file
216
src/Policy/__Libraries/StellaOps.Policy/Replay/ReplayReport.cs
Normal file
@@ -0,0 +1,216 @@
|
||||
namespace StellaOps.Policy.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// Detailed report of a replay operation.
|
||||
/// </summary>
|
||||
public sealed record ReplayReport
|
||||
{
|
||||
/// <summary>
|
||||
/// Report ID for reference.
|
||||
/// </summary>
|
||||
public required string ReportId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the report was generated.
|
||||
/// </summary>
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact that was evaluated.
|
||||
/// </summary>
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot used for replay.
|
||||
/// </summary>
|
||||
public required string SnapshotId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original verdict ID (if compared).
|
||||
/// </summary>
|
||||
public string? OriginalVerdictId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overall match status.
|
||||
/// </summary>
|
||||
public required ReplayMatchStatus MatchStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the evaluation is deterministic.
|
||||
/// </summary>
|
||||
public required bool IsDeterministic { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level in determinism (0.0 to 1.0).
|
||||
/// </summary>
|
||||
public required decimal DeterminismConfidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Summary of differences found.
|
||||
/// </summary>
|
||||
public required DifferenceSummary Differences { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Input resolution details.
|
||||
/// </summary>
|
||||
public required InputResolutionSummary InputResolution { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Execution timing.
|
||||
/// </summary>
|
||||
public required ExecutionTiming Timing { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Recommendations based on results.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Recommendations { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of differences found.
|
||||
/// </summary>
|
||||
public sealed record DifferenceSummary
|
||||
{
|
||||
public int TotalDifferences { get; init; }
|
||||
public int CriticalDifferences { get; init; }
|
||||
public int MinorDifferences { get; init; }
|
||||
public int FindingDifferences { get; init; }
|
||||
public IReadOnlyList<VerdictDifference> TopDifferences { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of input resolution.
|
||||
/// </summary>
|
||||
public sealed record InputResolutionSummary
|
||||
{
|
||||
public int TotalSources { get; init; }
|
||||
public int ResolvedFromBundle { get; init; }
|
||||
public int ResolvedFromLocalStore { get; init; }
|
||||
public int ResolvedFromNetwork { get; init; }
|
||||
public int FailedToResolve { get; init; }
|
||||
public IReadOnlyList<string> MissingSources { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execution timing breakdown.
|
||||
/// </summary>
|
||||
public sealed record ExecutionTiming
|
||||
{
|
||||
public TimeSpan TotalDuration { get; init; }
|
||||
public TimeSpan SnapshotLoadTime { get; init; }
|
||||
public TimeSpan InputResolutionTime { get; init; }
|
||||
public TimeSpan EvaluationTime { get; init; }
|
||||
public TimeSpan ComparisonTime { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for creating replay reports.
|
||||
/// </summary>
|
||||
public sealed class ReplayReportBuilder
|
||||
{
|
||||
private readonly ReplayResult _result;
|
||||
private readonly ReplayRequest _request;
|
||||
private readonly List<string> _recommendations = [];
|
||||
|
||||
public ReplayReportBuilder(ReplayRequest request, ReplayResult result)
|
||||
{
|
||||
_request = request ?? throw new ArgumentNullException(nameof(request));
|
||||
_result = result ?? throw new ArgumentNullException(nameof(result));
|
||||
}
|
||||
|
||||
public ReplayReportBuilder AddRecommendation(string recommendation)
|
||||
{
|
||||
_recommendations.Add(recommendation);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ReplayReportBuilder AddRecommendationsFromResult()
|
||||
{
|
||||
if (_result.MatchStatus == ReplayMatchStatus.Mismatch)
|
||||
{
|
||||
_recommendations.Add("Review the delta report to identify non-deterministic behavior");
|
||||
_recommendations.Add("Check if advisory feeds have been updated since the original evaluation");
|
||||
}
|
||||
|
||||
if (_result.MatchStatus == ReplayMatchStatus.ReplayFailed)
|
||||
{
|
||||
_recommendations.Add("Ensure the snapshot bundle is complete and accessible");
|
||||
_recommendations.Add("Consider enabling network fetch for missing sources");
|
||||
}
|
||||
|
||||
if (_result.MatchStatus == ReplayMatchStatus.MatchWithinTolerance)
|
||||
{
|
||||
_recommendations.Add("Minor differences detected - review scoring precision settings");
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public ReplayReport Build()
|
||||
{
|
||||
return new ReplayReport
|
||||
{
|
||||
ReportId = $"rpt:{Guid.NewGuid():N}",
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
ArtifactDigest = _request.ArtifactDigest,
|
||||
SnapshotId = _request.SnapshotId,
|
||||
OriginalVerdictId = _request.OriginalVerdictId,
|
||||
MatchStatus = _result.MatchStatus,
|
||||
IsDeterministic = _result.MatchStatus == ReplayMatchStatus.ExactMatch,
|
||||
DeterminismConfidence = CalculateConfidence(),
|
||||
Differences = BuildDifferenceSummary(),
|
||||
InputResolution = BuildInputResolutionSummary(),
|
||||
Timing = BuildExecutionTiming(),
|
||||
Recommendations = _recommendations
|
||||
};
|
||||
}
|
||||
|
||||
private decimal CalculateConfidence() =>
|
||||
_result.MatchStatus switch
|
||||
{
|
||||
ReplayMatchStatus.ExactMatch => 1.0m,
|
||||
ReplayMatchStatus.MatchWithinTolerance => 0.9m,
|
||||
ReplayMatchStatus.Mismatch => 0.0m,
|
||||
ReplayMatchStatus.NoComparison => 0.5m,
|
||||
ReplayMatchStatus.ReplayFailed => 0.0m,
|
||||
_ => 0.5m
|
||||
};
|
||||
|
||||
private DifferenceSummary BuildDifferenceSummary()
|
||||
{
|
||||
if (_result.DeltaReport is null)
|
||||
return new DifferenceSummary();
|
||||
|
||||
var fieldDeltas = _result.DeltaReport.FieldDeltas;
|
||||
var findingDeltas = _result.DeltaReport.FindingDeltas;
|
||||
|
||||
return new DifferenceSummary
|
||||
{
|
||||
TotalDifferences = fieldDeltas.Count + findingDeltas.Count,
|
||||
CriticalDifferences = fieldDeltas.Count(d => d.FieldName is "Decision" or "Score"),
|
||||
MinorDifferences = fieldDeltas.Count(d => d.FieldName is not "Decision" and not "Score"),
|
||||
FindingDifferences = findingDeltas.Count
|
||||
};
|
||||
}
|
||||
|
||||
private InputResolutionSummary BuildInputResolutionSummary()
|
||||
{
|
||||
return new InputResolutionSummary
|
||||
{
|
||||
TotalSources = 0,
|
||||
ResolvedFromBundle = 0,
|
||||
ResolvedFromLocalStore = 0,
|
||||
ResolvedFromNetwork = 0,
|
||||
FailedToResolve = 0,
|
||||
MissingSources = _result.DeltaReport?.SuspectedCauses ?? []
|
||||
};
|
||||
}
|
||||
|
||||
private ExecutionTiming BuildExecutionTiming()
|
||||
{
|
||||
return new ExecutionTiming
|
||||
{
|
||||
TotalDuration = _result.Duration
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
namespace StellaOps.Policy.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// Request to replay a policy evaluation with frozen inputs.
|
||||
/// </summary>
|
||||
public sealed record ReplayRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The artifact to evaluate (same as original).
|
||||
/// </summary>
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ID of the knowledge snapshot to use for replay.
|
||||
/// </summary>
|
||||
public required string SnapshotId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original verdict ID being replayed (for comparison).
|
||||
/// </summary>
|
||||
public string? OriginalVerdictId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Replay options.
|
||||
/// </summary>
|
||||
public ReplayOptions Options { get; init; } = ReplayOptions.Default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options controlling replay behavior.
|
||||
/// </summary>
|
||||
public sealed record ReplayOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether to compare with original verdict.
|
||||
/// </summary>
|
||||
public bool CompareWithOriginal { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to allow network access for missing sources.
|
||||
/// </summary>
|
||||
public bool AllowNetworkFetch { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to generate detailed diff report.
|
||||
/// </summary>
|
||||
public bool GenerateDetailedReport { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Tolerance for score differences (for floating point comparison).
|
||||
/// </summary>
|
||||
public decimal ScoreTolerance { get; init; } = 0.001m;
|
||||
|
||||
public static ReplayOptions Default { get; } = new();
|
||||
}
|
||||
199
src/Policy/__Libraries/StellaOps.Policy/Replay/ReplayResult.cs
Normal file
199
src/Policy/__Libraries/StellaOps.Policy/Replay/ReplayResult.cs
Normal file
@@ -0,0 +1,199 @@
|
||||
namespace StellaOps.Policy.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// Result of a replay operation.
|
||||
/// </summary>
|
||||
public sealed record ReplayResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the replay matched the original verdict.
|
||||
/// </summary>
|
||||
public required ReplayMatchStatus MatchStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The verdict produced by replay.
|
||||
/// </summary>
|
||||
public required ReplayedVerdict ReplayedVerdict { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The original verdict (if available for comparison).
|
||||
/// </summary>
|
||||
public ReplayedVerdict? OriginalVerdict { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed delta report if differences found.
|
||||
/// </summary>
|
||||
public ReplayDeltaReport? DeltaReport { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot used for replay.
|
||||
/// </summary>
|
||||
public required string SnapshotId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When replay was executed.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ReplayedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration of replay execution.
|
||||
/// </summary>
|
||||
public TimeSpan Duration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result.
|
||||
/// </summary>
|
||||
public static ReplayResult Failed(string snapshotId, string error) => new()
|
||||
{
|
||||
MatchStatus = ReplayMatchStatus.ReplayFailed,
|
||||
ReplayedVerdict = ReplayedVerdict.Empty,
|
||||
SnapshotId = snapshotId,
|
||||
ReplayedAt = DateTimeOffset.UtcNow,
|
||||
DeltaReport = new ReplayDeltaReport
|
||||
{
|
||||
Summary = error,
|
||||
SuspectedCauses = [error]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Match status between replayed and original verdict.
|
||||
/// </summary>
|
||||
public enum ReplayMatchStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Verdicts match exactly (deterministic).
|
||||
/// </summary>
|
||||
ExactMatch,
|
||||
|
||||
/// <summary>
|
||||
/// Verdicts match within tolerance.
|
||||
/// </summary>
|
||||
MatchWithinTolerance,
|
||||
|
||||
/// <summary>
|
||||
/// Verdicts differ (non-deterministic or inputs changed).
|
||||
/// </summary>
|
||||
Mismatch,
|
||||
|
||||
/// <summary>
|
||||
/// Original verdict not available for comparison.
|
||||
/// </summary>
|
||||
NoComparison,
|
||||
|
||||
/// <summary>
|
||||
/// Replay failed due to missing inputs.
|
||||
/// </summary>
|
||||
ReplayFailed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detailed report of differences between replayed and original.
|
||||
/// </summary>
|
||||
public sealed record ReplayDeltaReport
|
||||
{
|
||||
/// <summary>
|
||||
/// Summary of the difference.
|
||||
/// </summary>
|
||||
public required string Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Specific fields that differ.
|
||||
/// </summary>
|
||||
public IReadOnlyList<FieldDelta> FieldDeltas { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Findings that differ.
|
||||
/// </summary>
|
||||
public IReadOnlyList<FindingDelta> FindingDeltas { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Input sources that may have caused difference.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> SuspectedCauses { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Difference in a scalar field.
|
||||
/// </summary>
|
||||
public sealed record FieldDelta(
|
||||
string FieldName,
|
||||
string OriginalValue,
|
||||
string ReplayedValue);
|
||||
|
||||
/// <summary>
|
||||
/// Difference in a finding.
|
||||
/// </summary>
|
||||
public sealed record FindingDelta(
|
||||
string FindingId,
|
||||
DeltaType Type,
|
||||
string? Description);
|
||||
|
||||
/// <summary>
|
||||
/// Type of delta change.
|
||||
/// </summary>
|
||||
public enum DeltaType
|
||||
{
|
||||
Added,
|
||||
Removed,
|
||||
Modified
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simplified verdict for replay comparison.
|
||||
/// </summary>
|
||||
public sealed record ReplayedVerdict
|
||||
{
|
||||
/// <summary>
|
||||
/// Verdict ID.
|
||||
/// </summary>
|
||||
public string? VerdictId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact digest evaluated.
|
||||
/// </summary>
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy decision.
|
||||
/// </summary>
|
||||
public required ReplayDecision Decision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Risk score.
|
||||
/// </summary>
|
||||
public required decimal Score { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Finding IDs.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> FindingIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Knowledge snapshot used.
|
||||
/// </summary>
|
||||
public string? KnowledgeSnapshotId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Empty verdict for failed replays.
|
||||
/// </summary>
|
||||
public static ReplayedVerdict Empty { get; } = new()
|
||||
{
|
||||
ArtifactDigest = string.Empty,
|
||||
Decision = ReplayDecision.Unknown,
|
||||
Score = 0
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replay decision outcome.
|
||||
/// </summary>
|
||||
public enum ReplayDecision
|
||||
{
|
||||
Unknown,
|
||||
Pass,
|
||||
Fail,
|
||||
PassWithExceptions,
|
||||
Indeterminate
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
namespace StellaOps.Policy.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// Compares policy evaluation results for determinism verification.
|
||||
/// </summary>
|
||||
public sealed class VerdictComparer : IVerdictComparer
|
||||
{
|
||||
/// <summary>
|
||||
/// Compares two verdicts and returns detailed comparison result.
|
||||
/// </summary>
|
||||
public VerdictComparisonResult Compare(
|
||||
ReplayedVerdict replayed,
|
||||
ReplayedVerdict original,
|
||||
VerdictComparisonOptions options)
|
||||
{
|
||||
var differences = new List<VerdictDifference>();
|
||||
|
||||
// Compare decision
|
||||
if (replayed.Decision != original.Decision)
|
||||
{
|
||||
differences.Add(new VerdictDifference(
|
||||
"Decision",
|
||||
DifferenceCategory.Critical,
|
||||
original.Decision.ToString(),
|
||||
replayed.Decision.ToString()));
|
||||
}
|
||||
|
||||
// Compare score with tolerance
|
||||
var scoreDiff = Math.Abs(replayed.Score - original.Score);
|
||||
if (scoreDiff > 0)
|
||||
{
|
||||
// Record any score difference, categorized by severity
|
||||
DifferenceCategory category;
|
||||
if (scoreDiff > options.CriticalScoreTolerance)
|
||||
category = DifferenceCategory.Critical;
|
||||
else if (scoreDiff > options.ScoreTolerance)
|
||||
category = DifferenceCategory.Minor;
|
||||
else
|
||||
category = DifferenceCategory.Negligible; // Within tolerance
|
||||
|
||||
differences.Add(new VerdictDifference(
|
||||
"Score",
|
||||
category,
|
||||
original.Score.ToString("F4"),
|
||||
replayed.Score.ToString("F4")));
|
||||
}
|
||||
|
||||
// Compare findings
|
||||
var findingDiffs = CompareFindingLists(replayed.FindingIds, original.FindingIds);
|
||||
differences.AddRange(findingDiffs);
|
||||
|
||||
// Determine overall match status
|
||||
var matchStatus = DetermineMatchStatus(differences, options);
|
||||
|
||||
return new VerdictComparisonResult
|
||||
{
|
||||
MatchStatus = matchStatus,
|
||||
Differences = differences,
|
||||
IsDeterministic = matchStatus == ReplayMatchStatus.ExactMatch,
|
||||
DeterminismConfidence = CalculateDeterminismConfidence(differences)
|
||||
};
|
||||
}
|
||||
|
||||
private static IEnumerable<VerdictDifference> CompareFindingLists(
|
||||
IReadOnlyList<string> replayed,
|
||||
IReadOnlyList<string> original)
|
||||
{
|
||||
var replayedSet = replayed.ToHashSet();
|
||||
var originalSet = original.ToHashSet();
|
||||
|
||||
// Findings added in replay
|
||||
foreach (var id in replayedSet.Except(originalSet))
|
||||
{
|
||||
yield return new VerdictDifference(
|
||||
$"Finding:{id}",
|
||||
DifferenceCategory.Finding,
|
||||
"absent",
|
||||
"present");
|
||||
}
|
||||
|
||||
// Findings removed in replay
|
||||
foreach (var id in originalSet.Except(replayedSet))
|
||||
{
|
||||
yield return new VerdictDifference(
|
||||
$"Finding:{id}",
|
||||
DifferenceCategory.Finding,
|
||||
"present",
|
||||
"absent");
|
||||
}
|
||||
}
|
||||
|
||||
private static ReplayMatchStatus DetermineMatchStatus(
|
||||
List<VerdictDifference> differences,
|
||||
VerdictComparisonOptions options)
|
||||
{
|
||||
if (differences.Count == 0)
|
||||
return ReplayMatchStatus.ExactMatch;
|
||||
|
||||
if (differences.Any(d => d.Category == DifferenceCategory.Critical))
|
||||
return ReplayMatchStatus.Mismatch;
|
||||
|
||||
// Negligible = within tolerance, should be MatchWithinTolerance (not ExactMatch)
|
||||
if (differences.All(d => d.Category == DifferenceCategory.Negligible))
|
||||
return ReplayMatchStatus.MatchWithinTolerance;
|
||||
|
||||
// Minor or negligible differences only
|
||||
if (options.TreatMinorAsMatch &&
|
||||
differences.All(d => d.Category is DifferenceCategory.Minor or DifferenceCategory.Negligible))
|
||||
return ReplayMatchStatus.MatchWithinTolerance;
|
||||
|
||||
return ReplayMatchStatus.Mismatch;
|
||||
}
|
||||
|
||||
private static decimal CalculateDeterminismConfidence(List<VerdictDifference> differences)
|
||||
{
|
||||
if (differences.Count == 0)
|
||||
return 1.0m;
|
||||
|
||||
var criticalCount = differences.Count(d => d.Category == DifferenceCategory.Critical);
|
||||
var minorCount = differences.Count(d => d.Category == DifferenceCategory.Minor);
|
||||
var findingCount = differences.Count(d => d.Category == DifferenceCategory.Finding);
|
||||
|
||||
// Simple penalty-based calculation
|
||||
var penalty = (criticalCount * 0.3m) + (minorCount * 0.05m) + (findingCount * 0.1m);
|
||||
return Math.Max(0, 1.0m - penalty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of verdict comparison.
|
||||
/// </summary>
|
||||
public sealed record VerdictComparisonResult
|
||||
{
|
||||
public required ReplayMatchStatus MatchStatus { get; init; }
|
||||
public required IReadOnlyList<VerdictDifference> Differences { get; init; }
|
||||
public required bool IsDeterministic { get; init; }
|
||||
public required decimal DeterminismConfidence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Difference found between verdicts.
|
||||
/// </summary>
|
||||
public sealed record VerdictDifference(
|
||||
string Field,
|
||||
DifferenceCategory Category,
|
||||
string OriginalValue,
|
||||
string ReplayedValue);
|
||||
|
||||
/// <summary>
|
||||
/// Category of difference.
|
||||
/// </summary>
|
||||
public enum DifferenceCategory
|
||||
{
|
||||
Critical,
|
||||
Minor,
|
||||
Negligible,
|
||||
Finding
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for verdict comparison.
|
||||
/// </summary>
|
||||
public sealed record VerdictComparisonOptions
|
||||
{
|
||||
public decimal ScoreTolerance { get; init; } = 0.001m;
|
||||
public decimal CriticalScoreTolerance { get; init; } = 0.1m;
|
||||
public bool TreatMinorAsMatch { get; init; } = true;
|
||||
|
||||
public static VerdictComparisonOptions Default { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for verdict comparison.
|
||||
/// </summary>
|
||||
public interface IVerdictComparer
|
||||
{
|
||||
VerdictComparisonResult Compare(
|
||||
ReplayedVerdict replayed,
|
||||
ReplayedVerdict original,
|
||||
VerdictComparisonOptions options);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
namespace StellaOps.Policy.Snapshots;
|
||||
|
||||
/// <summary>
|
||||
/// Unified manifest for a knowledge snapshot.
|
||||
/// Content-addressed bundle capturing all inputs to a policy evaluation.
|
||||
/// </summary>
|
||||
public sealed record KnowledgeSnapshotManifest
|
||||
{
|
||||
/// <summary>
|
||||
/// Content-addressed snapshot ID: ksm:sha256:{hash}
|
||||
/// </summary>
|
||||
public required string SnapshotId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this snapshot was created (UTC).
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Engine version that created this snapshot.
|
||||
/// </summary>
|
||||
public required EngineInfo Engine { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugins/analyzers active during snapshot creation.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PluginInfo> Plugins { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the policy bundle used.
|
||||
/// </summary>
|
||||
public required PolicyBundleRef Policy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the scoring rules used.
|
||||
/// </summary>
|
||||
public required ScoringRulesRef Scoring { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the trust bundle (root certificates, VEX publishers).
|
||||
/// </summary>
|
||||
public TrustBundleRef? Trust { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Knowledge sources included in this snapshot.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<KnowledgeSourceDescriptor> Sources { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Determinism profile for environment reproducibility.
|
||||
/// </summary>
|
||||
public DeterminismProfile? Environment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional DSSE signature over the manifest.
|
||||
/// </summary>
|
||||
public string? Signature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Manifest format version.
|
||||
/// </summary>
|
||||
public string ManifestVersion { get; init; } = "1.0";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Engine version information.
|
||||
/// </summary>
|
||||
public sealed record EngineInfo(
|
||||
string Name,
|
||||
string Version,
|
||||
string Commit);
|
||||
|
||||
/// <summary>
|
||||
/// Plugin/analyzer information.
|
||||
/// </summary>
|
||||
public sealed record PluginInfo(
|
||||
string Name,
|
||||
string Version,
|
||||
string Type);
|
||||
|
||||
/// <summary>
|
||||
/// Reference to a policy bundle.
|
||||
/// </summary>
|
||||
public sealed record PolicyBundleRef(
|
||||
string PolicyId,
|
||||
string Digest,
|
||||
string? Uri);
|
||||
|
||||
/// <summary>
|
||||
/// Reference to scoring rules.
|
||||
/// </summary>
|
||||
public sealed record ScoringRulesRef(
|
||||
string RulesId,
|
||||
string Digest,
|
||||
string? Uri);
|
||||
|
||||
/// <summary>
|
||||
/// Reference to trust bundle.
|
||||
/// </summary>
|
||||
public sealed record TrustBundleRef(
|
||||
string BundleId,
|
||||
string Digest,
|
||||
string? Uri);
|
||||
|
||||
/// <summary>
|
||||
/// Determinism profile for environment capture.
|
||||
/// </summary>
|
||||
public sealed record DeterminismProfile(
|
||||
string TimezoneOffset,
|
||||
string Locale,
|
||||
string Platform,
|
||||
IReadOnlyDictionary<string, string> EnvironmentVars);
|
||||
@@ -0,0 +1,85 @@
|
||||
namespace StellaOps.Policy.Snapshots;
|
||||
|
||||
/// <summary>
|
||||
/// Descriptor for a knowledge source included in a snapshot.
|
||||
/// </summary>
|
||||
public sealed record KnowledgeSourceDescriptor
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique name of the source (e.g., "nvd", "osv", "vendor-vex").
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of source: "advisory-feed", "vex", "sbom", "reachability", "policy".
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Epoch or version of the source data.
|
||||
/// </summary>
|
||||
public required string Epoch { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content digest of the source data.
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Origin URI where this source was fetched from.
|
||||
/// </summary>
|
||||
public string? Origin { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this source was last updated.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LastUpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Record count or entry count in this source.
|
||||
/// </summary>
|
||||
public int? RecordCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this source is bundled (embedded) or referenced.
|
||||
/// </summary>
|
||||
public SourceInclusionMode InclusionMode { get; init; } = SourceInclusionMode.Referenced;
|
||||
|
||||
/// <summary>
|
||||
/// Relative path within the snapshot bundle (if bundled).
|
||||
/// </summary>
|
||||
public string? BundlePath { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How a source is included in the snapshot.
|
||||
/// </summary>
|
||||
public enum SourceInclusionMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Source is referenced by digest only (requires external fetch for replay).
|
||||
/// </summary>
|
||||
Referenced,
|
||||
|
||||
/// <summary>
|
||||
/// Source content is embedded in the snapshot bundle.
|
||||
/// </summary>
|
||||
Bundled,
|
||||
|
||||
/// <summary>
|
||||
/// Source is bundled and compressed.
|
||||
/// </summary>
|
||||
BundledCompressed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known knowledge source types.
|
||||
/// </summary>
|
||||
public static class KnowledgeSourceTypes
|
||||
{
|
||||
public const string AdvisoryFeed = "advisory-feed";
|
||||
public const string Vex = "vex";
|
||||
public const string Sbom = "sbom";
|
||||
public const string Reachability = "reachability";
|
||||
public const string Policy = "policy";
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Policy.Snapshots;
|
||||
|
||||
/// <summary>
|
||||
/// Wrapper for policy evaluation that binds evaluations to knowledge snapshots.
|
||||
/// </summary>
|
||||
public sealed class SnapshotAwarePolicyEvaluator : ISnapshotAwarePolicyEvaluator
|
||||
{
|
||||
private readonly ISnapshotService _snapshotService;
|
||||
private readonly IKnowledgeSourceProvider _knowledgeSourceProvider;
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly ILogger<SnapshotAwarePolicyEvaluator> _logger;
|
||||
private readonly string _engineVersion;
|
||||
private readonly string _engineCommit;
|
||||
|
||||
public SnapshotAwarePolicyEvaluator(
|
||||
ISnapshotService snapshotService,
|
||||
IKnowledgeSourceProvider knowledgeSourceProvider,
|
||||
ICryptoHash cryptoHash,
|
||||
ILogger<SnapshotAwarePolicyEvaluator> logger,
|
||||
string? engineVersion = null,
|
||||
string? engineCommit = null)
|
||||
{
|
||||
_snapshotService = snapshotService ?? throw new ArgumentNullException(nameof(snapshotService));
|
||||
_knowledgeSourceProvider = knowledgeSourceProvider ?? throw new ArgumentNullException(nameof(knowledgeSourceProvider));
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_engineVersion = engineVersion ?? "1.0.0";
|
||||
_engineCommit = engineCommit ?? "unknown";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a snapshot capturing current knowledge state.
|
||||
/// </summary>
|
||||
public async Task<KnowledgeSnapshotManifest> CaptureCurrentSnapshotAsync(
|
||||
string policyId,
|
||||
string policyDigest,
|
||||
string scoringId,
|
||||
string scoringDigest,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var builder = new SnapshotBuilder(_cryptoHash)
|
||||
.WithEngine("StellaOps.Policy", _engineVersion, _engineCommit)
|
||||
.WithPolicy(policyId, policyDigest)
|
||||
.WithScoring(scoringId, scoringDigest);
|
||||
|
||||
// Add all active knowledge sources
|
||||
var sources = await _knowledgeSourceProvider.GetActiveSourcesAsync(ct).ConfigureAwait(false);
|
||||
foreach (var source in sources)
|
||||
{
|
||||
builder.WithSource(source);
|
||||
}
|
||||
|
||||
builder.CaptureCurrentEnvironment();
|
||||
|
||||
return await _snapshotService.CreateSnapshotAsync(builder, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a snapshot before use in evaluation.
|
||||
/// </summary>
|
||||
public async Task<SnapshotVerificationResult> VerifySnapshotAsync(
|
||||
KnowledgeSnapshotManifest snapshot,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await _snapshotService.VerifySnapshotAsync(snapshot, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binds evaluation metadata to a snapshot.
|
||||
/// </summary>
|
||||
public SnapshotBoundEvaluationResult BindEvaluationToSnapshot(
|
||||
KnowledgeSnapshotManifest snapshot,
|
||||
object evaluationResult)
|
||||
{
|
||||
return new SnapshotBoundEvaluationResult(
|
||||
KnowledgeSnapshotId: snapshot.SnapshotId,
|
||||
SnapshotCreatedAt: snapshot.CreatedAt,
|
||||
ManifestVersion: snapshot.ManifestVersion,
|
||||
EngineVersion: snapshot.Engine.Version,
|
||||
SourceCount: snapshot.Sources.Count,
|
||||
EvaluationResult: evaluationResult);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of policy evaluation bound to a knowledge snapshot.
|
||||
/// </summary>
|
||||
public sealed record SnapshotBoundEvaluationResult(
|
||||
string KnowledgeSnapshotId,
|
||||
DateTimeOffset SnapshotCreatedAt,
|
||||
string ManifestVersion,
|
||||
string EngineVersion,
|
||||
int SourceCount,
|
||||
object EvaluationResult);
|
||||
|
||||
/// <summary>
|
||||
/// Interface for snapshot-aware policy evaluation.
|
||||
/// </summary>
|
||||
public interface ISnapshotAwarePolicyEvaluator
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a snapshot capturing current knowledge state.
|
||||
/// </summary>
|
||||
Task<KnowledgeSnapshotManifest> CaptureCurrentSnapshotAsync(
|
||||
string policyId,
|
||||
string policyDigest,
|
||||
string scoringId,
|
||||
string scoringDigest,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a snapshot before use in evaluation.
|
||||
/// </summary>
|
||||
Task<SnapshotVerificationResult> VerifySnapshotAsync(
|
||||
KnowledgeSnapshotManifest snapshot,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Binds evaluation metadata to a snapshot.
|
||||
/// </summary>
|
||||
SnapshotBoundEvaluationResult BindEvaluationToSnapshot(
|
||||
KnowledgeSnapshotManifest snapshot,
|
||||
object evaluationResult);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provider for active knowledge sources.
|
||||
/// </summary>
|
||||
public interface IKnowledgeSourceProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets all active knowledge sources that should be captured in a snapshot.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<KnowledgeSourceDescriptor>> GetActiveSourcesAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IKnowledgeSourceProvider"/> for testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryKnowledgeSourceProvider : IKnowledgeSourceProvider
|
||||
{
|
||||
private readonly List<KnowledgeSourceDescriptor> _sources = [];
|
||||
private readonly object _lock = new();
|
||||
|
||||
public void AddSource(KnowledgeSourceDescriptor source)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_sources.Add(source);
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearSources()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_sources.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<KnowledgeSourceDescriptor>> GetActiveSourcesAsync(CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
lock (_lock)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<KnowledgeSourceDescriptor>>(_sources.ToList());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Failure reasons for snapshot-based evaluation.
|
||||
/// </summary>
|
||||
public enum SnapshotFailureReason
|
||||
{
|
||||
/// <summary>
|
||||
/// The snapshot failed integrity validation.
|
||||
/// </summary>
|
||||
InvalidSnapshot,
|
||||
|
||||
/// <summary>
|
||||
/// The snapshot signature is invalid.
|
||||
/// </summary>
|
||||
InvalidSignature,
|
||||
|
||||
/// <summary>
|
||||
/// A required knowledge source is missing.
|
||||
/// </summary>
|
||||
MissingSource,
|
||||
|
||||
/// <summary>
|
||||
/// The snapshot has expired.
|
||||
/// </summary>
|
||||
Expired
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Policy.Snapshots;
|
||||
|
||||
/// <summary>
|
||||
/// Fluent builder for constructing knowledge snapshot manifests.
|
||||
/// </summary>
|
||||
public sealed class SnapshotBuilder
|
||||
{
|
||||
private readonly List<KnowledgeSourceDescriptor> _sources = [];
|
||||
private readonly List<PluginInfo> _plugins = [];
|
||||
private EngineInfo? _engine;
|
||||
private PolicyBundleRef? _policy;
|
||||
private ScoringRulesRef? _scoring;
|
||||
private TrustBundleRef? _trust;
|
||||
private DeterminismProfile? _environment;
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
|
||||
public SnapshotBuilder(ICryptoHash cryptoHash)
|
||||
{
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
}
|
||||
|
||||
public SnapshotBuilder WithEngine(string name, string version, string commit)
|
||||
{
|
||||
_engine = new EngineInfo(name, version, commit);
|
||||
return this;
|
||||
}
|
||||
|
||||
public SnapshotBuilder WithPlugin(string name, string version, string type)
|
||||
{
|
||||
_plugins.Add(new PluginInfo(name, version, type));
|
||||
return this;
|
||||
}
|
||||
|
||||
public SnapshotBuilder WithPolicy(string policyId, string digest, string? uri = null)
|
||||
{
|
||||
_policy = new PolicyBundleRef(policyId, digest, uri);
|
||||
return this;
|
||||
}
|
||||
|
||||
public SnapshotBuilder WithScoring(string rulesId, string digest, string? uri = null)
|
||||
{
|
||||
_scoring = new ScoringRulesRef(rulesId, digest, uri);
|
||||
return this;
|
||||
}
|
||||
|
||||
public SnapshotBuilder WithTrust(string bundleId, string digest, string? uri = null)
|
||||
{
|
||||
_trust = new TrustBundleRef(bundleId, digest, uri);
|
||||
return this;
|
||||
}
|
||||
|
||||
public SnapshotBuilder WithSource(KnowledgeSourceDescriptor source)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
_sources.Add(source);
|
||||
return this;
|
||||
}
|
||||
|
||||
public SnapshotBuilder WithAdvisoryFeed(
|
||||
string name, string epoch, string digest, string? origin = null)
|
||||
{
|
||||
_sources.Add(new KnowledgeSourceDescriptor
|
||||
{
|
||||
Name = name,
|
||||
Type = KnowledgeSourceTypes.AdvisoryFeed,
|
||||
Epoch = epoch,
|
||||
Digest = digest,
|
||||
Origin = origin
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public SnapshotBuilder WithVex(string name, string digest, string? origin = null)
|
||||
{
|
||||
_sources.Add(new KnowledgeSourceDescriptor
|
||||
{
|
||||
Name = name,
|
||||
Type = KnowledgeSourceTypes.Vex,
|
||||
Epoch = DateTimeOffset.UtcNow.ToString("o", CultureInfo.InvariantCulture),
|
||||
Digest = digest,
|
||||
Origin = origin
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public SnapshotBuilder WithSbom(string name, string digest, string? origin = null)
|
||||
{
|
||||
_sources.Add(new KnowledgeSourceDescriptor
|
||||
{
|
||||
Name = name,
|
||||
Type = KnowledgeSourceTypes.Sbom,
|
||||
Epoch = DateTimeOffset.UtcNow.ToString("o", CultureInfo.InvariantCulture),
|
||||
Digest = digest,
|
||||
Origin = origin
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public SnapshotBuilder WithReachability(string name, string digest, string? origin = null)
|
||||
{
|
||||
_sources.Add(new KnowledgeSourceDescriptor
|
||||
{
|
||||
Name = name,
|
||||
Type = KnowledgeSourceTypes.Reachability,
|
||||
Epoch = DateTimeOffset.UtcNow.ToString("o", CultureInfo.InvariantCulture),
|
||||
Digest = digest,
|
||||
Origin = origin
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public SnapshotBuilder WithEnvironment(DeterminismProfile environment)
|
||||
{
|
||||
_environment = environment;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SnapshotBuilder CaptureCurrentEnvironment()
|
||||
{
|
||||
_environment = new DeterminismProfile(
|
||||
TimezoneOffset: TimeZoneInfo.Local.BaseUtcOffset.ToString(),
|
||||
Locale: CultureInfo.CurrentCulture.Name,
|
||||
Platform: Environment.OSVersion.ToString(),
|
||||
EnvironmentVars: new Dictionary<string, string>());
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the manifest and computes the content-addressed ID.
|
||||
/// </summary>
|
||||
public KnowledgeSnapshotManifest Build()
|
||||
{
|
||||
if (_engine is null)
|
||||
throw new InvalidOperationException("Engine info is required");
|
||||
if (_policy is null)
|
||||
throw new InvalidOperationException("Policy reference is required");
|
||||
if (_scoring is null)
|
||||
throw new InvalidOperationException("Scoring reference is required");
|
||||
if (_sources.Count == 0)
|
||||
throw new InvalidOperationException("At least one source is required");
|
||||
|
||||
// Create manifest without ID first
|
||||
var manifest = new KnowledgeSnapshotManifest
|
||||
{
|
||||
SnapshotId = "", // Placeholder
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Engine = _engine,
|
||||
Plugins = _plugins.ToList(),
|
||||
Policy = _policy,
|
||||
Scoring = _scoring,
|
||||
Trust = _trust,
|
||||
Sources = _sources.OrderBy(s => s.Name, StringComparer.Ordinal).ToList(),
|
||||
Environment = _environment
|
||||
};
|
||||
|
||||
// Compute content-addressed ID
|
||||
var snapshotId = ComputeSnapshotId(manifest);
|
||||
|
||||
return manifest with { SnapshotId = snapshotId };
|
||||
}
|
||||
|
||||
private string ComputeSnapshotId(KnowledgeSnapshotManifest manifest)
|
||||
{
|
||||
// Serialize to canonical JSON (sorted keys, no whitespace)
|
||||
var json = JsonSerializer.Serialize(manifest with { SnapshotId = "" },
|
||||
SnapshotSerializerOptions.Canonical);
|
||||
|
||||
var hash = _cryptoHash.ComputeHashHex(System.Text.Encoding.UTF8.GetBytes(json), "SHA256");
|
||||
return $"ksm:sha256:{hash}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Centralized JSON serializer options for snapshots.
|
||||
/// </summary>
|
||||
internal static class SnapshotSerializerOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Canonical JSON options for deterministic serialization.
|
||||
/// </summary>
|
||||
public static JsonSerializerOptions Canonical { get; } = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Policy.Snapshots;
|
||||
|
||||
/// <summary>
|
||||
/// Generates and validates content-addressed snapshot IDs.
|
||||
/// </summary>
|
||||
public sealed class SnapshotIdGenerator : ISnapshotIdGenerator
|
||||
{
|
||||
private const string Prefix = "ksm:sha256:";
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
|
||||
public SnapshotIdGenerator(ICryptoHash cryptoHash)
|
||||
{
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a content-addressed ID for a manifest.
|
||||
/// </summary>
|
||||
public string GenerateId(KnowledgeSnapshotManifest manifest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
var canonicalJson = ToCanonicalJson(manifest with { SnapshotId = "", Signature = null });
|
||||
var hash = _cryptoHash.ComputeHashHex(System.Text.Encoding.UTF8.GetBytes(canonicalJson), "SHA256");
|
||||
return $"{Prefix}{hash}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a manifest's ID matches its content.
|
||||
/// </summary>
|
||||
public bool ValidateId(KnowledgeSnapshotManifest manifest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
var expectedId = GenerateId(manifest);
|
||||
return string.Equals(manifest.SnapshotId, expectedId, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a snapshot ID into its components.
|
||||
/// </summary>
|
||||
public SnapshotIdComponents? ParseId(string snapshotId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(snapshotId))
|
||||
return null;
|
||||
|
||||
if (!snapshotId.StartsWith(Prefix, StringComparison.Ordinal))
|
||||
return null;
|
||||
|
||||
var hash = snapshotId[Prefix.Length..];
|
||||
if (hash.Length != 64) // SHA-256 hex length
|
||||
return null;
|
||||
|
||||
return new SnapshotIdComponents("sha256", hash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a string is a valid snapshot ID format.
|
||||
/// </summary>
|
||||
public bool IsValidIdFormat(string snapshotId)
|
||||
{
|
||||
return ParseId(snapshotId) is not null;
|
||||
}
|
||||
|
||||
private static string ToCanonicalJson(KnowledgeSnapshotManifest manifest)
|
||||
{
|
||||
return JsonSerializer.Serialize(manifest, SnapshotSerializerOptions.Canonical);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsed components of a snapshot ID.
|
||||
/// </summary>
|
||||
public sealed record SnapshotIdComponents(string Algorithm, string Hash);
|
||||
|
||||
/// <summary>
|
||||
/// Interface for snapshot ID generation and validation.
|
||||
/// </summary>
|
||||
public interface ISnapshotIdGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a content-addressed ID for a manifest.
|
||||
/// </summary>
|
||||
string GenerateId(KnowledgeSnapshotManifest manifest);
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a manifest's ID matches its content.
|
||||
/// </summary>
|
||||
bool ValidateId(KnowledgeSnapshotManifest manifest);
|
||||
|
||||
/// <summary>
|
||||
/// Parses a snapshot ID into its components.
|
||||
/// </summary>
|
||||
SnapshotIdComponents? ParseId(string snapshotId);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a string is a valid snapshot ID format.
|
||||
/// </summary>
|
||||
bool IsValidIdFormat(string snapshotId);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Policy.Snapshots;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing knowledge snapshots.
|
||||
/// </summary>
|
||||
public sealed class SnapshotService : ISnapshotService
|
||||
{
|
||||
private readonly ISnapshotIdGenerator _idGenerator;
|
||||
private readonly ICryptoSigner? _signer;
|
||||
private readonly ISnapshotStore _store;
|
||||
private readonly ILogger<SnapshotService> _logger;
|
||||
|
||||
public SnapshotService(
|
||||
ISnapshotIdGenerator idGenerator,
|
||||
ISnapshotStore store,
|
||||
ILogger<SnapshotService> logger,
|
||||
ICryptoSigner? signer = null)
|
||||
{
|
||||
_idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_signer = signer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates and persists a new snapshot.
|
||||
/// </summary>
|
||||
public async Task<KnowledgeSnapshotManifest> CreateSnapshotAsync(
|
||||
SnapshotBuilder builder,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
var manifest = builder.Build();
|
||||
|
||||
// Validate ID before storing
|
||||
if (!_idGenerator.ValidateId(manifest))
|
||||
throw new InvalidOperationException("Snapshot ID validation failed");
|
||||
|
||||
await _store.SaveAsync(manifest, ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Created snapshot {SnapshotId}", manifest.SnapshotId);
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seals a snapshot with a DSSE signature.
|
||||
/// </summary>
|
||||
public async Task<KnowledgeSnapshotManifest> SealSnapshotAsync(
|
||||
KnowledgeSnapshotManifest manifest,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
if (_signer is null)
|
||||
throw new InvalidOperationException("No signer configured for sealing snapshots");
|
||||
|
||||
var payload = JsonSerializer.SerializeToUtf8Bytes(manifest with { Signature = null },
|
||||
SnapshotSerializerOptions.Canonical);
|
||||
var signatureBytes = await _signer.SignAsync(payload, ct).ConfigureAwait(false);
|
||||
var signature = Convert.ToBase64String(signatureBytes);
|
||||
|
||||
var sealedManifest = manifest with { Signature = signature };
|
||||
|
||||
await _store.SaveAsync(sealedManifest, ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Sealed snapshot {SnapshotId}", manifest.SnapshotId);
|
||||
|
||||
return sealedManifest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a snapshot's integrity and signature.
|
||||
/// </summary>
|
||||
public async Task<SnapshotVerificationResult> VerifySnapshotAsync(
|
||||
KnowledgeSnapshotManifest manifest,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
// Verify content-addressed ID
|
||||
if (!_idGenerator.ValidateId(manifest))
|
||||
{
|
||||
return SnapshotVerificationResult.Fail("Snapshot ID does not match content");
|
||||
}
|
||||
|
||||
// Verify signature if present
|
||||
if (manifest.Signature is not null)
|
||||
{
|
||||
if (_signer is null)
|
||||
{
|
||||
return SnapshotVerificationResult.Fail("No signer configured for signature verification");
|
||||
}
|
||||
|
||||
var payload = JsonSerializer.SerializeToUtf8Bytes(manifest with { Signature = null },
|
||||
SnapshotSerializerOptions.Canonical);
|
||||
var signatureBytes = Convert.FromBase64String(manifest.Signature);
|
||||
var sigValid = await _signer.VerifyAsync(payload, signatureBytes, ct).ConfigureAwait(false);
|
||||
|
||||
if (!sigValid)
|
||||
{
|
||||
return SnapshotVerificationResult.Fail("Signature verification failed");
|
||||
}
|
||||
}
|
||||
|
||||
return SnapshotVerificationResult.Success();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a snapshot by ID.
|
||||
/// </summary>
|
||||
public async Task<KnowledgeSnapshotManifest?> GetSnapshotAsync(
|
||||
string snapshotId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(snapshotId))
|
||||
return null;
|
||||
|
||||
return await _store.GetAsync(snapshotId, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists all snapshots in the store.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<KnowledgeSnapshotManifest>> ListSnapshotsAsync(
|
||||
int skip = 0,
|
||||
int take = 100,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await _store.ListAsync(skip, take, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of snapshot verification.
|
||||
/// </summary>
|
||||
public sealed record SnapshotVerificationResult(bool IsValid, string? Error)
|
||||
{
|
||||
public static SnapshotVerificationResult Success() => new(true, null);
|
||||
public static SnapshotVerificationResult Fail(string error) => new(false, error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for snapshot management operations.
|
||||
/// </summary>
|
||||
public interface ISnapshotService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates and persists a new snapshot.
|
||||
/// </summary>
|
||||
Task<KnowledgeSnapshotManifest> CreateSnapshotAsync(SnapshotBuilder builder, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Seals a snapshot with a DSSE signature.
|
||||
/// </summary>
|
||||
Task<KnowledgeSnapshotManifest> SealSnapshotAsync(KnowledgeSnapshotManifest manifest, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a snapshot's integrity and signature.
|
||||
/// </summary>
|
||||
Task<SnapshotVerificationResult> VerifySnapshotAsync(KnowledgeSnapshotManifest manifest, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a snapshot by ID.
|
||||
/// </summary>
|
||||
Task<KnowledgeSnapshotManifest?> GetSnapshotAsync(string snapshotId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all snapshots in the store.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<KnowledgeSnapshotManifest>> ListSnapshotsAsync(int skip = 0, int take = 100, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for snapshot persistence.
|
||||
/// </summary>
|
||||
public interface ISnapshotStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Saves a snapshot manifest.
|
||||
/// </summary>
|
||||
Task SaveAsync(KnowledgeSnapshotManifest manifest, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a snapshot manifest by ID.
|
||||
/// </summary>
|
||||
Task<KnowledgeSnapshotManifest?> GetAsync(string snapshotId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists snapshot manifests.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<KnowledgeSnapshotManifest>> ListAsync(int skip = 0, int take = 100, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a snapshot manifest by ID.
|
||||
/// </summary>
|
||||
Task<bool> DeleteAsync(string snapshotId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets bundled content by path.
|
||||
/// </summary>
|
||||
Task<byte[]?> GetBundledContentAsync(string bundlePath, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets content by digest.
|
||||
/// </summary>
|
||||
Task<byte[]?> GetByDigestAsync(string digest, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="ISnapshotStore"/> for testing.
|
||||
/// </summary>
|
||||
public sealed class InMemorySnapshotStore : ISnapshotStore
|
||||
{
|
||||
private readonly Dictionary<string, KnowledgeSnapshotManifest> _snapshots = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public Task SaveAsync(KnowledgeSnapshotManifest manifest, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
lock (_lock)
|
||||
{
|
||||
_snapshots[manifest.SnapshotId] = manifest;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<KnowledgeSnapshotManifest?> GetAsync(string snapshotId, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
lock (_lock)
|
||||
{
|
||||
return Task.FromResult(_snapshots.TryGetValue(snapshotId, out var manifest) ? manifest : null);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<KnowledgeSnapshotManifest>> ListAsync(int skip = 0, int take = 100, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
lock (_lock)
|
||||
{
|
||||
var result = _snapshots.Values
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<KnowledgeSnapshotManifest>>(result);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(string snapshotId, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
lock (_lock)
|
||||
{
|
||||
return Task.FromResult(_snapshots.Remove(snapshotId));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<byte[]?> GetBundledContentAsync(string bundlePath, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
// In-memory implementation doesn't support bundled content
|
||||
return Task.FromResult<byte[]?>(null);
|
||||
}
|
||||
|
||||
public Task<byte[]?> GetByDigestAsync(string digest, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
// In-memory implementation doesn't support digest-based lookup
|
||||
return Task.FromResult<byte[]?>(null);
|
||||
}
|
||||
}
|
||||
@@ -27,5 +27,6 @@
|
||||
<ProjectReference Include="../../StellaOps.Policy.RiskProfile/StellaOps.Policy.RiskProfile.csproj" />
|
||||
<ProjectReference Include="../../../Attestor/__Libraries/StellaOps.Attestor.ProofChain/StellaOps.Attestor.ProofChain.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Canonical.Json/StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
* PolicyBundle - Policy configuration for trust evaluation.
|
||||
* Sprint: SPRINT_3600_0001_0001 (Trust Algebra and Lattice Engine)
|
||||
* Task: TRUST-014
|
||||
* Update: SPRINT_4300_0002_0001 (BUDGET-002) - Added UnknownBudgets support.
|
||||
*
|
||||
* Defines trust roots, trust requirements, and selection rule overrides.
|
||||
* Defines trust roots, trust requirements, selection rule overrides, and unknown budgets.
|
||||
*/
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy.TrustLattice;
|
||||
|
||||
/// <summary>
|
||||
@@ -70,6 +73,58 @@ public sealed record TrustRequirements
|
||||
public bool RequireSignatures { get; init; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unknown budget rule for policy bundles.
|
||||
/// Sprint: SPRINT_4300_0002_0001 (BUDGET-002)
|
||||
/// </summary>
|
||||
public sealed record PolicyBundleUnknownBudget
|
||||
{
|
||||
/// <summary>
|
||||
/// Budget name identifier.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Environment filter: "production", "staging", "dev", or "*" for all.
|
||||
/// </summary>
|
||||
public string Environment { get; init; } = "*";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum unknown tier allowed (T1=strict, T4=permissive).
|
||||
/// Null means no tier restriction.
|
||||
/// </summary>
|
||||
public int? TierMax { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum total unknown count allowed.
|
||||
/// Null means no count restriction.
|
||||
/// </summary>
|
||||
public int? CountMax { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum mean entropy allowed (0.0-1.0).
|
||||
/// Null means no entropy restriction.
|
||||
/// </summary>
|
||||
public double? EntropyMax { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Per-reason-code limits.
|
||||
/// Keys are reason code names (e.g., "Reachability", "Identity").
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, int> ReasonLimits { get; init; } =
|
||||
ImmutableDictionary<string, int>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Action to take when budget is exceeded: "block" or "warn".
|
||||
/// </summary>
|
||||
public string Action { get; init; } = "warn";
|
||||
|
||||
/// <summary>
|
||||
/// Custom message to display when budget is exceeded.
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Conflict resolution strategy.
|
||||
/// </summary>
|
||||
@@ -147,6 +202,12 @@ public sealed record PolicyBundle
|
||||
public IReadOnlyList<string> AcceptedVexFormats { get; init; } =
|
||||
["CycloneDX/ECMA-424", "OpenVEX", "CSAF"];
|
||||
|
||||
/// <summary>
|
||||
/// Unknown budget rules for environment-scoped enforcement.
|
||||
/// Sprint: SPRINT_4300_0002_0001 (BUDGET-002)
|
||||
/// </summary>
|
||||
public IReadOnlyList<PolicyBundleUnknownBudget> UnknownBudgets { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the merged selection rules (custom + baseline).
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_4500_0001_0002 - VEX Trust Scoring Framework
|
||||
// Tasks: TRUST-015 (trust threshold), TRUST-016 (allowlist/blocklist),
|
||||
// TRUST-017 (TrustInsufficientViolation), TRUST-018 (trust context)
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Vex;
|
||||
|
||||
/// <summary>
|
||||
/// Policy violation when VEX source trust is insufficient.
|
||||
/// </summary>
|
||||
public sealed record TrustInsufficientViolation : IPolicyViolation
|
||||
{
|
||||
/// <summary>
|
||||
/// Violation code.
|
||||
/// </summary>
|
||||
public string Code => "VEX_TRUST_INSUFFICIENT";
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable message.
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity of the violation.
|
||||
/// </summary>
|
||||
public PolicyViolationSeverity Severity { get; init; } = PolicyViolationSeverity.Error;
|
||||
|
||||
/// <summary>
|
||||
/// Source ID that failed trust check.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sourceId")]
|
||||
public required string SourceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actual trust score of the source.
|
||||
/// </summary>
|
||||
[JsonPropertyName("actualTrustScore")]
|
||||
public required double ActualTrustScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Required minimum trust score.
|
||||
/// </summary>
|
||||
[JsonPropertyName("requiredTrustScore")]
|
||||
public required double RequiredTrustScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Context of the policy rule that was violated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ruleContext")]
|
||||
public string? RuleContext { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Suggested remediation actions.
|
||||
/// </summary>
|
||||
[JsonPropertyName("remediations")]
|
||||
public ImmutableArray<string> Remediations { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy violation when VEX source is on blocklist.
|
||||
/// </summary>
|
||||
public sealed record SourceBlockedViolation : IPolicyViolation
|
||||
{
|
||||
/// <summary>
|
||||
/// Violation code.
|
||||
/// </summary>
|
||||
public string Code => "VEX_SOURCE_BLOCKED";
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable message.
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity of the violation.
|
||||
/// </summary>
|
||||
public PolicyViolationSeverity Severity { get; init; } = PolicyViolationSeverity.Error;
|
||||
|
||||
/// <summary>
|
||||
/// Source ID that is blocked.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sourceId")]
|
||||
public required string SourceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for blocking.
|
||||
/// </summary>
|
||||
[JsonPropertyName("blockReason")]
|
||||
public string? BlockReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the source was blocked.
|
||||
/// </summary>
|
||||
[JsonPropertyName("blockedAt")]
|
||||
public DateTimeOffset? BlockedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy violation when required source is not in allowlist.
|
||||
/// </summary>
|
||||
public sealed record SourceNotAllowedViolation : IPolicyViolation
|
||||
{
|
||||
/// <summary>
|
||||
/// Violation code.
|
||||
/// </summary>
|
||||
public string Code => "VEX_SOURCE_NOT_ALLOWED";
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable message.
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity of the violation.
|
||||
/// </summary>
|
||||
public PolicyViolationSeverity Severity { get; init; } = PolicyViolationSeverity.Warning;
|
||||
|
||||
/// <summary>
|
||||
/// Source ID that is not allowed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sourceId")]
|
||||
public required string SourceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of allowed sources.
|
||||
/// </summary>
|
||||
[JsonPropertyName("allowedSources")]
|
||||
public ImmutableArray<string> AllowedSources { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy violation when trust has decayed below threshold.
|
||||
/// </summary>
|
||||
public sealed record TrustDecayedViolation : IPolicyViolation
|
||||
{
|
||||
/// <summary>
|
||||
/// Violation code.
|
||||
/// </summary>
|
||||
public string Code => "VEX_TRUST_DECAYED";
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable message.
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity of the violation.
|
||||
/// </summary>
|
||||
public PolicyViolationSeverity Severity { get; init; } = PolicyViolationSeverity.Warning;
|
||||
|
||||
/// <summary>
|
||||
/// Source ID with decayed trust.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sourceId")]
|
||||
public required string SourceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original trust score before decay.
|
||||
/// </summary>
|
||||
[JsonPropertyName("originalScore")]
|
||||
public required double OriginalScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current score after decay.
|
||||
/// </summary>
|
||||
[JsonPropertyName("currentScore")]
|
||||
public required double CurrentScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Age of the statement in days.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ageDays")]
|
||||
public required double AgeDays { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Recommended action.
|
||||
/// </summary>
|
||||
[JsonPropertyName("recommendation")]
|
||||
public string? Recommendation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for policy violations.
|
||||
/// </summary>
|
||||
public interface IPolicyViolation
|
||||
{
|
||||
/// <summary>
|
||||
/// Violation code.
|
||||
/// </summary>
|
||||
string Code { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable message.
|
||||
/// </summary>
|
||||
string Message { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity of the violation.
|
||||
/// </summary>
|
||||
PolicyViolationSeverity Severity { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity levels for policy violations.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum PolicyViolationSeverity
|
||||
{
|
||||
/// <summary>Informational only, no action required.</summary>
|
||||
Info = 0,
|
||||
|
||||
/// <summary>Warning, should be addressed but not blocking.</summary>
|
||||
Warning = 1,
|
||||
|
||||
/// <summary>Error, must be addressed.</summary>
|
||||
Error = 2,
|
||||
|
||||
/// <summary>Critical, blocks all processing.</summary>
|
||||
Critical = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for trust-based policy rules.
|
||||
/// </summary>
|
||||
public sealed record TrustPolicyConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimum trust score required for acceptance.
|
||||
/// </summary>
|
||||
[JsonPropertyName("minimumTrustScore")]
|
||||
public double MinimumTrustScore { get; init; } = 0.5;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum trust score for critical vulnerabilities.
|
||||
/// </summary>
|
||||
[JsonPropertyName("criticalVulnMinimumTrust")]
|
||||
public double CriticalVulnMinimumTrust { get; init; } = 0.7;
|
||||
|
||||
/// <summary>
|
||||
/// Blocked source IDs.
|
||||
/// </summary>
|
||||
[JsonPropertyName("blockedSources")]
|
||||
public ImmutableArray<string> BlockedSources { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Allowed source IDs (if set, only these are allowed).
|
||||
/// </summary>
|
||||
[JsonPropertyName("allowedSources")]
|
||||
public ImmutableArray<string> AllowedSources { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enforce allowlist (if false, allowedSources is ignored).
|
||||
/// </summary>
|
||||
[JsonPropertyName("enforceAllowlist")]
|
||||
public bool EnforceAllowlist { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum statement age in days before trust is considered stale.
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxStatementAgeDays")]
|
||||
public double MaxStatementAgeDays { get; init; } = 365.0;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to require cryptographic signature for high-trust sources.
|
||||
/// </summary>
|
||||
[JsonPropertyName("requireSignatureForHighTrust")]
|
||||
public bool RequireSignatureForHighTrust { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Creates default configuration.
|
||||
/// </summary>
|
||||
public static TrustPolicyConfiguration Default => new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for evaluating VEX trust against policy rules.
|
||||
/// </summary>
|
||||
public interface ITrustPolicyEvaluator
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates a VEX source against trust policy.
|
||||
/// </summary>
|
||||
TrustPolicyEvaluationResult Evaluate(
|
||||
TrustPolicyEvaluationContext context,
|
||||
TrustPolicyConfiguration? config = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context for trust policy evaluation.
|
||||
/// </summary>
|
||||
public sealed record TrustPolicyEvaluationContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Source ID being evaluated.
|
||||
/// </summary>
|
||||
public required string SourceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Computed trust score.
|
||||
/// </summary>
|
||||
public required double TrustScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the source is cryptographically verified.
|
||||
/// </summary>
|
||||
public required bool IsVerified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Age of the statement in days.
|
||||
/// </summary>
|
||||
public double StatementAgeDays { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity of the vulnerability being assessed.
|
||||
/// </summary>
|
||||
public string? VulnerabilitySeverity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original trust score before decay.
|
||||
/// </summary>
|
||||
public double? OriginalTrustScore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of trust policy evaluation.
|
||||
/// </summary>
|
||||
public sealed record TrustPolicyEvaluationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the source passes policy.
|
||||
/// </summary>
|
||||
[JsonPropertyName("passed")]
|
||||
public required bool Passed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy violations found.
|
||||
/// </summary>
|
||||
[JsonPropertyName("violations")]
|
||||
public ImmutableArray<IPolicyViolation> Violations { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Warnings (non-blocking).
|
||||
/// </summary>
|
||||
[JsonPropertyName("warnings")]
|
||||
public ImmutableArray<string> Warnings { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Effective trust score after policy adjustments.
|
||||
/// </summary>
|
||||
[JsonPropertyName("effectiveTrustScore")]
|
||||
public required double EffectiveTrustScore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of trust policy evaluator.
|
||||
/// </summary>
|
||||
public sealed class TrustPolicyEvaluator : ITrustPolicyEvaluator
|
||||
{
|
||||
public TrustPolicyEvaluationResult Evaluate(
|
||||
TrustPolicyEvaluationContext context,
|
||||
TrustPolicyConfiguration? config = null)
|
||||
{
|
||||
config ??= TrustPolicyConfiguration.Default;
|
||||
|
||||
var violations = new List<IPolicyViolation>();
|
||||
var warnings = new List<string>();
|
||||
var effectiveTrust = context.TrustScore;
|
||||
|
||||
// Check blocklist
|
||||
if (config.BlockedSources.Contains(context.SourceId))
|
||||
{
|
||||
violations.Add(new SourceBlockedViolation
|
||||
{
|
||||
Message = $"Source '{context.SourceId}' is on the blocklist",
|
||||
SourceId = context.SourceId
|
||||
});
|
||||
}
|
||||
|
||||
// Check allowlist (if enforced)
|
||||
if (config.EnforceAllowlist &&
|
||||
config.AllowedSources.Length > 0 &&
|
||||
!config.AllowedSources.Contains(context.SourceId))
|
||||
{
|
||||
violations.Add(new SourceNotAllowedViolation
|
||||
{
|
||||
Message = $"Source '{context.SourceId}' is not in the allowlist",
|
||||
SourceId = context.SourceId,
|
||||
AllowedSources = config.AllowedSources
|
||||
});
|
||||
}
|
||||
|
||||
// Check minimum trust score
|
||||
var requiredMinimum = config.MinimumTrustScore;
|
||||
|
||||
// Higher threshold for critical vulnerabilities
|
||||
if (context.VulnerabilitySeverity?.Equals("critical", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
requiredMinimum = config.CriticalVulnMinimumTrust;
|
||||
}
|
||||
|
||||
if (context.TrustScore < requiredMinimum)
|
||||
{
|
||||
violations.Add(new TrustInsufficientViolation
|
||||
{
|
||||
Message = $"Source '{context.SourceId}' trust score ({context.TrustScore:F2}) is below required minimum ({requiredMinimum:F2})",
|
||||
SourceId = context.SourceId,
|
||||
ActualTrustScore = context.TrustScore,
|
||||
RequiredTrustScore = requiredMinimum,
|
||||
RuleContext = context.VulnerabilitySeverity != null
|
||||
? $"Evaluating for {context.VulnerabilitySeverity} vulnerability"
|
||||
: null,
|
||||
Remediations =
|
||||
[
|
||||
"Obtain VEX from a higher-trust source",
|
||||
"Request cryptographic signature from source",
|
||||
"Wait for source to accumulate more accurate history"
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Check for decayed trust
|
||||
if (context.OriginalTrustScore.HasValue &&
|
||||
context.OriginalTrustScore > context.TrustScore &&
|
||||
context.TrustScore < requiredMinimum &&
|
||||
context.OriginalTrustScore >= requiredMinimum)
|
||||
{
|
||||
violations.Add(new TrustDecayedViolation
|
||||
{
|
||||
Message = $"Source '{context.SourceId}' trust has decayed from {context.OriginalTrustScore:F2} to {context.TrustScore:F2}",
|
||||
SourceId = context.SourceId,
|
||||
OriginalScore = context.OriginalTrustScore.Value,
|
||||
CurrentScore = context.TrustScore,
|
||||
AgeDays = context.StatementAgeDays,
|
||||
Recommendation = "Request updated VEX statement from source"
|
||||
});
|
||||
}
|
||||
|
||||
// Check statement age
|
||||
if (context.StatementAgeDays > config.MaxStatementAgeDays)
|
||||
{
|
||||
warnings.Add($"VEX statement is {context.StatementAgeDays:F0} days old (max: {config.MaxStatementAgeDays})");
|
||||
}
|
||||
|
||||
// Check signature requirement for high trust
|
||||
if (config.RequireSignatureForHighTrust &&
|
||||
context.TrustScore >= 0.8 &&
|
||||
!context.IsVerified)
|
||||
{
|
||||
warnings.Add("High-trust source should have cryptographic signature");
|
||||
}
|
||||
|
||||
return new TrustPolicyEvaluationResult
|
||||
{
|
||||
Passed = violations.Count == 0,
|
||||
Violations = violations.Cast<IPolicyViolation>().ToImmutableArray(),
|
||||
Warnings = warnings.ToImmutableArray(),
|
||||
EffectiveTrustScore = effectiveTrust
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user