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