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

@@ -0,0 +1,181 @@
// -----------------------------------------------------------------------------
// UnknownRanking.cs
// Sprint: SPRINT_3600_0002_0001_unknowns_ranking_containment
// Task: UNK-RANK-001 - Define BlastRadius, ExploitPressure, ContainmentSignals records
// Task: UNK-RANK-002 - Extend UnknownItem with new fields
// Description: Enhanced unknown ranking models with containment signals
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Unknowns.Core.Models;
/// <summary>
/// Represents the blast radius of an unknown - the potential impact if exploited.
/// Per advisory "Building a Deeper Moat Beyond Reachability" §17.5.
/// </summary>
/// <param name="Dependents">Number of dependent packages/components.</param>
/// <param name="NetFacing">Whether the component is network-facing.</param>
/// <param name="Privilege">Privilege level required/granted (e.g., "root", "user", "none").</param>
public sealed record BlastRadius(
[property: JsonPropertyName("dependents")] int Dependents,
[property: JsonPropertyName("netFacing")] bool NetFacing,
[property: JsonPropertyName("privilege")] string Privilege)
{
/// <summary>Default blast radius for cases without signal data.</summary>
public static BlastRadius Unknown => new(0, false, "unknown");
/// <summary>
/// Calculate normalized blast radius score [0, 1].
/// </summary>
public double Score()
{
// Dependents: normalize to 50 (high impact threshold)
var dependents01 = Math.Clamp(Dependents / 50.0, 0, 1);
// Network facing adds 0.5
var net = NetFacing ? 0.5 : 0.0;
// Root privilege adds 0.5
var priv = Privilege == "root" ? 0.5 : Privilege == "admin" ? 0.3 : 0.0;
return Math.Clamp((dependents01 + net + priv) / 2.0, 0, 1);
}
}
/// <summary>
/// Represents exploit pressure signals for an unknown.
/// </summary>
/// <param name="Epss">EPSS score (0..1), null if unknown.</param>
/// <param name="Kev">Whether this is in CISA KEV catalog.</param>
public sealed record ExploitPressure(
[property: JsonPropertyName("epss")] double? Epss,
[property: JsonPropertyName("kev")] bool Kev)
{
/// <summary>Default exploit pressure for cases without signal data.</summary>
public static ExploitPressure Unknown => new(null, false);
/// <summary>
/// Calculate normalized exploit pressure score [0, 1].
/// </summary>
public double Score()
{
// EPSS score, default to 0.35 (median) if unknown
var epss01 = Epss ?? 0.35;
// KEV adds 0.30
var kev = Kev ? 0.30 : 0.0;
return Math.Clamp(epss01 + kev, 0, 1);
}
}
/// <summary>
/// Represents runtime containment signals that reduce risk.
/// Per advisory "Building a Deeper Moat Beyond Reachability" §17.5.
/// </summary>
/// <param name="Seccomp">Seccomp status: "enforced", "audit", "disabled", "unknown".</param>
/// <param name="Fs">Filesystem status: "ro" (read-only), "rw", "unknown".</param>
/// <param name="NetworkPolicy">Network policy status: "enforced", "audit", "disabled", "unknown".</param>
/// <param name="Capabilities">Dropped capabilities count (higher = more restricted).</param>
public sealed record ContainmentSignals(
[property: JsonPropertyName("seccomp")] string Seccomp,
[property: JsonPropertyName("fs")] string Fs,
[property: JsonPropertyName("networkPolicy")] string NetworkPolicy = "unknown",
[property: JsonPropertyName("capabilities")] int Capabilities = 0)
{
/// <summary>Default containment for cases without signal data.</summary>
public static ContainmentSignals Unknown => new("unknown", "unknown");
/// <summary>Well-sandboxed container profile.</summary>
public static ContainmentSignals WellSandboxed => new("enforced", "ro", "enforced", 20);
/// <summary>
/// Calculate containment deduction [0, 0.3] (higher = more contained = lower risk).
/// </summary>
public double Deduction()
{
var deduction = 0.0;
// Seccomp enforced: -0.10
if (Seccomp == "enforced") deduction += 0.10;
else if (Seccomp == "audit") deduction += 0.05;
// Read-only filesystem: -0.10
if (Fs == "ro") deduction += 0.10;
// Network policy enforced: -0.05
if (NetworkPolicy == "enforced") deduction += 0.05;
// Capabilities dropped (max 0.05)
deduction += Math.Min(Capabilities / 40.0 * 0.05, 0.05);
return Math.Clamp(deduction, 0, 0.30);
}
/// <summary>
/// Whether containment is well-configured.
/// </summary>
public bool IsWellContained => Seccomp == "enforced" && Fs == "ro";
}
/// <summary>
/// Enhanced unknown item for ranking and API responses.
/// Extends base unknown with blast radius and containment signals.
/// </summary>
/// <param name="Id">Unknown ID.</param>
/// <param name="ArtifactDigest">Digest of the artifact containing this unknown.</param>
/// <param name="ArtifactPurl">Package URL if applicable.</param>
/// <param name="Reasons">Reasons this is an unknown (e.g., "missing_vex", "ambiguous_indirect_call").</param>
/// <param name="BlastRadius">Blast radius signals.</param>
/// <param name="EvidenceScarcity">Evidence scarcity score [0, 1].</param>
/// <param name="ExploitPressure">Exploit pressure signals.</param>
/// <param name="Containment">Containment signals.</param>
/// <param name="Score">Computed ranking score [0, 1].</param>
/// <param name="ProofRef">Reference to proof bundle for this ranking.</param>
public sealed record UnknownItem(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("artifactDigest")] string ArtifactDigest,
[property: JsonPropertyName("artifactPurl")] string? ArtifactPurl,
[property: JsonPropertyName("reasons")] string[] Reasons,
[property: JsonPropertyName("blastRadius")] BlastRadius BlastRadius,
[property: JsonPropertyName("evidenceScarcity")] double EvidenceScarcity,
[property: JsonPropertyName("exploitPressure")] ExploitPressure ExploitPressure,
[property: JsonPropertyName("containment")] ContainmentSignals Containment,
[property: JsonPropertyName("score")] double Score,
[property: JsonPropertyName("proofRef")] string? ProofRef)
{
/// <summary>
/// Create an UnknownItem from a base Unknown with ranking signals.
/// </summary>
public static UnknownItem FromUnknown(
Unknown unknown,
BlastRadius blastRadius,
ExploitPressure exploitPressure,
ContainmentSignals containment,
double score,
string? proofRef = null)
{
// Extract reasons from context/kind
var reasons = unknown.Kind switch
{
UnknownKind.MissingVex => ["missing_vex"],
UnknownKind.AmbiguousIndirect => ["ambiguous_indirect_call"],
UnknownKind.NoGraph => ["no_dependency_graph"],
UnknownKind.StaleEvidence => ["stale_evidence"],
_ => [unknown.Kind.ToString().ToLowerInvariant()]
};
return new UnknownItem(
Id: unknown.Id.ToString(),
ArtifactDigest: unknown.SubjectHash,
ArtifactPurl: unknown.SubjectRef,
Reasons: reasons,
BlastRadius: blastRadius,
EvidenceScarcity: unknown.UncertaintyScore,
ExploitPressure: exploitPressure,
Containment: containment,
Score: score,
ProofRef: proofRef);
}
}

