feat(rate-limiting): Implement core rate limiting functionality with configuration, decision-making, metrics, middleware, and service registration

- Add RateLimitConfig for configuration management with YAML binding support.
- Introduce RateLimitDecision to encapsulate the result of rate limit checks.
- Implement RateLimitMetrics for OpenTelemetry metrics tracking.
- Create RateLimitMiddleware for enforcing rate limits on incoming requests.
- Develop RateLimitService to orchestrate instance and environment rate limit checks.
- Add RateLimitServiceCollectionExtensions for dependency injection registration.
This commit is contained in:
master
2025-12-17 18:02:37 +02:00
parent 394b57f6bf
commit 8bbfe4d2d2
211 changed files with 47179 additions and 1590 deletions

View File

@@ -2,6 +2,18 @@ using System.Collections.Immutable;
namespace StellaOps.Policy;
/// <summary>
/// Configuration for policy-based risk scoring.
/// </summary>
/// <param name="Version">Configuration version.</param>
/// <param name="SeverityWeights">Weight multipliers per severity level.</param>
/// <param name="QuietPenalty">Score penalty for quiet-mode findings.</param>
/// <param name="WarnPenalty">Score penalty for warn-mode findings.</param>
/// <param name="IgnorePenalty">Score penalty for ignored findings.</param>
/// <param name="TrustOverrides">Trust adjustments by source.</param>
/// <param name="ReachabilityBuckets">Weights per reachability tier.</param>
/// <param name="UnknownConfidence">Configuration for unknown handling.</param>
/// <param name="SmartDiff">Optional Smart-Diff scoring configuration.</param>
public sealed record PolicyScoringConfig(
string Version,
ImmutableDictionary<PolicySeverity, double> SeverityWeights,
@@ -10,9 +22,53 @@ public sealed record PolicyScoringConfig(
double IgnorePenalty,
ImmutableDictionary<string, double> TrustOverrides,
ImmutableDictionary<string, double> ReachabilityBuckets,
PolicyUnknownConfidenceConfig UnknownConfidence)
PolicyUnknownConfidenceConfig UnknownConfidence,
SmartDiffPolicyScoringConfig? SmartDiff = null)
{
public static string BaselineVersion => "1.0";
public static PolicyScoringConfig Default { get; } = PolicyScoringConfigBinder.LoadDefault();
}
/// <summary>
/// Smart-Diff scoring configuration integrated into policy scoring.
/// Sprint: SPRINT_3500_0004_0001
/// Task: SDIFF-BIN-020 - Add config to PolicyScoringConfig
/// </summary>
public sealed record SmartDiffPolicyScoringConfig(
/// <summary>Weight for reachability flip from unreachable to reachable.</summary>
double ReachabilityFlipUpWeight = 1.0,
/// <summary>Weight for reachability flip from reachable to unreachable.</summary>
double ReachabilityFlipDownWeight = 0.8,
/// <summary>Weight for VEX status flip to affected.</summary>
double VexFlipToAffectedWeight = 0.9,
/// <summary>Weight for VEX status flip to not_affected.</summary>
double VexFlipToNotAffectedWeight = 0.7,
/// <summary>Weight for entering affected version range.</summary>
double RangeEntryWeight = 0.8,
/// <summary>Weight for exiting affected version range.</summary>
double RangeExitWeight = 0.6,
/// <summary>Weight for KEV addition.</summary>
double KevAddedWeight = 1.0,
/// <summary>EPSS threshold for significance.</summary>
double EpssThreshold = 0.1,
/// <summary>Weight for EPSS threshold crossing.</summary>
double EpssThresholdCrossWeight = 0.5,
/// <summary>Weight for hardening regression.</summary>
double HardeningRegressionWeight = 0.7,
/// <summary>Weight for hardening improvement.</summary>
double HardeningImprovementWeight = 0.3,
/// <summary>Minimum hardening score drop to flag as regression.</summary>
double HardeningRegressionThreshold = 0.1)
{
/// <summary>Default Smart-Diff policy configuration.</summary>
public static SmartDiffPolicyScoringConfig Default { get; } = new();
/// <summary>Strict configuration with higher weights for regressions.</summary>
public static SmartDiffPolicyScoringConfig Strict { get; } = new(
ReachabilityFlipUpWeight: 1.2,
VexFlipToAffectedWeight: 1.1,
KevAddedWeight: 1.5,
HardeningRegressionWeight: 1.0,
HardeningRegressionThreshold: 0.05);
}

