work
This commit is contained in:
@@ -1,6 +0,0 @@
|
||||
namespace StellaOps.RiskEngine.Core;
|
||||
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.RiskEngine.Core.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Result of a risk score computation.
|
||||
/// </summary>
|
||||
public sealed record RiskScoreResult(
|
||||
Guid JobId,
|
||||
string Provider,
|
||||
string Subject,
|
||||
double Score,
|
||||
bool Success,
|
||||
string? Error,
|
||||
IReadOnlyDictionary<string, double> Signals,
|
||||
DateTimeOffset CompletedAtUtc);
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.RiskEngine.Core.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Input for a risk score computation. Subject is an opaque asset/id; Signals are deterministic numeric factors.
|
||||
/// </summary>
|
||||
public sealed record ScoreRequest(
|
||||
string Provider,
|
||||
string Subject,
|
||||
IReadOnlyDictionary<string, double> Signals);
|
||||
|
||||
/// <summary>
|
||||
/// Job envelope carried through the queue.
|
||||
/// </summary>
|
||||
public sealed record RiskScoreJob(Guid JobId, ScoreRequest Request);
|
||||
@@ -0,0 +1,37 @@
|
||||
using StellaOps.RiskEngine.Core.Contracts;
|
||||
|
||||
namespace StellaOps.RiskEngine.Core.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Risk provider that derives score from CVSS base score and KEV flag.
|
||||
/// Score formula: clamp01((cvss/10) + kevBonus), where kevBonus = 0.2 if KEV, else 0.
|
||||
/// </summary>
|
||||
public sealed class CvssKevProvider : IRiskScoreProvider
|
||||
{
|
||||
public const string ProviderName = "cvss-kev";
|
||||
|
||||
private readonly ICvssSource cvss;
|
||||
private readonly IKevSource kev;
|
||||
|
||||
public CvssKevProvider(ICvssSource cvss, IKevSource kev)
|
||||
{
|
||||
this.cvss = cvss ?? throw new ArgumentNullException(nameof(cvss));
|
||||
this.kev = kev ?? throw new ArgumentNullException(nameof(kev));
|
||||
}
|
||||
|
||||
public string Name => ProviderName;
|
||||
|
||||
public async Task<double> ScoreAsync(ScoreRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var cvssScore = await cvss.GetCvssAsync(request.Subject, cancellationToken).ConfigureAwait(false) ?? 0d;
|
||||
cvssScore = Math.Clamp(cvssScore, 0d, 10d);
|
||||
|
||||
var kevFlag = await kev.IsKevAsync(request.Subject, cancellationToken).ConfigureAwait(false) ?? false;
|
||||
|
||||
var kevBonus = kevFlag ? 0.2d : 0d;
|
||||
var raw = (cvssScore / 10d) + kevBonus;
|
||||
return Math.Round(Math.Min(1d, raw), 6, MidpointRounding.ToEven);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using StellaOps.RiskEngine.Core.Contracts;
|
||||
|
||||
namespace StellaOps.RiskEngine.Core.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Default provider that clamps each signal to [0,1] and averages the result.
|
||||
/// Deterministic and side-effect free.
|
||||
/// </summary>
|
||||
public sealed class DefaultTransformsProvider : IRiskScoreProvider
|
||||
{
|
||||
public const string ProviderName = "default-transforms";
|
||||
|
||||
public string Name => ProviderName;
|
||||
|
||||
public Task<double> ScoreAsync(ScoreRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (request.Signals.Count == 0)
|
||||
{
|
||||
return Task.FromResult(0d);
|
||||
}
|
||||
|
||||
var sum = 0d;
|
||||
foreach (var kvp in request.Signals.OrderBy(k => k.Key, StringComparer.Ordinal))
|
||||
{
|
||||
sum += Clamp01(kvp.Value);
|
||||
}
|
||||
|
||||
var average = sum / request.Signals.Count;
|
||||
return Task.FromResult(Math.Round(average, 6, MidpointRounding.ToEven));
|
||||
}
|
||||
|
||||
private static double Clamp01(double value) =>
|
||||
value switch
|
||||
{
|
||||
< 0d => 0d,
|
||||
> 1d => 1d,
|
||||
_ => value
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using StellaOps.RiskEngine.Core.Contracts;
|
||||
|
||||
namespace StellaOps.RiskEngine.Core.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Combines fix availability, asset criticality, and internet exposure into a bounded score.
|
||||
/// Formula: clamp01(0.5 * FixAvailability + 0.3 * Criticality + 0.2 * Exposure).
|
||||
/// Inputs are expected in [0,1]; missing keys default to 0.
|
||||
/// </summary>
|
||||
public sealed class FixExposureProvider : IRiskScoreProvider
|
||||
{
|
||||
public const string ProviderName = "fix-exposure";
|
||||
|
||||
public string Name => ProviderName;
|
||||
|
||||
public Task<double> ScoreAsync(ScoreRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var fix = Get(request, "FixAvailability");
|
||||
var crit = Get(request, "Criticality");
|
||||
var exposure = Get(request, "Exposure");
|
||||
|
||||
var weighted = (0.5 * fix) + (0.3 * crit) + (0.2 * exposure);
|
||||
var score = Math.Round(Math.Clamp(weighted, 0d, 1d), 6, MidpointRounding.ToEven);
|
||||
return Task.FromResult(score);
|
||||
}
|
||||
|
||||
private static double Get(ScoreRequest request, string key) =>
|
||||
request.Signals.TryGetValue(key, out var value)
|
||||
? Math.Clamp(value, 0d, 1d)
|
||||
: 0d;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace StellaOps.RiskEngine.Core.Providers;
|
||||
|
||||
public interface ICvssSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns CVSS base score (0-10) for the subject, or null if unknown.
|
||||
/// </summary>
|
||||
Task<double?> GetCvssAsync(string subject, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IKevSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true if the subject is marked as known exploited (KEV), false otherwise, null if unknown.
|
||||
/// </summary>
|
||||
Task<bool?> IsKevAsync(string subject, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null-object CVSS source returning no score (treat as unknown).
|
||||
/// </summary>
|
||||
public sealed class NullCvssSource : ICvssSource
|
||||
{
|
||||
public Task<double?> GetCvssAsync(string subject, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<double?>(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null-object KEV source returning false.
|
||||
/// </summary>
|
||||
public sealed class NullKevSource : IKevSource
|
||||
{
|
||||
public Task<bool?> IsKevAsync(string subject, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<bool?>(false);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using StellaOps.RiskEngine.Core.Contracts;
|
||||
|
||||
namespace StellaOps.RiskEngine.Core.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Computes a risk score for a request. Implementations must be deterministic for identical inputs.
|
||||
/// </summary>
|
||||
public interface IRiskScoreProvider
|
||||
{
|
||||
string Name { get; }
|
||||
|
||||
Task<double> ScoreAsync(ScoreRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IRiskScoreProviderRegistry
|
||||
{
|
||||
bool TryGet(string name, out IRiskScoreProvider provider);
|
||||
|
||||
IReadOnlyCollection<string> ProviderNames { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple in-memory provider registry.
|
||||
/// </summary>
|
||||
public sealed class RiskScoreProviderRegistry : IRiskScoreProviderRegistry
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, IRiskScoreProvider> providers;
|
||||
|
||||
public RiskScoreProviderRegistry(IEnumerable<IRiskScoreProvider> providers)
|
||||
{
|
||||
var map = new Dictionary<string, IRiskScoreProvider>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var provider in providers)
|
||||
{
|
||||
map[provider.Name] = provider;
|
||||
}
|
||||
|
||||
this.providers = map;
|
||||
}
|
||||
|
||||
public bool TryGet(string name, out IRiskScoreProvider provider) =>
|
||||
providers.TryGetValue(name, out provider!);
|
||||
|
||||
public IReadOnlyCollection<string> ProviderNames => providers.Keys.ToArray();
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using StellaOps.RiskEngine.Core.Contracts;
|
||||
|
||||
namespace StellaOps.RiskEngine.Core.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// VEX gate provider that short-circuits scoring when a denial is present.
|
||||
/// Signals are ignored when <c>HasDenial</c> is true.
|
||||
/// </summary>
|
||||
public sealed class VexGateProvider : IRiskScoreProvider
|
||||
{
|
||||
public const string ProviderName = "vex-gate";
|
||||
|
||||
public string Name => ProviderName;
|
||||
|
||||
public Task<double> ScoreAsync(ScoreRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var hasDenial = request.Signals.TryGetValue("HasDenial", out var denialFlag) && denialFlag >= 1;
|
||||
if (hasDenial)
|
||||
{
|
||||
return Task.FromResult(0d);
|
||||
}
|
||||
|
||||
// Fall back to simple max of remaining signals (if any)
|
||||
var max = request.Signals
|
||||
.Where(kvp => !string.Equals(kvp.Key, "HasDenial", StringComparison.Ordinal))
|
||||
.Select(kvp => kvp.Value)
|
||||
.DefaultIfEmpty(0d)
|
||||
.Max();
|
||||
|
||||
var score = Math.Clamp(max, 0d, 1d);
|
||||
return Task.FromResult(Math.Round(score, 6, MidpointRounding.ToEven));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using StellaOps.RiskEngine.Core.Contracts;
|
||||
|
||||
namespace StellaOps.RiskEngine.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Persists risk score results for later retrieval/ledger projection.
|
||||
/// Implementations must be deterministic and side‑effect free for identical inputs.
|
||||
/// </summary>
|
||||
public interface IRiskScoreResultStore
|
||||
{
|
||||
Task SaveAsync(RiskScoreResult result, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Threading.Channels;
|
||||
using StellaOps.RiskEngine.Core.Contracts;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace StellaOps.RiskEngine.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic FIFO queue for risk score jobs.
|
||||
/// </summary>
|
||||
public sealed class RiskScoreQueue
|
||||
{
|
||||
private readonly Channel<RiskScoreJob> channel;
|
||||
|
||||
public RiskScoreQueue(int? capacity = null)
|
||||
{
|
||||
if (capacity.HasValue)
|
||||
{
|
||||
var options = new BoundedChannelOptions(capacity.Value)
|
||||
{
|
||||
AllowSynchronousContinuations = false,
|
||||
SingleReader = true,
|
||||
SingleWriter = false,
|
||||
FullMode = BoundedChannelFullMode.Wait
|
||||
};
|
||||
|
||||
channel = Channel.CreateBounded<RiskScoreJob>(options);
|
||||
}
|
||||
else
|
||||
{
|
||||
var options = new UnboundedChannelOptions
|
||||
{
|
||||
AllowSynchronousContinuations = false,
|
||||
SingleReader = true,
|
||||
SingleWriter = false
|
||||
};
|
||||
|
||||
channel = Channel.CreateUnbounded<RiskScoreJob>(options);
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask EnqueueAsync(ScoreRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
var job = new RiskScoreJob(Guid.NewGuid(), request);
|
||||
return channel.Writer.WriteAsync(job, cancellationToken);
|
||||
}
|
||||
|
||||
public ValueTask<RiskScoreJob> DequeueAsync(CancellationToken cancellationToken) =>
|
||||
channel.Reader.ReadAsync(cancellationToken);
|
||||
|
||||
public bool TryDequeue([NotNullWhen(true)] out RiskScoreJob? job) => channel.Reader.TryRead(out job);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using StellaOps.RiskEngine.Core.Contracts;
|
||||
using StellaOps.RiskEngine.Core.Providers;
|
||||
|
||||
namespace StellaOps.RiskEngine.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Single-reader worker that pulls jobs from the queue and executes providers deterministically.
|
||||
/// </summary>
|
||||
public sealed class RiskScoreWorker
|
||||
{
|
||||
private readonly RiskScoreQueue queue;
|
||||
private readonly IRiskScoreProviderRegistry registry;
|
||||
private readonly IRiskScoreResultStore? resultStore;
|
||||
private readonly TimeProvider timeProvider;
|
||||
|
||||
public RiskScoreWorker(
|
||||
RiskScoreQueue queue,
|
||||
IRiskScoreProviderRegistry registry,
|
||||
IRiskScoreResultStore? resultStore = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
this.queue = queue ?? throw new ArgumentNullException(nameof(queue));
|
||||
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
this.resultStore = resultStore;
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<RiskScoreResult> ProcessNextAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await queue.DequeueAsync(cancellationToken).ConfigureAwait(false);
|
||||
var request = job.Request;
|
||||
|
||||
RiskScoreResult Build(double score, bool success, string? error) =>
|
||||
new(
|
||||
job.JobId,
|
||||
request.Provider,
|
||||
request.Subject,
|
||||
Score: score,
|
||||
Success: success,
|
||||
Error: error,
|
||||
request.Signals,
|
||||
CompletedAtUtc: timeProvider.GetUtcNow());
|
||||
|
||||
if (!registry.TryGet(request.Provider, out var provider))
|
||||
{
|
||||
var missing = Build(0d, false, "Provider not registered");
|
||||
await PersistAsync(missing, cancellationToken).ConfigureAwait(false);
|
||||
return missing;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var score = await provider.ScoreAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var success = Build(score, true, null);
|
||||
await PersistAsync(success, cancellationToken).ConfigureAwait(false);
|
||||
return success;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var failure = Build(0d, false, ex.Message);
|
||||
await PersistAsync(failure, cancellationToken).ConfigureAwait(false);
|
||||
return failure;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RiskScoreResult>> ProcessBatchAsync(int expectedCount, CancellationToken cancellationToken)
|
||||
{
|
||||
if (expectedCount < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(expectedCount));
|
||||
}
|
||||
|
||||
var results = new List<RiskScoreResult>(expectedCount);
|
||||
for (var i = 0; i < expectedCount; i++)
|
||||
{
|
||||
results.Add(await ProcessNextAsync(cancellationToken).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private Task PersistAsync(RiskScoreResult result, CancellationToken cancellationToken) =>
|
||||
resultStore is null
|
||||
? Task.CompletedTask
|
||||
: resultStore.SaveAsync(result, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.RiskEngine.Core.Contracts;
|
||||
using StellaOps.RiskEngine.Core.Services;
|
||||
|
||||
namespace StellaOps.RiskEngine.Infrastructure.Stores;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic in-memory store for risk score results.
|
||||
/// Used for offline/ephemeral runs and testing until ledger integration lands.
|
||||
/// </summary>
|
||||
public sealed class InMemoryRiskScoreResultStore : IRiskScoreResultStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, RiskScoreResult> results = new();
|
||||
private readonly ConcurrentQueue<Guid> order = new();
|
||||
|
||||
public Task SaveAsync(RiskScoreResult result, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (results.TryAdd(result.JobId, result))
|
||||
{
|
||||
order.Enqueue(result.JobId);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public IReadOnlyList<RiskScoreResult> Snapshot() =>
|
||||
order.Select(id => results[id]).ToArray();
|
||||
|
||||
public bool TryGet(Guid jobId, out RiskScoreResult result) =>
|
||||
results.TryGetValue(jobId, out result!);
|
||||
}
|
||||
@@ -1,10 +1,298 @@
|
||||
namespace StellaOps.RiskEngine.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
using StellaOps.RiskEngine.Core.Contracts;
|
||||
using StellaOps.RiskEngine.Core.Providers;
|
||||
using StellaOps.RiskEngine.Core.Services;
|
||||
using StellaOps.RiskEngine.Infrastructure.Stores;
|
||||
|
||||
namespace StellaOps.RiskEngine.Tests;
|
||||
|
||||
public class RiskScoreWorkerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ProcessesJobsInFifoOrder()
|
||||
{
|
||||
var provider = new DeterministicProvider("default", 1.0);
|
||||
var registry = new RiskScoreProviderRegistry(new[] { provider });
|
||||
var queue = new RiskScoreQueue();
|
||||
var worker = new RiskScoreWorker(queue, registry);
|
||||
|
||||
var first = new ScoreRequest("default", "asset-1", new Dictionary<string, double> { ["a"] = 2 });
|
||||
var second = new ScoreRequest("default", "asset-2", new Dictionary<string, double> { ["b"] = 3 });
|
||||
|
||||
await queue.EnqueueAsync(first, CancellationToken.None);
|
||||
await queue.EnqueueAsync(second, CancellationToken.None);
|
||||
|
||||
var results = await worker.ProcessBatchAsync(2, CancellationToken.None);
|
||||
|
||||
Assert.Collection(
|
||||
results,
|
||||
r =>
|
||||
{
|
||||
Assert.Equal(first.Subject, r.Subject);
|
||||
Assert.True(r.Success);
|
||||
Assert.Equal(2, r.Score);
|
||||
},
|
||||
r =>
|
||||
{
|
||||
Assert.Equal(second.Subject, r.Subject);
|
||||
Assert.True(r.Success);
|
||||
Assert.Equal(3, r.Score);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingProviderYieldsFailure()
|
||||
{
|
||||
var registry = new RiskScoreProviderRegistry(Array.Empty<IRiskScoreProvider>());
|
||||
var queue = new RiskScoreQueue();
|
||||
var store = new InMemoryRiskScoreResultStore();
|
||||
var worker = new RiskScoreWorker(queue, registry, store);
|
||||
|
||||
await queue.EnqueueAsync(
|
||||
new ScoreRequest("absent", "asset", new Dictionary<string, double>()),
|
||||
CancellationToken.None);
|
||||
|
||||
var result = await worker.ProcessNextAsync(CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("absent", result.Provider);
|
||||
Assert.NotNull(result.Error);
|
||||
Assert.Equal(0d, result.Score);
|
||||
Assert.True(store.TryGet(result.JobId, out var stored));
|
||||
Assert.False(stored.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeterministicProviderReturnsStableScore()
|
||||
{
|
||||
var provider = new DeterministicProvider("default", weight: 2.0);
|
||||
var registry = new RiskScoreProviderRegistry(new[] { provider });
|
||||
var queue = new RiskScoreQueue();
|
||||
var worker = new RiskScoreWorker(queue, registry);
|
||||
|
||||
var request = new ScoreRequest("default", "asset", new Dictionary<string, double> { ["x"] = 1.5, ["y"] = 0.5 });
|
||||
|
||||
await queue.EnqueueAsync(request, CancellationToken.None);
|
||||
await queue.EnqueueAsync(request, CancellationToken.None);
|
||||
|
||||
var results = await worker.ProcessBatchAsync(2, CancellationToken.None);
|
||||
|
||||
var expected = await provider.ScoreAsync(request, CancellationToken.None);
|
||||
Assert.All(results, r =>
|
||||
{
|
||||
Assert.True(r.Success);
|
||||
Assert.Equal(expected, r.Score);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DefaultProviderClampsAndAveragesSignals()
|
||||
{
|
||||
var provider = new DefaultTransformsProvider();
|
||||
var registry = new RiskScoreProviderRegistry(new[] { provider });
|
||||
var queue = new RiskScoreQueue();
|
||||
var worker = new RiskScoreWorker(queue, registry);
|
||||
|
||||
var request = new ScoreRequest(DefaultTransformsProvider.ProviderName, "asset", new Dictionary<string, double>
|
||||
{
|
||||
["low"] = -1,
|
||||
["mid"] = 0.25,
|
||||
["high"] = 2
|
||||
});
|
||||
|
||||
await queue.EnqueueAsync(request, CancellationToken.None);
|
||||
var result = await worker.ProcessNextAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
var expected = Math.Round((0 + 0.25 + 1) / 3, 6, MidpointRounding.ToEven);
|
||||
Assert.Equal(expected, result.Score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CvssKevProviderAddsKevBonus()
|
||||
{
|
||||
var cvssSource = new FakeCvssSource(new Dictionary<string, double>
|
||||
{
|
||||
["CVE-2025-0001"] = 9.8
|
||||
});
|
||||
var kevSource = new FakeKevSource(new Dictionary<string, bool>
|
||||
{
|
||||
["CVE-2025-0001"] = true
|
||||
});
|
||||
|
||||
var provider = new CvssKevProvider(cvssSource, kevSource);
|
||||
var registry = new RiskScoreProviderRegistry(new[] { provider });
|
||||
var queue = new RiskScoreQueue();
|
||||
var worker = new RiskScoreWorker(queue, registry);
|
||||
|
||||
var request = new ScoreRequest(CvssKevProvider.ProviderName, "CVE-2025-0001", new Dictionary<string, double>());
|
||||
await queue.EnqueueAsync(request, CancellationToken.None);
|
||||
|
||||
var result = await worker.ProcessNextAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(1.0d, result.Score); // 0.98 + 0.2 capped at 1.0
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CvssKevProviderHandlesMissingCvss()
|
||||
{
|
||||
var cvssSource = new FakeCvssSource(new Dictionary<string, double>());
|
||||
var kevSource = new FakeKevSource(new Dictionary<string, bool>());
|
||||
var provider = new CvssKevProvider(cvssSource, kevSource);
|
||||
var registry = new RiskScoreProviderRegistry(new[] { provider });
|
||||
var queue = new RiskScoreQueue();
|
||||
var worker = new RiskScoreWorker(queue, registry);
|
||||
|
||||
var request = new ScoreRequest(CvssKevProvider.ProviderName, "unknown", new Dictionary<string, double>());
|
||||
await queue.EnqueueAsync(request, CancellationToken.None);
|
||||
|
||||
var result = await worker.ProcessNextAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(0d, result.Score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VexGateProviderShortCircuitsOnDenial()
|
||||
{
|
||||
var provider = new VexGateProvider();
|
||||
var registry = new RiskScoreProviderRegistry(new[] { provider });
|
||||
var queue = new RiskScoreQueue();
|
||||
var worker = new RiskScoreWorker(queue, registry);
|
||||
|
||||
var request = new ScoreRequest(VexGateProvider.ProviderName, "asset", new Dictionary<string, double>
|
||||
{
|
||||
["HasDenial"] = 1,
|
||||
["Other"] = 0.9
|
||||
});
|
||||
|
||||
await queue.EnqueueAsync(request, CancellationToken.None);
|
||||
var result = await worker.ProcessNextAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(0d, result.Score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VexGateProviderUsesMaxSignalWhenNoDenial()
|
||||
{
|
||||
var provider = new VexGateProvider();
|
||||
var registry = new RiskScoreProviderRegistry(new[] { provider });
|
||||
var queue = new RiskScoreQueue();
|
||||
var worker = new RiskScoreWorker(queue, registry);
|
||||
|
||||
var request = new ScoreRequest(VexGateProvider.ProviderName, "asset", new Dictionary<string, double>
|
||||
{
|
||||
["HasDenial"] = 0,
|
||||
["s1"] = 0.4,
|
||||
["s2"] = 0.8
|
||||
});
|
||||
|
||||
await queue.EnqueueAsync(request, CancellationToken.None);
|
||||
var result = await worker.ProcessNextAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(0.8d, result.Score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FixExposureProviderAppliesWeights()
|
||||
{
|
||||
var provider = new FixExposureProvider();
|
||||
var registry = new RiskScoreProviderRegistry(new[] { provider });
|
||||
var queue = new RiskScoreQueue();
|
||||
var worker = new RiskScoreWorker(queue, registry);
|
||||
|
||||
var request = new ScoreRequest(FixExposureProvider.ProviderName, "asset", new Dictionary<string, double>
|
||||
{
|
||||
["FixAvailability"] = 0.6,
|
||||
["Criticality"] = 0.8,
|
||||
["Exposure"] = 0.25
|
||||
});
|
||||
|
||||
await queue.EnqueueAsync(request, CancellationToken.None);
|
||||
var result = await worker.ProcessNextAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
var expected = Math.Round((0.5 * 0.6) + (0.3 * 0.8) + (0.2 * 0.25), 6, MidpointRounding.ToEven);
|
||||
Assert.Equal(expected, result.Score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FixExposureProviderDefaultsMissingSignalsToZero()
|
||||
{
|
||||
var provider = new FixExposureProvider();
|
||||
var registry = new RiskScoreProviderRegistry(new[] { provider });
|
||||
var queue = new RiskScoreQueue();
|
||||
var worker = new RiskScoreWorker(queue, registry);
|
||||
|
||||
var request = new ScoreRequest(FixExposureProvider.ProviderName, "asset", new Dictionary<string, double>
|
||||
{
|
||||
["FixAvailability"] = 1.0
|
||||
});
|
||||
|
||||
await queue.EnqueueAsync(request, CancellationToken.None);
|
||||
var result = await worker.ProcessNextAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
// Only fix=1; criticality/exposure default to 0 → 0.5 * 1.0
|
||||
Assert.Equal(0.5d, result.Score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResultsPersistedToStore()
|
||||
{
|
||||
var provider = new DeterministicProvider("default", 1.0);
|
||||
var registry = new RiskScoreProviderRegistry(new[] { provider });
|
||||
var queue = new RiskScoreQueue();
|
||||
var store = new InMemoryRiskScoreResultStore();
|
||||
var worker = new RiskScoreWorker(queue, registry, store);
|
||||
|
||||
var first = new ScoreRequest("default", "a1", new Dictionary<string, double> { ["s1"] = 1 });
|
||||
var second = new ScoreRequest("default", "a2", new Dictionary<string, double> { ["s2"] = 2 });
|
||||
|
||||
await queue.EnqueueAsync(first, CancellationToken.None);
|
||||
await queue.EnqueueAsync(second, CancellationToken.None);
|
||||
|
||||
var results = await worker.ProcessBatchAsync(2, CancellationToken.None);
|
||||
|
||||
var snapshot = store.Snapshot();
|
||||
Assert.Equal(results.Select(r => r.JobId), snapshot.Select(r => r.JobId));
|
||||
Assert.Equal(results.Select(r => r.Score), snapshot.Select(r => r.Score));
|
||||
}
|
||||
|
||||
private sealed class FakeCvssSource : ICvssSource
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, double> data;
|
||||
public FakeCvssSource(IReadOnlyDictionary<string, double> data) => this.data = data;
|
||||
public Task<double?> GetCvssAsync(string subject, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<double?>(data.TryGetValue(subject, out var value) ? value : null);
|
||||
}
|
||||
|
||||
private sealed class FakeKevSource : IKevSource
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, bool> data;
|
||||
public FakeKevSource(IReadOnlyDictionary<string, bool> data) => this.data = data;
|
||||
public Task<bool?> IsKevAsync(string subject, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<bool?>(data.TryGetValue(subject, out var value) ? value : null);
|
||||
}
|
||||
|
||||
private sealed class DeterministicProvider : IRiskScoreProvider
|
||||
{
|
||||
public DeterministicProvider(string name, double weight)
|
||||
{
|
||||
Name = name;
|
||||
this.weight = weight;
|
||||
}
|
||||
|
||||
private readonly double weight;
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public Task<double> ScoreAsync(ScoreRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var sum = request.Signals.Values.Sum();
|
||||
return Task.FromResult(Math.Round(sum * weight, 6, MidpointRounding.ToEven));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,82 @@
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
var summaries = new[]
|
||||
{
|
||||
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
|
||||
};
|
||||
|
||||
app.MapGet("/weatherforecast", () =>
|
||||
{
|
||||
var forecast = Enumerable.Range(1, 5).Select(index =>
|
||||
new WeatherForecast
|
||||
(
|
||||
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
|
||||
Random.Shared.Next(-20, 55),
|
||||
summaries[Random.Shared.Next(summaries.Length)]
|
||||
))
|
||||
.ToArray();
|
||||
return forecast;
|
||||
})
|
||||
.WithName("GetWeatherForecast");
|
||||
|
||||
app.Run();
|
||||
|
||||
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
|
||||
{
|
||||
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
||||
}
|
||||
using StellaOps.RiskEngine.Core.Contracts;
|
||||
using StellaOps.RiskEngine.Core.Providers;
|
||||
using StellaOps.RiskEngine.Core.Services;
|
||||
using StellaOps.RiskEngine.Infrastructure.Stores;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
builder.Services.AddSingleton<RiskScoreQueue>();
|
||||
builder.Services.AddSingleton<IRiskScoreResultStore, InMemoryRiskScoreResultStore>();
|
||||
builder.Services.AddSingleton<IRiskScoreProviderRegistry>(_ =>
|
||||
new RiskScoreProviderRegistry(new IRiskScoreProvider[]
|
||||
{
|
||||
new DefaultTransformsProvider(),
|
||||
new CvssKevProvider(new NullCvssSource(), new NullKevSource()),
|
||||
new VexGateProvider(),
|
||||
new FixExposureProvider()
|
||||
}));
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.MapGet("/risk-scores/providers", (IRiskScoreProviderRegistry registry) =>
|
||||
Results.Ok(new { providers = registry.ProviderNames.OrderBy(n => n, StringComparer.OrdinalIgnoreCase) }));
|
||||
|
||||
app.MapPost("/risk-scores/jobs", async (
|
||||
ScoreRequest request,
|
||||
RiskScoreQueue queue,
|
||||
IRiskScoreProviderRegistry registry,
|
||||
IRiskScoreResultStore store,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var job = new RiskScoreJob(Guid.NewGuid(), request);
|
||||
await queue.EnqueueAsync(job.Request, ct).ConfigureAwait(false);
|
||||
var worker = new RiskScoreWorker(queue, registry, store, timeProvider);
|
||||
var result = await worker.ProcessNextAsync(ct).ConfigureAwait(false);
|
||||
return Results.Accepted($"/risk-scores/jobs/{job.JobId}", new { jobId = job.JobId, result });
|
||||
});
|
||||
|
||||
app.MapGet("/risk-scores/jobs/{jobId:guid}", (Guid jobId, InMemoryRiskScoreResultStore store) =>
|
||||
store.TryGet(jobId, out var result)
|
||||
? Results.Ok(result)
|
||||
: Results.NotFound());
|
||||
|
||||
app.MapPost("/risk-scores/simulations", async (
|
||||
IReadOnlyCollection<ScoreRequest> requests,
|
||||
IRiskScoreProviderRegistry registry,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var results = new List<RiskScoreResult>(requests.Count);
|
||||
foreach (var req in requests)
|
||||
{
|
||||
if (!registry.TryGet(req.Provider, out var provider))
|
||||
{
|
||||
results.Add(new RiskScoreResult(Guid.NewGuid(), req.Provider, req.Subject, 0d, false, "Provider not registered", req.Signals, timeProvider.GetUtcNow()));
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var score = await provider.ScoreAsync(req, ct).ConfigureAwait(false);
|
||||
results.Add(new RiskScoreResult(Guid.NewGuid(), req.Provider, req.Subject, score, true, null, req.Signals, timeProvider.GetUtcNow()));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results.Add(new RiskScoreResult(Guid.NewGuid(), req.Provider, req.Subject, 0d, false, ex.Message, req.Signals, timeProvider.GetUtcNow()));
|
||||
}
|
||||
}
|
||||
|
||||
return Results.Ok(new { results });
|
||||
});
|
||||
|
||||
app.Run();
|
||||
|
||||
10
src/RiskEngine/StellaOps.RiskEngine/TASKS.md
Normal file
10
src/RiskEngine/StellaOps.RiskEngine/TASKS.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Risk Engine Tasks (Sprint 0129-0001-0001)
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| RISK-ENGINE-66-001 | DONE (2025-11-25) | Scoring queue + worker + provider registry scaffolded; deterministic tests added. |
|
||||
| RISK-ENGINE-66-002 | DONE (2025-11-25) | Default transforms provider added; queue/worker tests updated. |
|
||||
| RISK-ENGINE-67-001 | DONE (2025-11-25) | CVSS+KEV provider implemented with tests; clamped scoring formula shipped. |
|
||||
| RISK-ENGINE-67-002 | DONE (2025-11-25) | VEX gate provider added; short-circuits on denial flag. |
|
||||
| RISK-ENGINE-67-003 | DONE (2025-11-25) | Fix availability / criticality / exposure provider added with weighted scoring + missing-signal defaults tested. |
|
||||
| RISK-ENGINE-68-001 | DONE (2025-11-25) | Worker now persists results via result-store abstraction; in-memory store added with FIFO snapshot + failure capture. |
|
||||
Reference in New Issue
Block a user