View File

@@ -0,0 +1,375 @@
// -----------------------------------------------------------------------------
// RuntimeSignalIngester.cs
// Sprint: SPRINT_3600_0002_0001_unknowns_ranking_containment
// Task: UNK-RANK-006 - Implement runtime signal ingestion for containment facts
// Description: Ingests runtime containment signals from container orchestrators
// -----------------------------------------------------------------------------
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Unknowns.Core.Models;
namespace StellaOps.Unknowns.Core.Services;
/// <summary>
/// Service for ingesting runtime containment signals from various sources.
/// Per advisory "Building a Deeper Moat Beyond Reachability" §17.5.
/// </summary>
public interface IRuntimeSignalIngester
{
/// <summary>
/// Ingest containment signals for a specific artifact digest.
/// </summary>
/// <param name="artifactDigest">SHA-256 digest of the artifact.</param>
/// <param name="signals">Raw signal data.</param>
/// <param name="source">Signal source (k8s, docker, podman, etc.).</param>
/// <param name="ct">Cancellation token.</param>
Task<ContainmentSignalResult> IngestAsync(
string artifactDigest,
RuntimeSignalData signals,
string source,
CancellationToken ct = default);
/// <summary>
/// Query containment signals for an artifact.
/// </summary>
/// <param name="artifactDigest">SHA-256 digest of the artifact.</param>
/// <param name="ct">Cancellation token.</param>
Task<ContainmentSignals> GetContainmentAsync(string artifactDigest, CancellationToken ct = default);
/// <summary>
/// Query blast radius signals for an artifact.
/// </summary>
/// <param name="artifactDigest">SHA-256 digest of the artifact.</param>
/// <param name="ct">Cancellation token.</param>
Task<BlastRadius> GetBlastRadiusAsync(string artifactDigest, CancellationToken ct = default);
/// <summary>
/// Query exploit pressure signals for an artifact.
/// </summary>
/// <param name="artifactDigest">SHA-256 digest of the artifact.</param>
/// <param name="ct">Cancellation token.</param>
Task<ExploitPressure> GetExploitPressureAsync(string artifactDigest, CancellationToken ct = default);
}
/// <summary>
/// Raw runtime signal data from orchestrators.
/// </summary>
public sealed record RuntimeSignalData
{
/// <summary>Container/pod ID.</summary>
public string? ContainerId { get; init; }
/// <summary>Namespace (k8s).</summary>
public string? Namespace { get; init; }
/// <summary>Seccomp profile status.</summary>
public string? SeccompProfile { get; init; }
/// <summary>Security context information.</summary>
public SecurityContextData? SecurityContext { get; init; }
/// <summary>Network policy status.</summary>
public NetworkPolicyData? NetworkPolicy { get; init; }
/// <summary>Resource consumption data.</summary>
public ResourceData? Resources { get; init; }
/// <summary>Timestamp of signal collection.</summary>
public DateTimeOffset CollectedAt { get; init; } = DateTimeOffset.UtcNow;
}
/// <summary>
/// Security context data from container runtime.
/// </summary>
public sealed record SecurityContextData
{
/// <summary>Whether running as root.</summary>
public bool? RunAsRoot { get; init; }
/// <summary>User ID.</summary>
public int? RunAsUser { get; init; }
/// <summary>Whether read-only root filesystem.</summary>
public bool? ReadOnlyRootFilesystem { get; init; }
/// <summary>Whether privilege escalation is allowed.</summary>
public bool? AllowPrivilegeEscalation { get; init; }
/// <summary>Dropped capabilities.</summary>
public IReadOnlyList<string>? DropCapabilities { get; init; }
/// <summary>Added capabilities.</summary>
public IReadOnlyList<string>? AddCapabilities { get; init; }
/// <summary>Whether running privileged.</summary>
public bool? Privileged { get; init; }
}
/// <summary>
/// Network policy data.
/// </summary>
public sealed record NetworkPolicyData
{
/// <summary>Whether ingress is restricted.</summary>
public bool? IngressRestricted { get; init; }
/// <summary>Whether egress is restricted.</summary>
public bool? EgressRestricted { get; init; }
/// <summary>Number of policies applied.</summary>
public int PolicyCount { get; init; }
/// <summary>Whether default deny is in effect.</summary>
public bool? DefaultDeny { get; init; }
}
/// <summary>
/// Resource consumption data for blast radius calculation.
/// </summary>
public sealed record ResourceData
{
/// <summary>Number of replicas.</summary>
public int? Replicas { get; init; }
/// <summary>Number of dependent services.</summary>
public int? Dependents { get; init; }
/// <summary>Whether exposed via LoadBalancer/Ingress.</summary>
public bool? NetFacing { get; init; }
/// <summary>Service type (ClusterIP, NodePort, LoadBalancer).</summary>
public string? ServiceType { get; init; }
}
/// <summary>
/// Result of signal ingestion.
/// </summary>
public sealed record ContainmentSignalResult(
bool Success,
string? Error,
ContainmentSignals? Containment,
BlastRadius? BlastRadius,
DateTimeOffset IngestedAt);
/// <summary>
/// Default implementation of IRuntimeSignalIngester.
/// </summary>
public sealed class RuntimeSignalIngester : IRuntimeSignalIngester
{
private readonly ILogger<RuntimeSignalIngester> _logger;
private readonly IRuntimeSignalStore _store;
public RuntimeSignalIngester(
ILogger<RuntimeSignalIngester> logger,
IRuntimeSignalStore store)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_store = store ?? throw new ArgumentNullException(nameof(store));
}
public async Task<ContainmentSignalResult> IngestAsync(
string artifactDigest,
RuntimeSignalData signals,
string source,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
ArgumentNullException.ThrowIfNull(signals);
try
{
// Convert raw signals to containment model
var containment = ConvertToContainment(signals);
var blastRadius = ConvertToBlastRadius(signals);
// Store the signals
await _store.StoreContainmentAsync(artifactDigest, containment, source, ct);
await _store.StoreBlastRadiusAsync(artifactDigest, blastRadius, source, ct);
_logger.LogInformation(
"Ingested runtime signals for {Digest} from {Source}: seccomp={Seccomp}, fs={Fs}, dependents={Deps}",
artifactDigest[..12], source, containment.Seccomp, containment.Fs, blastRadius.Dependents);
return new ContainmentSignalResult(
Success: true,
Error: null,
Containment: containment,
BlastRadius: blastRadius,
IngestedAt: DateTimeOffset.UtcNow);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to ingest runtime signals for {Digest}", artifactDigest[..12]);
return new ContainmentSignalResult(
Success: false,
Error: ex.Message,
Containment: null,
BlastRadius: null,
IngestedAt: DateTimeOffset.UtcNow);
}
}
public async Task<ContainmentSignals> GetContainmentAsync(string artifactDigest, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
var stored = await _store.GetContainmentAsync(artifactDigest, ct);
return stored ?? ContainmentSignals.Unknown;
}
public async Task<BlastRadius> GetBlastRadiusAsync(string artifactDigest, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
var stored = await _store.GetBlastRadiusAsync(artifactDigest, ct);
return stored ?? BlastRadius.Unknown;
}
public async Task<ExploitPressure> GetExploitPressureAsync(string artifactDigest, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
var stored = await _store.GetExploitPressureAsync(artifactDigest, ct);
return stored ?? ExploitPressure.Unknown;
}
private static ContainmentSignals ConvertToContainment(RuntimeSignalData signals)
{
// Seccomp status
var seccomp = signals.SeccompProfile?.ToLowerInvariant() switch
{
"runtimedefault" or "runtime/default" => "enforced",
"localhost" or "localhost/*" => "enforced",
"unconfined" => "disabled",
_ => "unknown"
};
// If security context has explicit seccomp, prefer that
if (signals.SecurityContext is not null)
{
if (signals.SecurityContext.Privileged == true)
seccomp = "disabled"; // Privileged overrides seccomp
}
// Filesystem status
var fs = signals.SecurityContext?.ReadOnlyRootFilesystem == true ? "ro" : "rw";
// Network policy status
var networkPolicy = "unknown";
if (signals.NetworkPolicy is not null)
{
if (signals.NetworkPolicy.DefaultDeny == true ||
(signals.NetworkPolicy.IngressRestricted == true && signals.NetworkPolicy.EgressRestricted == true))
{
networkPolicy = "enforced";
}
else if (signals.NetworkPolicy.PolicyCount > 0)
{
networkPolicy = "audit";
}
else
{
networkPolicy = "disabled";
}
}
// Dropped capabilities count
var capabilities = signals.SecurityContext?.DropCapabilities?.Count ?? 0;
return new ContainmentSignals(seccomp, fs, networkPolicy, capabilities);
}
private static BlastRadius ConvertToBlastRadius(RuntimeSignalData signals)
{
var dependents = signals.Resources?.Dependents ?? 0;
// Net facing check
var netFacing = signals.Resources?.NetFacing == true ||
signals.Resources?.ServiceType is "LoadBalancer" or "NodePort";
// Privilege check
var privilege = "user";
if (signals.SecurityContext?.RunAsRoot == true || signals.SecurityContext?.RunAsUser == 0)
privilege = "root";
else if (signals.SecurityContext?.Privileged == true)
privilege = "root";
else if (signals.SecurityContext?.AllowPrivilegeEscalation == true)
privilege = "elevated";
return new BlastRadius(dependents, netFacing, privilege);
}
}
/// <summary>
/// Storage interface for runtime signals.
/// </summary>
public interface IRuntimeSignalStore
{
Task StoreContainmentAsync(string artifactDigest, ContainmentSignals signals, string source, CancellationToken ct);
Task StoreBlastRadiusAsync(string artifactDigest, BlastRadius signals, string source, CancellationToken ct);
Task<ContainmentSignals?> GetContainmentAsync(string artifactDigest, CancellationToken ct);
Task<BlastRadius?> GetBlastRadiusAsync(string artifactDigest, CancellationToken ct);
Task<ExploitPressure?> GetExploitPressureAsync(string artifactDigest, CancellationToken ct);
}
/// <summary>
/// In-memory implementation for testing.
/// </summary>
public sealed class InMemoryRuntimeSignalStore : IRuntimeSignalStore
{
private readonly Dictionary<string, ContainmentSignals> _containment = new();
private readonly Dictionary<string, BlastRadius> _blastRadius = new();
private readonly Dictionary<string, ExploitPressure> _exploitPressure = new();
private readonly object _lock = new();
public Task StoreContainmentAsync(string artifactDigest, ContainmentSignals signals, string source, CancellationToken ct)
{
lock (_lock)
{
_containment[artifactDigest] = signals;
}
return Task.CompletedTask;
}
public Task StoreBlastRadiusAsync(string artifactDigest, BlastRadius signals, string source, CancellationToken ct)
{
lock (_lock)
{
_blastRadius[artifactDigest] = signals;
}
return Task.CompletedTask;
}
public Task<ContainmentSignals?> GetContainmentAsync(string artifactDigest, CancellationToken ct)
{
lock (_lock)
{
return Task.FromResult(_containment.TryGetValue(artifactDigest, out var signals) ? signals : null);
}
}
public Task<BlastRadius?> GetBlastRadiusAsync(string artifactDigest, CancellationToken ct)
{
lock (_lock)
{
return Task.FromResult(_blastRadius.TryGetValue(artifactDigest, out var signals) ? signals : null);
}
}
public Task<ExploitPressure?> GetExploitPressureAsync(string artifactDigest, CancellationToken ct)
{
lock (_lock)
{
return Task.FromResult(_exploitPressure.TryGetValue(artifactDigest, out var signals) ? signals : null);
}
}
public void SetExploitPressure(string artifactDigest, ExploitPressure pressure)
{
lock (_lock)
{
_exploitPressure[artifactDigest] = pressure;
}
}
}

