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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user