View File

@@ -0,0 +1,147 @@
// -----------------------------------------------------------------------------
// ProofHashing.cs
// Sprint: SPRINT_3401_0002_0001_score_replay_proof_bundle
// Task: SCORE-REPLAY-002 - Implement ProofHashing with per-node canonical hash
// Description: Deterministic hashing for proof nodes and root hash computation
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace StellaOps.Policy.Scoring;
/// <summary>
/// Provides deterministic hashing functions for proof nodes.
/// Per advisory "Determinism and Reproducibility Technical Reference" §11.2.
/// </summary>
public static class ProofHashing
{
// JSON serializer options for canonical JSON output
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never
};
/// <summary>
/// Compute and attach the node hash to a ProofNode.
/// The hash is computed over the canonical JSON representation excluding the NodeHash field.
/// </summary>
/// <param name="node">The proof node to hash.</param>
/// <returns>A new ProofNode with the NodeHash field populated.</returns>
public static ProofNode WithHash(ProofNode node)
{
ArgumentNullException.ThrowIfNull(node);
var canonical = CanonicalizeNode(node);
var hash = ComputeSha256Hex(canonical);
return node with { NodeHash = $"sha256:{hash}" };
}
/// <summary>
/// Compute the root hash over an ordered sequence of proof nodes.
/// The root hash is the SHA-256 of the canonical JSON array of node hashes.
/// </summary>
/// <param name="nodesInOrder">The proof nodes in deterministic order.</param>
/// <returns>The root hash as "sha256:&lt;hex&gt;".</returns>
public static string ComputeRootHash(IEnumerable<ProofNode> nodesInOrder)
{
ArgumentNullException.ThrowIfNull(nodesInOrder);
var hashes = nodesInOrder.Select(n => n.NodeHash).ToArray();
var canonical = CanonicalizeArray(hashes);
var hash = ComputeSha256Hex(canonical);
return $"sha256:{hash}";
}
/// <summary>
/// Verify that a node's hash is correct.
/// </summary>
/// <param name="node">The node to verify.</param>
/// <returns>True if the hash is valid, false otherwise.</returns>
public static bool VerifyNodeHash(ProofNode node)
{
ArgumentNullException.ThrowIfNull(node);
if (string.IsNullOrEmpty(node.NodeHash))
return false;
var computed = WithHash(node with { NodeHash = string.Empty });
return node.NodeHash.Equals(computed.NodeHash, StringComparison.Ordinal);
}
/// <summary>
/// Verify that the root hash matches the nodes.
/// </summary>
/// <param name="nodesInOrder">The proof nodes in order.</param>
/// <param name="expectedRootHash">The expected root hash.</param>
/// <returns>True if the root hash matches, false otherwise.</returns>
public static bool VerifyRootHash(IEnumerable<ProofNode> nodesInOrder, string expectedRootHash)
{
ArgumentNullException.ThrowIfNull(nodesInOrder);
var computed = ComputeRootHash(nodesInOrder);
return computed.Equals(expectedRootHash, StringComparison.Ordinal);
}
#region Canonical JSON Helpers
/// <summary>
/// Create canonical JSON representation of a proof node (excluding NodeHash).
/// Keys are sorted alphabetically for determinism.
/// </summary>
private static byte[] CanonicalizeNode(ProofNode node)
{
// Build a sorted object for canonical representation
// Note: We explicitly exclude NodeHash from the canonical form
var obj = new SortedDictionary<string, object?>(StringComparer.Ordinal)
{
["actor"] = node.Actor,
["delta"] = node.Delta,
["evidenceRefs"] = node.EvidenceRefs,
["id"] = node.Id,
["kind"] = node.Kind.ToString().ToLowerInvariant(),
["parentIds"] = node.ParentIds,
["ruleId"] = node.RuleId,
["seed"] = Convert.ToBase64String(node.Seed),
["total"] = node.Total,
["tsUtc"] = node.TsUtc.ToUniversalTime().ToString("O")
};
return SerializeCanonical(obj);
}
/// <summary>
/// Create canonical JSON representation of a string array.
/// </summary>
private static byte[] CanonicalizeArray(string[] values)
{
return SerializeCanonical(values);
}
/// <summary>
/// Serialize an object to canonical JSON bytes (no whitespace, sorted keys).
/// </summary>
private static byte[] SerializeCanonical(object obj)
{
// Use JsonNode for better control over serialization
var json = JsonSerializer.Serialize(obj, CanonicalJsonOptions);
return Encoding.UTF8.GetBytes(json);
}
/// <summary>
/// Compute SHA-256 hash and return as lowercase hex string.
/// </summary>
private static string ComputeSha256Hex(byte[] data)
{
var hash = SHA256.HashData(data);
return Convert.ToHexStringLower(hash);
}
#endregion
}