View File

@@ -0,0 +1,206 @@
// -----------------------------------------------------------------------------
// UnknownProofEmitter.cs
// Sprint: SPRINT_3600_0002_0001_unknowns_ranking_containment
// Task: UNK-RANK-004 - Add proof ledger emission for unknown ranking
// Description: Emits proof nodes explaining unknown ranking factors
// -----------------------------------------------------------------------------
using StellaOps.Policy.Scoring;
using StellaOps.Unknowns.Core.Models;
namespace StellaOps.Unknowns.Core.Services;
/// <summary>
/// Service for emitting proof nodes for unknown ranking decisions.
/// Each unknown produces a mini proof ledger explaining ranking factors.
/// </summary>
public interface IUnknownProofEmitter
{
/// <summary>
/// Create a proof ledger for an unknown ranking decision.
/// </summary>
/// <param name="unknown">The unknown being ranked.</param>
/// <param name="blastRadius">Blast radius signals.</param>
/// <param name="exploitPressure">Exploit pressure signals.</param>
/// <param name="containment">Containment signals.</param>
/// <param name="finalScore">The final computed score.</param>
/// <returns>Proof ledger with ranking explanation.</returns>
ProofLedger EmitProof(
Unknown unknown,
BlastRadius blastRadius,
ExploitPressure exploitPressure,
ContainmentSignals containment,
double finalScore);
}
/// <summary>
/// Default implementation of IUnknownProofEmitter.
/// </summary>
public sealed class UnknownProofEmitter : IUnknownProofEmitter
{
private const string ActorName = "unknown-ranker";
private static readonly byte[] DefaultSeed = new byte[32];
/// <inheritdoc />
public ProofLedger EmitProof(
Unknown unknown,
BlastRadius blastRadius,
ExploitPressure exploitPressure,
ContainmentSignals containment,
double finalScore)
{
ArgumentNullException.ThrowIfNull(unknown);
ArgumentNullException.ThrowIfNull(blastRadius);
ArgumentNullException.ThrowIfNull(exploitPressure);
ArgumentNullException.ThrowIfNull(containment);
var ledger = new ProofLedger();
var now = DateTimeOffset.UtcNow;
double runningTotal = 0;
// Input node: capture reasons and evidence scarcity
var inputNode = ProofNode.Create(
id: $"unk-{unknown.Id}-input",
kind: ProofNodeKind.Input,
ruleId: "unknown.input",
actor: ActorName,
tsUtc: now,
seed: DefaultSeed,
delta: 0,
total: 0,
evidenceRefs: [
$"unknown:{unknown.Id}",
$"kind:{unknown.Kind}",
$"severity:{unknown.Severity}",
$"scarcity:{unknown.UncertaintyScore:F4}"
]);
ledger.Append(inputNode);
// Delta node: blast radius component
var blastDelta = blastRadius.Score() * 0.60; // 60% weight
runningTotal += blastDelta;
var blastNode = ProofNode.Create(
id: $"unk-{unknown.Id}-blast",
kind: ProofNodeKind.Delta,
ruleId: "unknown.blast_radius",
actor: ActorName,
tsUtc: now.AddMicroseconds(1),
seed: DefaultSeed,
delta: blastDelta,
total: runningTotal,
parentIds: [inputNode.Id],
evidenceRefs: [
$"dependents:{blastRadius.Dependents}",
$"net_facing:{blastRadius.NetFacing}",
$"privilege:{blastRadius.Privilege}",
$"blast_score:{blastRadius.Score():F4}"
]);
ledger.Append(blastNode);
// Delta node: evidence scarcity component
var scarcityDelta = unknown.UncertaintyScore * 0.30; // 30% weight
runningTotal += scarcityDelta;
var scarcityNode = ProofNode.Create(
id: $"unk-{unknown.Id}-scarcity",
kind: ProofNodeKind.Delta,
ruleId: "unknown.scarcity",
actor: ActorName,
tsUtc: now.AddMicroseconds(2),
seed: DefaultSeed,
delta: scarcityDelta,
total: runningTotal,
parentIds: [blastNode.Id],
evidenceRefs: [
$"uncertainty:{unknown.UncertaintyScore:F4}",
$"scarcity_delta:{scarcityDelta:F4}"
]);
ledger.Append(scarcityNode);
// Delta node: exploit pressure component
var pressureDelta = exploitPressure.Score() * 0.30; // 30% weight
runningTotal += pressureDelta;
var pressureNode = ProofNode.Create(
id: $"unk-{unknown.Id}-pressure",
kind: ProofNodeKind.Delta,
ruleId: "unknown.exploit_pressure",
actor: ActorName,
tsUtc: now.AddMicroseconds(3),
seed: DefaultSeed,
delta: pressureDelta,
total: runningTotal,
parentIds: [scarcityNode.Id],
evidenceRefs: [
$"epss:{exploitPressure.Epss:F4}",
$"kev:{exploitPressure.Kev}",
$"pressure_score:{exploitPressure.Score():F4}"
]);
ledger.Append(pressureNode);
// Delta node: containment deduction (negative delta)
var containmentDeduction = containment.Deduction();
if (Math.Abs(containmentDeduction) > 0.0001)
{
runningTotal -= containmentDeduction;
var containmentNode = ProofNode.Create(
id: $"unk-{unknown.Id}-containment",
kind: ProofNodeKind.Delta,
ruleId: "unknown.containment",
actor: ActorName,
tsUtc: now.AddMicroseconds(4),
seed: DefaultSeed,
delta: -containmentDeduction, // Negative because it's a deduction
total: runningTotal,
parentIds: [pressureNode.Id],
evidenceRefs: [
$"seccomp:{containment.Seccomp}",
$"fs:{containment.Fs}",
$"deduction:{containmentDeduction:F4}"
]);
ledger.Append(containmentNode);
}
// Score node: final score
var scoreNode = ProofNode.Create(
id: $"unk-{unknown.Id}-score",
kind: ProofNodeKind.Score,
ruleId: "unknown.final_score",
actor: ActorName,
tsUtc: now.AddMicroseconds(5),
seed: DefaultSeed,
delta: 0,
total: finalScore,
parentIds: containmentDeduction > 0
? [$"unk-{unknown.Id}-containment"]
: [pressureNode.Id],
evidenceRefs: [
$"final_score:{finalScore:F4}",
$"band:{finalScore.ToTriageBand()}",
$"priority:{finalScore.ToPriorityLabel()}"
]);
ledger.Append(scoreNode);
return ledger;
}
}
/// <summary>
/// Extension methods for integrating proof emission with ranking.
/// </summary>
public static class UnknownProofExtensions
{
/// <summary>
/// Rank an unknown and emit a proof ledger.
/// </summary>
public static (UnknownItem Item, ProofLedger Proof) RankWithProof(
this IUnknownRanker ranker,
IUnknownProofEmitter emitter,
Unknown unknown,
BlastRadius blastRadius,
ExploitPressure exploitPressure,
ContainmentSignals containment)
{
var item = ranker.RankUnknown(unknown, blastRadius, exploitPressure, containment);
var proof = emitter.EmitProof(unknown, blastRadius, exploitPressure, containment, item.Score);
return (item, proof);
}
}

