work
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-25 08:01:23 +02:00
parent d92973d6fd
commit 6bee1fdcf5
207 changed files with 12816 additions and 2295 deletions

View File

@@ -1,6 +0,0 @@
namespace StellaOps.RiskEngine.Core;
public class Class1
{
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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
};
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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));
}
}

View File

@@ -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 sideeffect free for identical inputs.
/// </summary>
public interface IRiskScoreResultStore
{
Task SaveAsync(RiskScoreResult result, CancellationToken cancellationToken);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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!);
}

View File

@@ -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));
}
}
}

View File

@@ -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();

View 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. |