View File

@@ -0,0 +1,197 @@
// -----------------------------------------------------------------------------
// ProofLedger.cs
// Sprint: SPRINT_3401_0002_0001_score_replay_proof_bundle
// Task: SCORE-REPLAY-003 - Implement ProofLedger with deterministic append
// Description: Append-only ledger for score proof nodes with root hash computation
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Scoring;
/// <summary>
/// Append-only ledger for score proof nodes.
/// Provides deterministic root hash computation for audit and replay.
/// Per advisory "Determinism and Reproducibility Technical Reference" §11.2.
/// </summary>
public sealed class ProofLedger
{
private readonly List<ProofNode> _nodes = [];
private readonly object _lock = new();
private string? _cachedRootHash;
/// <summary>
/// The ordered list of proof nodes in the ledger.
/// </summary>
public IReadOnlyList<ProofNode> Nodes => _nodes.AsReadOnly();
/// <summary>
/// The number of nodes in the ledger.
/// </summary>
public int Count => _nodes.Count;
/// <summary>
/// Append a proof node to the ledger.
/// The node hash will be computed and attached automatically.
/// </summary>
/// <param name="node">The node to append.</param>
/// <exception cref="ArgumentNullException">If node is null.</exception>
public void Append(ProofNode node)
{
ArgumentNullException.ThrowIfNull(node);
lock (_lock)
{
// Compute hash if not already computed
var hashedNode = string.IsNullOrEmpty(node.NodeHash)
? ProofHashing.WithHash(node)
: node;
_nodes.Add(hashedNode);
_cachedRootHash = null; // Invalidate cache
}
}
/// <summary>
/// Append multiple proof nodes to the ledger in order.
/// </summary>
/// <param name="nodes">The nodes to append.</param>
public void AppendRange(IEnumerable<ProofNode> nodes)
{
ArgumentNullException.ThrowIfNull(nodes);
lock (_lock)
{
foreach (var node in nodes)
{
var hashedNode = string.IsNullOrEmpty(node.NodeHash)
? ProofHashing.WithHash(node)
: node;
_nodes.Add(hashedNode);
}
_cachedRootHash = null; // Invalidate cache
}
}
/// <summary>
/// Compute the root hash of the ledger.
/// The root hash is deterministic given the same nodes in the same order.
/// </summary>
/// <returns>The root hash as "sha256:&lt;hex&gt;".</returns>
public string RootHash()
{
lock (_lock)
{
_cachedRootHash ??= ProofHashing.ComputeRootHash(_nodes);
return _cachedRootHash;
}
}
/// <summary>
/// Verify that all node hashes in the ledger are valid.
/// </summary>
/// <returns>True if all hashes are valid, false otherwise.</returns>
public bool VerifyIntegrity()
{
lock (_lock)
{
return _nodes.All(ProofHashing.VerifyNodeHash);
}
}
/// <summary>
/// Get a snapshot of the ledger as an immutable list.
/// </summary>
/// <returns>An immutable copy of the nodes.</returns>
public ImmutableList<ProofNode> ToImmutableSnapshot()
{
lock (_lock)
{
return [.. _nodes];
}
}
/// <summary>
/// Serialize the ledger to JSON.
/// </summary>
/// <param name="options">Optional JSON serializer options.</param>
/// <returns>The JSON representation of the ledger.</returns>
public string ToJson(JsonSerializerOptions? options = null)
{
lock (_lock)
{
var payload = new ProofLedgerPayload(
Nodes: [.. _nodes],
RootHash: RootHash(),
CreatedAtUtc: DateTimeOffset.UtcNow);
return JsonSerializer.Serialize(payload, options ?? DefaultJsonOptions);
}
}
/// <summary>
/// Deserialize a ledger from JSON and verify integrity.
/// </summary>
/// <param name="json">The JSON string.</param>
/// <param name="options">Optional JSON serializer options.</param>
/// <returns>The deserialized ledger.</returns>
/// <exception cref="InvalidOperationException">If integrity verification fails.</exception>
public static ProofLedger FromJson(string json, JsonSerializerOptions? options = null)
{
var payload = JsonSerializer.Deserialize<ProofLedgerPayload>(json, options ?? DefaultJsonOptions)
?? throw new InvalidOperationException("Failed to deserialize proof ledger");
var ledger = new ProofLedger();
// Add nodes directly without recomputing hashes
foreach (var node in payload.Nodes)
{
ledger._nodes.Add(node);
}
// Verify integrity
if (!ledger.VerifyIntegrity())
{
throw new InvalidOperationException("Proof ledger integrity check failed: node hashes do not match");
}
// Verify root hash
if (!ProofHashing.VerifyRootHash(ledger._nodes, payload.RootHash))
{
throw new InvalidOperationException("Proof ledger integrity check failed: root hash does not match");
}
return ledger;
}
/// <summary>
/// Create a new ledger from an existing sequence of nodes.
/// Useful for replay scenarios.
/// </summary>
/// <param name="nodes">The nodes to populate the ledger with.</param>
/// <returns>A new ledger containing the nodes.</returns>
public static ProofLedger FromNodes(IEnumerable<ProofNode> nodes)
{
var ledger = new ProofLedger();
ledger.AppendRange(nodes);
return ledger;
}
private static readonly JsonSerializerOptions DefaultJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
}
/// <summary>
/// JSON payload for proof ledger serialization.
/// </summary>
internal sealed record ProofLedgerPayload(
[property: JsonPropertyName("nodes")] ImmutableArray<ProofNode> Nodes,
[property: JsonPropertyName("rootHash")] string RootHash,
[property: JsonPropertyName("createdAtUtc")] DateTimeOffset CreatedAtUtc);

