up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-27 23:44:42 +02:00
parent ef6e4b2067
commit 3b96b2e3ea
298 changed files with 47516 additions and 1168 deletions

View File

@@ -0,0 +1,212 @@
using StellaOps.Policy.Engine.Domain;
using StellaOps.Policy.Engine.Violations;
namespace StellaOps.Policy.Engine.Services;
/// <summary>
/// API/SDK utilities for consumers to request policy decisions with source evidence summaries (POLICY-ENGINE-40-003).
/// Combines policy evaluation with severity fusion, conflict detection, and evidence summaries.
/// </summary>
internal sealed class PolicyDecisionService
{
private readonly ViolationEventService _eventService;
private readonly SeverityFusionService _fusionService;
private readonly ConflictHandlingService _conflictService;
private readonly EvidenceSummaryService _evidenceService;
public PolicyDecisionService(
ViolationEventService eventService,
SeverityFusionService fusionService,
ConflictHandlingService conflictService,
EvidenceSummaryService evidenceService)
{
_eventService = eventService ?? throw new ArgumentNullException(nameof(eventService));
_fusionService = fusionService ?? throw new ArgumentNullException(nameof(fusionService));
_conflictService = conflictService ?? throw new ArgumentNullException(nameof(conflictService));
_evidenceService = evidenceService ?? throw new ArgumentNullException(nameof(evidenceService));
}
/// <summary>
/// Request policy decisions with source evidence summaries for a given snapshot.
/// </summary>
public async Task<PolicyDecisionResponse> GetDecisionsAsync(
PolicyDecisionRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
if (string.IsNullOrWhiteSpace(request.SnapshotId))
{
throw new ArgumentException("snapshot_id is required", nameof(request));
}
// 1. Emit violation events from snapshot
var eventRequest = new ViolationEventRequest(request.SnapshotId);
await _eventService.EmitAsync(eventRequest, cancellationToken).ConfigureAwait(false);
// 2. Get fused severities with sources
var fused = await _fusionService.FuseAsync(request.SnapshotId, cancellationToken).ConfigureAwait(false);
// 3. Compute conflicts
var conflicts = await _conflictService.ComputeAsync(request.SnapshotId, fused, cancellationToken).ConfigureAwait(false);
// 4. Build decision items with evidence summaries
var decisions = BuildDecisionItems(request, fused, conflicts);
// 5. Build summary statistics
var summary = BuildSummary(decisions, fused);
return new PolicyDecisionResponse(
SnapshotId: request.SnapshotId,
Decisions: decisions,
Summary: summary);
}
private IReadOnlyList<PolicyDecisionItem> BuildDecisionItems(
PolicyDecisionRequest request,
IReadOnlyList<SeverityFusionResult> fused,
IReadOnlyList<ConflictRecord> conflicts)
{
var conflictLookup = conflicts
.GroupBy(c => (c.ComponentPurl, c.AdvisoryId))
.ToDictionary(
g => g.Key,
g => g.Sum(c => c.Conflicts.Count));
var items = new List<PolicyDecisionItem>(fused.Count);
foreach (var fusion in fused)
{
// Apply filters if specified
if (!string.IsNullOrWhiteSpace(request.TenantId) &&
!string.Equals(fusion.TenantId, request.TenantId, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (!string.IsNullOrWhiteSpace(request.ComponentPurl) &&
!string.Equals(fusion.ComponentPurl, request.ComponentPurl, StringComparison.Ordinal))
{
continue;
}
if (!string.IsNullOrWhiteSpace(request.AdvisoryId) &&
!string.Equals(fusion.AdvisoryId, request.AdvisoryId, StringComparison.OrdinalIgnoreCase))
{
continue;
}
// Build top sources (limited by MaxSources)
var topSources = fusion.Sources
.OrderByDescending(s => s.Score)
.ThenByDescending(s => s.Weight)
.Take(request.MaxSources)
.Select((s, index) => new PolicyDecisionSource(
Source: s.Source,
Weight: s.Weight,
Severity: s.Severity,
Score: s.Score,
Rank: index + 1))
.ToList();
// Build evidence summary if requested
PolicyDecisionEvidence? evidence = null;
if (request.IncludeEvidence)
{
evidence = BuildEvidence(fusion);
}
// Get conflict count for this component/advisory pair
var conflictKey = (fusion.ComponentPurl, fusion.AdvisoryId);
var conflictCount = conflictLookup.GetValueOrDefault(conflictKey, 0);
// Derive status from severity
var status = DeriveStatus(fusion.SeverityFused);
items.Add(new PolicyDecisionItem(
TenantId: fusion.TenantId,
ComponentPurl: fusion.ComponentPurl,
AdvisoryId: fusion.AdvisoryId,
SeverityFused: fusion.SeverityFused,
Score: fusion.Score,
Status: status,
TopSources: topSources,
Evidence: evidence,
ConflictCount: conflictCount,
ReasonCodes: fusion.ReasonCodes));
}
// Return deterministically ordered results
return items
.OrderBy(i => i.ComponentPurl, StringComparer.Ordinal)
.ThenBy(i => i.AdvisoryId, StringComparer.Ordinal)
.ThenBy(i => i.TenantId, StringComparer.Ordinal)
.ToList();
}
private PolicyDecisionEvidence BuildEvidence(SeverityFusionResult fusion)
{
// Build a deterministic evidence hash from the fusion result
var evidenceHash = $"{fusion.ComponentPurl}|{fusion.AdvisoryId}|{fusion.SnapshotId}";
var evidenceRequest = new EvidenceSummaryRequest(
EvidenceHash: evidenceHash,
FilePath: fusion.ComponentPurl,
Digest: null,
IngestedAt: null,
ConnectorId: fusion.Sources.FirstOrDefault()?.Source);
var response = _evidenceService.Summarize(evidenceRequest);
return new PolicyDecisionEvidence(
Headline: response.Summary.Headline,
Severity: response.Summary.Severity,
Locator: new PolicyDecisionLocator(
FilePath: response.Summary.Locator.FilePath,
Digest: response.Summary.Locator.Digest),
Signals: response.Summary.Signals);
}
private static PolicyDecisionSummary BuildSummary(
IReadOnlyList<PolicyDecisionItem> decisions,
IReadOnlyList<SeverityFusionResult> fused)
{
// Count decisions by severity
var severityCounts = decisions
.GroupBy(d => d.SeverityFused, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g => g.Count(),
StringComparer.OrdinalIgnoreCase);
// Calculate total conflicts
var totalConflicts = decisions.Sum(d => d.ConflictCount);
// Aggregate source ranks across all fused results
var sourceStats = fused
.SelectMany(f => f.Sources)
.GroupBy(s => s.Source, StringComparer.OrdinalIgnoreCase)
.Select(g => new PolicyDecisionSourceRank(
Source: g.Key,
TotalWeight: g.Sum(s => s.Weight),
DecisionCount: g.Count(),
AverageScore: g.Average(s => s.Score)))
.OrderByDescending(r => r.TotalWeight)
.ThenByDescending(r => r.AverageScore)
.ToList();
return new PolicyDecisionSummary(
TotalDecisions: decisions.Count,
TotalConflicts: totalConflicts,
SeverityCounts: severityCounts,
TopSeveritySources: sourceStats);
}
private static string DeriveStatus(string severity) => severity.ToLowerInvariant() switch
{
"critical" => "violation",
"high" => "violation",
"medium" => "warn",
_ => "ok"
};
}

View File

@@ -198,10 +198,11 @@ public sealed class RiskProfileConfigurationService
var validation = _validator.Validate(json);
if (!validation.IsValid)
{
var errorMessages = validation.Errors?.Values ?? Enumerable.Empty<string>();
_logger.LogWarning(
"Risk profile file '{File}' failed validation: {Errors}",
file,
string.Join("; ", validation.Message ?? "Unknown error"));
string.Join("; ", errorMessages.Any() ? errorMessages : new[] { "Unknown error" }));
continue;
}
}