View File

@@ -0,0 +1,162 @@
// -----------------------------------------------------------------------------
// UnknownRanker.cs
// Sprint: SPRINT_3600_0002_0001_unknowns_ranking_containment
// Task: UNK-RANK-003 - Implement UnknownRanker.Rank() with containment deductions
// Description: Ranks unknowns by blast radius, scarcity, pressure, and containment
// -----------------------------------------------------------------------------
using StellaOps.Unknowns.Core.Models;
namespace StellaOps.Unknowns.Core.Services;
/// <summary>
/// Service for ranking unknowns by risk.
/// Per advisory "Building a Deeper Moat Beyond Reachability" §17.5.
/// </summary>
public interface IUnknownRanker
{
/// <summary>
/// Compute a risk score for an unknown based on blast radius, evidence scarcity,
/// exploit pressure, and containment signals.
/// </summary>
/// <param name="blastRadius">Blast radius signals.</param>
/// <param name="scarcity">Evidence scarcity score [0, 1].</param>
/// <param name="exploitPressure">Exploit pressure signals.</param>
/// <param name="containment">Containment signals.</param>
/// <returns>Risk score [0, 1] where higher = more urgent.</returns>
double Rank(BlastRadius blastRadius, double scarcity, ExploitPressure exploitPressure, ContainmentSignals containment);
/// <summary>
/// Compute a ranked UnknownItem from a base Unknown with signals.
/// </summary>
UnknownItem RankUnknown(Unknown unknown, BlastRadius blastRadius, ExploitPressure exploitPressure, ContainmentSignals containment);
}
/// <summary>
/// Default implementation of IUnknownRanker.
/// </summary>
public sealed class UnknownRanker : IUnknownRanker
{
// Weight configuration (can be made configurable via options)
private readonly RankingWeights _weights;
public UnknownRanker() : this(RankingWeights.Default) { }
public UnknownRanker(RankingWeights weights)
{
_weights = weights ?? RankingWeights.Default;
}
/// <inheritdoc />
public double Rank(BlastRadius blastRadius, double scarcity, ExploitPressure exploitPressure, ContainmentSignals containment)
{
ArgumentNullException.ThrowIfNull(blastRadius);
ArgumentNullException.ThrowIfNull(exploitPressure);
ArgumentNullException.ThrowIfNull(containment);
// Blast radius component: how much damage could this cause?
var blast = blastRadius.Score();
// Evidence scarcity: how much do we not know?
var scarcity01 = Math.Clamp(scarcity, 0, 1);
// Exploit pressure: how likely is this to be exploited?
var pressure = exploitPressure.Score();
// Containment deduction: well-sandboxed = lower risk
var containmentDeduction = containment.Deduction();
// Weighted score with containment as a deduction
var rawScore = _weights.BlastRadius * blast +
_weights.Scarcity * scarcity01 +
_weights.ExploitPressure * pressure;
// Apply containment deduction
var finalScore = rawScore - containmentDeduction;
return Math.Clamp(Math.Round(finalScore, 4), 0, 1);
}
/// <inheritdoc />
public UnknownItem RankUnknown(Unknown unknown, BlastRadius blastRadius, ExploitPressure exploitPressure, ContainmentSignals containment)
{
ArgumentNullException.ThrowIfNull(unknown);
var score = Rank(blastRadius, unknown.UncertaintyScore, exploitPressure, containment);
return UnknownItem.FromUnknown(
unknown,
blastRadius,
exploitPressure,
containment,
score);
}
/// <summary>
/// Compute ranking for a batch of unknowns and sort by score descending.
/// </summary>
public IReadOnlyList<UnknownItem> RankAndSort(
IEnumerable<(Unknown Unknown, BlastRadius Blast, ExploitPressure Exploit, ContainmentSignals Containment)> items)
{
return items
.Select(i => RankUnknown(i.Unknown, i.Blast, i.Exploit, i.Containment))
.OrderByDescending(i => i.Score)
.ToList();
}
}
/// <summary>
/// Configurable weights for unknown ranking.
/// </summary>
public sealed record RankingWeights(
double BlastRadius,
double Scarcity,
double ExploitPressure)
{
/// <summary>
/// Default weights per advisory specification:
/// - Blast radius: 60%
/// - Scarcity: 30%
/// - Exploit pressure: 30%
/// Note: These sum to > 100% because containment provides deductions.
/// </summary>
public static RankingWeights Default => new(0.60, 0.30, 0.30);
/// <summary>
/// Conservative weights with higher blast radius emphasis.
/// </summary>
public static RankingWeights Conservative => new(0.70, 0.20, 0.30);
/// <summary>
/// Exploit-focused weights for KEV/EPSS prioritization.
/// </summary>
public static RankingWeights ExploitFocused => new(0.40, 0.20, 0.50);
}
/// <summary>
/// Extension methods for unknown ranking.
/// </summary>
public static class UnknownRankingExtensions
{
/// <summary>
/// Determine triage band based on ranking score.
/// </summary>
public static TriageBand ToTriageBand(this double score) => score switch
{
>= 0.7 => TriageBand.Hot,
>= 0.4 => TriageBand.Warm,
_ => TriageBand.Cold
};
/// <summary>
/// Get human-readable priority label.
/// </summary>
public static string ToPriorityLabel(this double score) => score switch
{
>= 0.8 => "Critical",
>= 0.6 => "High",
>= 0.4 => "Medium",
>= 0.2 => "Low",
_ => "Info"
};
}

