up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled

This commit is contained in:
master
2025-11-27 15:05:48 +02:00
parent 4831c7fcb0
commit e950474a77
278 changed files with 81498 additions and 672 deletions

View File

@@ -0,0 +1,106 @@
using System.Collections.Concurrent;
namespace StellaOps.Policy.Engine.Scoring;
/// <summary>
/// Store for risk scoring jobs.
/// </summary>
public interface IRiskScoringJobStore
{
Task SaveAsync(RiskScoringJob job, CancellationToken cancellationToken = default);
Task<RiskScoringJob?> GetAsync(string jobId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<RiskScoringJob>> ListByStatusAsync(RiskScoringJobStatus status, int limit = 100, CancellationToken cancellationToken = default);
Task<IReadOnlyList<RiskScoringJob>> ListByTenantAsync(string tenantId, int limit = 100, CancellationToken cancellationToken = default);
Task UpdateStatusAsync(string jobId, RiskScoringJobStatus status, string? errorMessage = null, CancellationToken cancellationToken = default);
Task<RiskScoringJob?> DequeueNextAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// In-memory implementation of risk scoring job store.
/// </summary>
public sealed class InMemoryRiskScoringJobStore : IRiskScoringJobStore
{
private readonly ConcurrentDictionary<string, RiskScoringJob> _jobs = new();
private readonly TimeProvider _timeProvider;
public InMemoryRiskScoringJobStore(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public Task SaveAsync(RiskScoringJob job, CancellationToken cancellationToken = default)
{
_jobs[job.JobId] = job;
return Task.CompletedTask;
}
public Task<RiskScoringJob?> GetAsync(string jobId, CancellationToken cancellationToken = default)
{
_jobs.TryGetValue(jobId, out var job);
return Task.FromResult(job);
}
public Task<IReadOnlyList<RiskScoringJob>> ListByStatusAsync(RiskScoringJobStatus status, int limit = 100, CancellationToken cancellationToken = default)
{
var jobs = _jobs.Values
.Where(j => j.Status == status)
.OrderBy(j => j.RequestedAt)
.Take(limit)
.ToList()
.AsReadOnly();
return Task.FromResult<IReadOnlyList<RiskScoringJob>>(jobs);
}
public Task<IReadOnlyList<RiskScoringJob>> ListByTenantAsync(string tenantId, int limit = 100, CancellationToken cancellationToken = default)
{
var jobs = _jobs.Values
.Where(j => j.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(j => j.RequestedAt)
.Take(limit)
.ToList()
.AsReadOnly();
return Task.FromResult<IReadOnlyList<RiskScoringJob>>(jobs);
}
public Task UpdateStatusAsync(string jobId, RiskScoringJobStatus status, string? errorMessage = null, CancellationToken cancellationToken = default)
{
if (_jobs.TryGetValue(jobId, out var job))
{
var now = _timeProvider.GetUtcNow();
var updated = job with
{
Status = status,
StartedAt = status == RiskScoringJobStatus.Running ? now : job.StartedAt,
CompletedAt = status is RiskScoringJobStatus.Completed or RiskScoringJobStatus.Failed or RiskScoringJobStatus.Cancelled ? now : job.CompletedAt,
ErrorMessage = errorMessage ?? job.ErrorMessage
};
_jobs[jobId] = updated;
}
return Task.CompletedTask;
}
public Task<RiskScoringJob?> DequeueNextAsync(CancellationToken cancellationToken = default)
{
var next = _jobs.Values
.Where(j => j.Status == RiskScoringJobStatus.Queued)
.OrderByDescending(j => j.Priority)
.ThenBy(j => j.RequestedAt)
.FirstOrDefault();
if (next != null)
{
var running = next with
{
Status = RiskScoringJobStatus.Running,
StartedAt = _timeProvider.GetUtcNow()
};
_jobs[next.JobId] = running;
return Task.FromResult<RiskScoringJob?>(running);
}
return Task.FromResult<RiskScoringJob?>(null);
}
}

View File

@@ -0,0 +1,131 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.Scoring;
/// <summary>
/// Event indicating a finding has been created or updated.
/// </summary>
public sealed record FindingChangedEvent(
[property: JsonPropertyName("finding_id")] string FindingId,
[property: JsonPropertyName("tenant_id")] string TenantId,
[property: JsonPropertyName("context_id")] string ContextId,
[property: JsonPropertyName("component_purl")] string ComponentPurl,
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
[property: JsonPropertyName("change_type")] FindingChangeType ChangeType,
[property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp,
[property: JsonPropertyName("correlation_id")] string? CorrelationId = null);
/// <summary>
/// Type of finding change.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<FindingChangeType>))]
public enum FindingChangeType
{
[JsonPropertyName("created")]
Created,
[JsonPropertyName("updated")]
Updated,
[JsonPropertyName("enriched")]
Enriched,
[JsonPropertyName("vex_applied")]
VexApplied
}
/// <summary>
/// Request to create a risk scoring job.
/// </summary>
public sealed record RiskScoringJobRequest(
[property: JsonPropertyName("tenant_id")] string TenantId,
[property: JsonPropertyName("context_id")] string ContextId,
[property: JsonPropertyName("profile_id")] string ProfileId,
[property: JsonPropertyName("findings")] IReadOnlyList<RiskScoringFinding> Findings,
[property: JsonPropertyName("priority")] RiskScoringPriority Priority = RiskScoringPriority.Normal,
[property: JsonPropertyName("correlation_id")] string? CorrelationId = null,
[property: JsonPropertyName("requested_at")] DateTimeOffset? RequestedAt = null);
/// <summary>
/// A finding to score.
/// </summary>
public sealed record RiskScoringFinding(
[property: JsonPropertyName("finding_id")] string FindingId,
[property: JsonPropertyName("component_purl")] string ComponentPurl,
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
[property: JsonPropertyName("trigger")] FindingChangeType Trigger);
/// <summary>
/// Priority for risk scoring jobs.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<RiskScoringPriority>))]
public enum RiskScoringPriority
{
[JsonPropertyName("low")]
Low,
[JsonPropertyName("normal")]
Normal,
[JsonPropertyName("high")]
High,
[JsonPropertyName("emergency")]
Emergency
}
/// <summary>
/// A queued or completed risk scoring job.
/// </summary>
public sealed record RiskScoringJob(
[property: JsonPropertyName("job_id")] string JobId,
[property: JsonPropertyName("tenant_id")] string TenantId,
[property: JsonPropertyName("context_id")] string ContextId,
[property: JsonPropertyName("profile_id")] string ProfileId,
[property: JsonPropertyName("profile_hash")] string ProfileHash,
[property: JsonPropertyName("findings")] IReadOnlyList<RiskScoringFinding> Findings,
[property: JsonPropertyName("priority")] RiskScoringPriority Priority,
[property: JsonPropertyName("status")] RiskScoringJobStatus Status,
[property: JsonPropertyName("requested_at")] DateTimeOffset RequestedAt,
[property: JsonPropertyName("started_at")] DateTimeOffset? StartedAt = null,
[property: JsonPropertyName("completed_at")] DateTimeOffset? CompletedAt = null,
[property: JsonPropertyName("correlation_id")] string? CorrelationId = null,
[property: JsonPropertyName("error_message")] string? ErrorMessage = null);
/// <summary>
/// Status of a risk scoring job.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<RiskScoringJobStatus>))]
public enum RiskScoringJobStatus
{
[JsonPropertyName("queued")]
Queued,
[JsonPropertyName("running")]
Running,
[JsonPropertyName("completed")]
Completed,
[JsonPropertyName("failed")]
Failed,
[JsonPropertyName("cancelled")]
Cancelled
}
/// <summary>
/// Result of scoring a single finding.
/// </summary>
public sealed record RiskScoringResult(
[property: JsonPropertyName("finding_id")] string FindingId,
[property: JsonPropertyName("profile_id")] string ProfileId,
[property: JsonPropertyName("profile_version")] string ProfileVersion,
[property: JsonPropertyName("raw_score")] double RawScore,
[property: JsonPropertyName("normalized_score")] double NormalizedScore,
[property: JsonPropertyName("severity")] string Severity,
[property: JsonPropertyName("signal_values")] IReadOnlyDictionary<string, object?> SignalValues,
[property: JsonPropertyName("signal_contributions")] IReadOnlyDictionary<string, double> SignalContributions,
[property: JsonPropertyName("override_applied")] string? OverrideApplied,
[property: JsonPropertyName("override_reason")] string? OverrideReason,
[property: JsonPropertyName("scored_at")] DateTimeOffset ScoredAt);

View File

@@ -0,0 +1,265 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Telemetry;
using StellaOps.Policy.RiskProfile.Hashing;
namespace StellaOps.Policy.Engine.Scoring;
/// <summary>
/// Service for triggering risk scoring jobs when findings change.
/// </summary>
public sealed class RiskScoringTriggerService
{
private readonly ILogger<RiskScoringTriggerService> _logger;
private readonly TimeProvider _timeProvider;
private readonly RiskProfileConfigurationService _profileService;
private readonly IRiskScoringJobStore _jobStore;
private readonly RiskProfileHasher _hasher;
private readonly ConcurrentDictionary<string, DateTimeOffset> _recentTriggers;
private readonly TimeSpan _deduplicationWindow;
public RiskScoringTriggerService(
ILogger<RiskScoringTriggerService> logger,
TimeProvider timeProvider,
RiskProfileConfigurationService profileService,
IRiskScoringJobStore jobStore)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_profileService = profileService ?? throw new ArgumentNullException(nameof(profileService));
_jobStore = jobStore ?? throw new ArgumentNullException(nameof(jobStore));
_hasher = new RiskProfileHasher();
_recentTriggers = new ConcurrentDictionary<string, DateTimeOffset>();
_deduplicationWindow = TimeSpan.FromMinutes(5);
}
/// <summary>
/// Handles a finding changed event and creates a scoring job if appropriate.
/// </summary>
/// <param name="evt">The finding changed event.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created job, or null if skipped.</returns>
public async Task<RiskScoringJob?> HandleFindingChangedAsync(
FindingChangedEvent evt,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(evt);
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity("risk_scoring.trigger");
activity?.SetTag("finding.id", evt.FindingId);
activity?.SetTag("change_type", evt.ChangeType.ToString());
if (!_profileService.IsEnabled)
{
_logger.LogDebug("Risk profile integration disabled; skipping scoring for {FindingId}", evt.FindingId);
return null;
}
var triggerKey = BuildTriggerKey(evt);
if (IsRecentlyTriggered(triggerKey))
{
_logger.LogDebug("Skipping duplicate trigger for {FindingId} within deduplication window", evt.FindingId);
PolicyEngineTelemetry.RiskScoringTriggersSkipped.Add(1);
return null;
}
var request = new RiskScoringJobRequest(
TenantId: evt.TenantId,
ContextId: evt.ContextId,
ProfileId: _profileService.DefaultProfileId,
Findings: new[]
{
new RiskScoringFinding(
evt.FindingId,
evt.ComponentPurl,
evt.AdvisoryId,
evt.ChangeType)
},
Priority: DeterminePriority(evt.ChangeType),
CorrelationId: evt.CorrelationId,
RequestedAt: evt.Timestamp);
var job = await CreateJobAsync(request, cancellationToken).ConfigureAwait(false);
RecordTrigger(triggerKey);
PolicyEngineTelemetry.RiskScoringJobsCreated.Add(1);
_logger.LogInformation(
"Created risk scoring job {JobId} for finding {FindingId} (trigger: {ChangeType})",
job.JobId, evt.FindingId, evt.ChangeType);
return job;
}
/// <summary>
/// Handles multiple finding changed events in batch.
/// </summary>
/// <param name="events">The finding changed events.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created job, or null if all events were skipped.</returns>
public async Task<RiskScoringJob?> HandleFindingsBatchAsync(
IReadOnlyList<FindingChangedEvent> events,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(events);
if (events.Count == 0)
{
return null;
}
if (!_profileService.IsEnabled)
{
_logger.LogDebug("Risk profile integration disabled; skipping batch scoring");
return null;
}
var uniqueEvents = events
.Where(e => !IsRecentlyTriggered(BuildTriggerKey(e)))
.GroupBy(e => e.FindingId)
.Select(g => g.OrderByDescending(e => e.Timestamp).First())
.ToList();
if (uniqueEvents.Count == 0)
{
_logger.LogDebug("All events in batch were duplicates; skipping");
return null;
}
var firstEvent = uniqueEvents[0];
var highestPriority = uniqueEvents.Select(e => DeterminePriority(e.ChangeType)).Max();
var request = new RiskScoringJobRequest(
TenantId: firstEvent.TenantId,
ContextId: firstEvent.ContextId,
ProfileId: _profileService.DefaultProfileId,
Findings: uniqueEvents.Select(e => new RiskScoringFinding(
e.FindingId,
e.ComponentPurl,
e.AdvisoryId,
e.ChangeType)).ToList(),
Priority: highestPriority,
CorrelationId: firstEvent.CorrelationId,
RequestedAt: _timeProvider.GetUtcNow());
var job = await CreateJobAsync(request, cancellationToken).ConfigureAwait(false);
foreach (var evt in uniqueEvents)
{
RecordTrigger(BuildTriggerKey(evt));
}
PolicyEngineTelemetry.RiskScoringJobsCreated.Add(1);
_logger.LogInformation(
"Created batch risk scoring job {JobId} for {FindingCount} findings",
job.JobId, uniqueEvents.Count);
return job;
}
/// <summary>
/// Creates a risk scoring job from a request.
/// </summary>
public async Task<RiskScoringJob> CreateJobAsync(
RiskScoringJobRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var profile = _profileService.GetProfile(request.ProfileId);
if (profile == null)
{
throw new InvalidOperationException($"Risk profile '{request.ProfileId}' not found.");
}
var profileHash = _hasher.ComputeHash(profile);
var requestedAt = request.RequestedAt ?? _timeProvider.GetUtcNow();
var jobId = GenerateJobId(request.TenantId, request.ContextId, requestedAt);
var job = new RiskScoringJob(
JobId: jobId,
TenantId: request.TenantId,
ContextId: request.ContextId,
ProfileId: request.ProfileId,
ProfileHash: profileHash,
Findings: request.Findings,
Priority: request.Priority,
Status: RiskScoringJobStatus.Queued,
RequestedAt: requestedAt,
CorrelationId: request.CorrelationId);
await _jobStore.SaveAsync(job, cancellationToken).ConfigureAwait(false);
return job;
}
/// <summary>
/// Gets the current queue depth.
/// </summary>
public async Task<int> GetQueueDepthAsync(CancellationToken cancellationToken = default)
{
var queued = await _jobStore.ListByStatusAsync(RiskScoringJobStatus.Queued, limit: 10000, cancellationToken).ConfigureAwait(false);
return queued.Count;
}
private static RiskScoringPriority DeterminePriority(FindingChangeType changeType)
{
return changeType switch
{
FindingChangeType.Created => RiskScoringPriority.High,
FindingChangeType.Enriched => RiskScoringPriority.High,
FindingChangeType.VexApplied => RiskScoringPriority.High,
FindingChangeType.Updated => RiskScoringPriority.Normal,
_ => RiskScoringPriority.Normal
};
}
private static string BuildTriggerKey(FindingChangedEvent evt)
{
return $"{evt.TenantId}|{evt.ContextId}|{evt.FindingId}|{evt.ChangeType}";
}
private bool IsRecentlyTriggered(string key)
{
if (_recentTriggers.TryGetValue(key, out var timestamp))
{
var elapsed = _timeProvider.GetUtcNow() - timestamp;
return elapsed < _deduplicationWindow;
}
return false;
}
private void RecordTrigger(string key)
{
var now = _timeProvider.GetUtcNow();
_recentTriggers[key] = now;
CleanupOldTriggers(now);
}
private void CleanupOldTriggers(DateTimeOffset now)
{
var threshold = now - _deduplicationWindow * 2;
var keysToRemove = _recentTriggers
.Where(kvp => kvp.Value < threshold)
.Select(kvp => kvp.Key)
.ToList();
foreach (var key in keysToRemove)
{
_recentTriggers.TryRemove(key, out _);
}
}
private static string GenerateJobId(string tenantId, string contextId, DateTimeOffset timestamp)
{
var seed = $"{tenantId}|{contextId}|{timestamp:O}|{Guid.NewGuid()}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
return $"rsj-{Convert.ToHexStringLower(hash)[..16]}";
}
}