View File

@@ -0,0 +1,167 @@
// -----------------------------------------------------------------------------
// ProofNode.cs
// Sprint: SPRINT_3401_0002_0001_score_replay_proof_bundle
// Task: SCORE-REPLAY-001 - Implement ProofNode record and ProofNodeKind enum
// Description: Proof ledger node types for score replay and audit trails
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Scoring;
/// <summary>
/// The type of proof ledger node.
/// Per advisory "Building a Deeper Moat Beyond Reachability" §11.2.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<ProofNodeKind>))]
public enum ProofNodeKind
{
/// <summary>Input node - captures initial scoring inputs.</summary>
[JsonStringEnumMemberName("input")]
Input,
/// <summary>Transform node - records a transformation/calculation step.</summary>
[JsonStringEnumMemberName("transform")]
Transform,
/// <summary>Delta node - records a scoring delta applied.</summary>
[JsonStringEnumMemberName("delta")]
Delta,
/// <summary>Score node - final score output.</summary>
[JsonStringEnumMemberName("score")]
Score
}
/// <summary>
/// A single node in the score proof ledger.
/// Each node represents a discrete step in the scoring process with cryptographic linking.
/// Per advisory "Determinism and Reproducibility Technical Reference" §11.2.
/// </summary>
/// <param name="Id">Unique identifier for this node (e.g., UUID or sequential).</param>
/// <param name="Kind">The type of proof node.</param>
/// <param name="RuleId">The rule or policy ID that generated this node.</param>
/// <param name="ParentIds">IDs of parent nodes this node depends on (for graph structure).</param>
/// <param name="EvidenceRefs">Digests or references to evidence artifacts in the bundle.</param>
/// <param name="Delta">Scoring delta applied (0 for non-Delta nodes).</param>
/// <param name="Total">Running total score at this node.</param>
/// <param name="Actor">Module or component name that created this node.</param>
/// <param name="TsUtc">Timestamp in UTC when the node was created.</param>
/// <param name="Seed">32-byte seed for deterministic replay.</param>
/// <param name="NodeHash">SHA-256 hash over canonical node (excluding NodeHash itself).</param>
public sealed record ProofNode(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("kind")] ProofNodeKind Kind,
[property: JsonPropertyName("ruleId")] string RuleId,
[property: JsonPropertyName("parentIds")] string[] ParentIds,
[property: JsonPropertyName("evidenceRefs")] string[] EvidenceRefs,
[property: JsonPropertyName("delta")] double Delta,
[property: JsonPropertyName("total")] double Total,
[property: JsonPropertyName("actor")] string Actor,
[property: JsonPropertyName("tsUtc")] DateTimeOffset TsUtc,
[property: JsonPropertyName("seed")] byte[] Seed,
[property: JsonPropertyName("nodeHash")] string NodeHash)
{
/// <summary>
/// Create a new ProofNode with default values for optional properties.
/// </summary>
public static ProofNode Create(
string id,
ProofNodeKind kind,
string ruleId,
string actor,
DateTimeOffset tsUtc,
byte[] seed,
double delta = 0.0,
double total = 0.0,
string[]? parentIds = null,
string[]? evidenceRefs = null)
{
return new ProofNode(
Id: id,
Kind: kind,
RuleId: ruleId,
ParentIds: parentIds ?? [],
EvidenceRefs: evidenceRefs ?? [],
Delta: delta,
Total: total,
Actor: actor,
TsUtc: tsUtc,
Seed: seed,
NodeHash: string.Empty // Will be computed by ProofHashing.WithHash
);
}
/// <summary>
/// Create an input node capturing initial scoring inputs.
/// </summary>
public static ProofNode CreateInput(
string id,
string ruleId,
string actor,
DateTimeOffset tsUtc,
byte[] seed,
double initialValue,
string[]? evidenceRefs = null)
{
return Create(
id: id,
kind: ProofNodeKind.Input,
ruleId: ruleId,
actor: actor,
tsUtc: tsUtc,
seed: seed,
total: initialValue,
evidenceRefs: evidenceRefs);
}
/// <summary>
/// Create a delta node recording a scoring adjustment.
/// </summary>
public static ProofNode CreateDelta(
string id,
string ruleId,
string actor,
DateTimeOffset tsUtc,
byte[] seed,
double delta,
double newTotal,
string[] parentIds,
string[]? evidenceRefs = null)
{
return Create(
id: id,
kind: ProofNodeKind.Delta,
ruleId: ruleId,
actor: actor,
tsUtc: tsUtc,
seed: seed,
delta: delta,
total: newTotal,
parentIds: parentIds,
evidenceRefs: evidenceRefs);
}
/// <summary>
/// Create a final score node.
/// </summary>
public static ProofNode CreateScore(
string id,
string ruleId,
string actor,
DateTimeOffset tsUtc,
byte[] seed,
double finalScore,
string[] parentIds)
{
return Create(
id: id,
kind: ProofNodeKind.Score,
ruleId: ruleId,
actor: actor,
tsUtc: tsUtc,
seed: seed,
total: finalScore,
parentIds: parentIds);
}
}