View File

@@ -0,0 +1,364 @@
// -----------------------------------------------------------------------------
// UnknownRankerTests.cs
// Sprint: SPRINT_3600_0002_0001_unknowns_ranking_containment
// Task: UNK-RANK-009 - Unit tests for ranking function
// Description: Tests for unknown ranking determinism and edge cases
// -----------------------------------------------------------------------------
using FluentAssertions;
using StellaOps.Unknowns.Core.Models;
using StellaOps.Unknowns.Core.Services;
using Xunit;
namespace StellaOps.Unknowns.Core.Tests.Services;
/// <summary>
/// Unit tests for UnknownRanker.
/// </summary>
public class UnknownRankerTests
{
private readonly UnknownRanker _ranker = new();
#region Basic Ranking Tests
[Fact]
public void Rank_HighBlastHighPressure_ReturnsHighScore()
{
// Arrange
var blast = new BlastRadius(100, NetFacing: true, Privilege: "root");
var pressure = new ExploitPressure(0.90, Kev: true);
var containment = ContainmentSignals.Unknown;
// Act
var score = _ranker.Rank(blast, scarcity: 0.8, pressure, containment);
// Assert - should be very high (close to 1.0)
score.Should().BeGreaterOrEqualTo(0.8);
}
[Fact]
public void Rank_LowBlastLowPressure_ReturnsLowScore()
{
// Arrange
var blast = new BlastRadius(1, NetFacing: false, Privilege: "none");
var pressure = new ExploitPressure(0.01, Kev: false);
var containment = ContainmentSignals.Unknown;
// Act
var score = _ranker.Rank(blast, scarcity: 0.1, pressure, containment);
// Assert - should be low
score.Should().BeLessThan(0.3);
}
[Fact]
public void Rank_WithContainment_ReducesScore()
{
// Arrange
var blast = new BlastRadius(50, NetFacing: true, Privilege: "user");
var pressure = new ExploitPressure(0.5, Kev: false);
var noContainment = ContainmentSignals.Unknown;
var wellContained = ContainmentSignals.WellSandboxed;
// Act
var scoreNoContainment = _ranker.Rank(blast, scarcity: 0.5, pressure, noContainment);
var scoreWellContained = _ranker.Rank(blast, scarcity: 0.5, pressure, wellContained);
// Assert - containment should reduce score
scoreWellContained.Should().BeLessThan(scoreNoContainment);
(scoreNoContainment - scoreWellContained).Should().BeGreaterOrEqualTo(0.15); // At least 0.15 reduction
}
#endregion
#region Containment Signal Tests
[Fact]
public void ContainmentSignals_SeccompEnforced_ProvidesDeduction()
{
// Arrange
var containment = new ContainmentSignals("enforced", "rw");
// Act
var deduction = containment.Deduction();
// Assert
deduction.Should().BeApproximately(0.10, 0.001);
}
[Fact]
public void ContainmentSignals_ReadOnlyFs_ProvidesDeduction()
{
// Arrange
var containment = new ContainmentSignals("disabled", "ro");
// Act
var deduction = containment.Deduction();
// Assert
deduction.Should().BeApproximately(0.10, 0.001);
}
[Fact]
public void ContainmentSignals_WellSandboxed_ProvidesMaxDeduction()
{
// Arrange
var containment = ContainmentSignals.WellSandboxed; // seccomp=enforced, fs=ro, netpol=enforced, caps=20
// Act
var deduction = containment.Deduction();
// Assert - should be significant
deduction.Should().BeGreaterOrEqualTo(0.25);
deduction.Should().BeLessOrEqualTo(0.30);
}
[Fact]
public void ContainmentSignals_Unknown_ProvidesNoDeduction()
{
// Arrange
var containment = ContainmentSignals.Unknown;
// Act
var deduction = containment.Deduction();
// Assert
deduction.Should().Be(0);
}
#endregion
#region Blast Radius Tests
[Fact]
public void BlastRadius_HighDependents_IncreasesScore()
{
// Arrange
var lowDeps = new BlastRadius(5, NetFacing: false, Privilege: "none");
var highDeps = new BlastRadius(100, NetFacing: false, Privilege: "none");
// Act
var lowScore = lowDeps.Score();
var highScore = highDeps.Score();
// Assert
highScore.Should().BeGreaterThan(lowScore);
}
[Fact]
public void BlastRadius_NetFacing_IncreasesScore()
{
// Arrange
var notNetFacing = new BlastRadius(10, NetFacing: false, Privilege: "none");
var netFacing = new BlastRadius(10, NetFacing: true, Privilege: "none");
// Act
var notNetScore = notNetFacing.Score();
var netScore = netFacing.Score();
// Assert
netScore.Should().BeGreaterThan(notNetScore);
(netScore - notNetScore).Should().BeApproximately(0.25, 0.01); // 0.5 / 2 = 0.25
}
[Fact]
public void BlastRadius_RootPrivilege_IncreasesScore()
{
// Arrange
var userPriv = new BlastRadius(10, NetFacing: false, Privilege: "user");
var rootPriv = new BlastRadius(10, NetFacing: false, Privilege: "root");
// Act
var userScore = userPriv.Score();
var rootScore = rootPriv.Score();
// Assert
rootScore.Should().BeGreaterThan(userScore);
}
#endregion
#region Exploit Pressure Tests
[Fact]
public void ExploitPressure_HighEpss_IncreasesScore()
{
// Arrange
var lowEpss = new ExploitPressure(0.01, Kev: false);
var highEpss = new ExploitPressure(0.90, Kev: false);
// Act
var lowScore = lowEpss.Score();
var highScore = highEpss.Score();
// Assert
highScore.Should().BeGreaterThan(lowScore);
}
[Fact]
public void ExploitPressure_Kev_IncreasesScore()
{
// Arrange
var noKev = new ExploitPressure(0.5, Kev: false);
var withKev = new ExploitPressure(0.5, Kev: true);
// Act
var noKevScore = noKev.Score();
var withKevScore = withKev.Score();
// Assert
withKevScore.Should().BeGreaterThan(noKevScore);
(withKevScore - noKevScore).Should().BeApproximately(0.30, 0.001);
}
[Fact]
public void ExploitPressure_NullEpss_UsesDefault()
{
// Arrange
var unknownEpss = ExploitPressure.Unknown;
// Act
var score = unknownEpss.Score();
// Assert - should use 0.35 default
score.Should().BeApproximately(0.35, 0.01);
}
#endregion
#region Determinism Tests
[Fact]
public void Rank_SameInputs_ReturnsSameScore()
{
// Arrange
var blast = new BlastRadius(42, NetFacing: true, Privilege: "user");
var pressure = new ExploitPressure(0.67, Kev: true);
var containment = new ContainmentSignals("enforced", "ro");
// Act - rank multiple times
var score1 = _ranker.Rank(blast, scarcity: 0.55, pressure, containment);
var score2 = _ranker.Rank(blast, scarcity: 0.55, pressure, containment);
var score3 = _ranker.Rank(blast, scarcity: 0.55, pressure, containment);
// Assert - all scores should be identical
score1.Should().Be(score2);
score2.Should().Be(score3);
}
[Fact]
public void Rank_SlightlyDifferentInputs_ReturnsDifferentScores()
{
// Arrange
var blast1 = new BlastRadius(42, NetFacing: true, Privilege: "user");
var blast2 = new BlastRadius(43, NetFacing: true, Privilege: "user"); // Just 1 more dependent
var pressure = new ExploitPressure(0.67, Kev: false);
var containment = ContainmentSignals.Unknown;
// Act
var score1 = _ranker.Rank(blast1, scarcity: 0.55, pressure, containment);
var score2 = _ranker.Rank(blast2, scarcity: 0.55, pressure, containment);
// Assert - scores should be different
score1.Should().NotBe(score2);
}
#endregion
#region Boundary Tests
[Fact]
public void Rank_AlwaysReturnsScoreInRange()
{
// Test many combinations to ensure score is always [0, 1]
var testCases = new[]
{
(new BlastRadius(0, false, "none"), 0.0, new ExploitPressure(0, false), ContainmentSignals.Unknown),
(new BlastRadius(1000, true, "root"), 1.0, new ExploitPressure(1.0, true), ContainmentSignals.Unknown),
(new BlastRadius(50, true, "root"), 0.5, new ExploitPressure(0.5, true), ContainmentSignals.WellSandboxed),
};
foreach (var (blast, scarcity, pressure, containment) in testCases)
{
var score = _ranker.Rank(blast, scarcity, pressure, containment);
score.Should().BeInRange(0, 1);
}
}
[Fact]
public void Rank_NegativeValues_ClampedToZero()
{
// Arrange - minimal risk with high containment
var blast = new BlastRadius(0, NetFacing: false, Privilege: "none");
var pressure = new ExploitPressure(0, Kev: false);
var containment = ContainmentSignals.WellSandboxed;
// Act
var score = _ranker.Rank(blast, scarcity: 0, pressure, containment);
// Assert - should be clamped to 0, not negative
score.Should().BeGreaterOrEqualTo(0);
}
#endregion
#region Triage Band Tests
[Theory]
[InlineData(0.9, "Hot")]
[InlineData(0.7, "Hot")]
[InlineData(0.5, "Warm")]
[InlineData(0.4, "Warm")]
[InlineData(0.3, "Cold")]
[InlineData(0.1, "Cold")]
public void ToTriageBand_ReturnsCorrectBand(double score, string expected)
{
// Act
var band = score.ToTriageBand();
// Assert
band.ToString().Should().Be(expected);
}
[Theory]
[InlineData(0.9, "Critical")]
[InlineData(0.8, "Critical")]
[InlineData(0.7, "High")]
[InlineData(0.6, "High")]
[InlineData(0.5, "Medium")]
[InlineData(0.3, "Low")]
[InlineData(0.1, "Info")]
public void ToPriorityLabel_ReturnsCorrectLabel(double score, string expected)
{
// Act
var label = score.ToPriorityLabel();
// Assert
label.Should().Be(expected);
}
#endregion
#region Custom Weights Tests
[Fact]
public void Rank_WithExploitFocusedWeights_PrioritizesExploitPressure()
{
// Arrange
var rankerDefault = new UnknownRanker(RankingWeights.Default);
var rankerExploitFocused = new UnknownRanker(RankingWeights.ExploitFocused);
var blast = new BlastRadius(10, NetFacing: false, Privilege: "none"); // Low blast
var pressure = new ExploitPressure(0.95, Kev: true); // High pressure
var containment = ContainmentSignals.Unknown;
// Act
var scoreDefault = rankerDefault.Rank(blast, scarcity: 0.3, pressure, containment);
var scoreExploitFocused = rankerExploitFocused.Rank(blast, scarcity: 0.3, pressure, containment);
// Assert - exploit-focused should rank this higher
scoreExploitFocused.Should().BeGreaterThan(scoreDefault);
}
#endregion
}