up
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
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-28 09:40:40 +02:00
parent 1c6730a1d2
commit 05da719048
206 changed files with 34741 additions and 1751 deletions

View File

@@ -1,12 +1,12 @@
using System.Collections.Generic;
using System.Net;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Linq;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Authentication;
using System.Collections.Generic;
using System.Net;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Linq;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
@@ -54,11 +54,11 @@ public sealed class ConsoleEndpointsTests
Assert.Equal(1, tenants.GetArrayLength());
Assert.Equal("tenant-default", tenants[0].GetProperty("id").GetString());
var events = sink.Events;
var authorizeEvent = Assert.Single(events, evt => evt.EventType == "authority.resource.authorize");
Assert.Equal(AuthEventOutcome.Success, authorizeEvent.Outcome);
var consoleEvent = Assert.Single(events, evt => evt.EventType == "authority.console.tenants.read");
var events = sink.Events;
var authorizeEvent = Assert.Single(events, evt => evt.EventType == "authority.resource.authorize");
Assert.Equal(AuthEventOutcome.Success, authorizeEvent.Outcome);
var consoleEvent = Assert.Single(events, evt => evt.EventType == "authority.console.tenants.read");
Assert.Equal(AuthEventOutcome.Success, consoleEvent.Outcome);
Assert.Contains("tenant.resolved", consoleEvent.Properties.Select(property => property.Name));
Assert.Equal(2, events.Count);
@@ -148,17 +148,17 @@ public sealed class ConsoleEndpointsTests
Assert.Equal("tenant-default", json.RootElement.GetProperty("tenant").GetString());
var events = sink.Events;
var authorizeEvent = Assert.Single(events, evt => evt.EventType == "authority.resource.authorize");
var authorizeEvent = Assert.Single(events, evt => evt.EventType == "authority.resource.authorize");
Assert.Equal(AuthEventOutcome.Success, authorizeEvent.Outcome);
var consoleEvent = Assert.Single(events, evt => evt.EventType == "authority.console.profile.read");
var consoleEvent = Assert.Single(events, evt => evt.EventType == "authority.console.profile.read");
Assert.Equal(AuthEventOutcome.Success, consoleEvent.Outcome);
Assert.Equal(2, events.Count);
}
[Fact]
public async Task TokenIntrospect_FlagsInactive_WhenExpired()
{
public async Task TokenIntrospect_FlagsInactive_WhenExpired()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-10-31T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
@@ -186,123 +186,340 @@ public sealed class ConsoleEndpointsTests
Assert.Equal("token-abc", json.RootElement.GetProperty("tokenId").GetString());
var events = sink.Events;
var authorizeEvent = Assert.Single(events, evt => evt.EventType == "authority.resource.authorize");
var authorizeEvent = Assert.Single(events, evt => evt.EventType == "authority.resource.authorize");
Assert.Equal(AuthEventOutcome.Success, authorizeEvent.Outcome);
var consoleEvent = Assert.Single(events, evt => evt.EventType == "authority.console.token.introspect");
var consoleEvent = Assert.Single(events, evt => evt.EventType == "authority.console.token.introspect");
Assert.Equal(AuthEventOutcome.Success, consoleEvent.Outcome);
Assert.Equal(2, events.Count);
}
[Fact]
public async Task VulnerabilityFindings_ReturnsSamplePayload()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.AdvisoryRead, StellaOpsScopes.VexRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var response = await client.GetAsync("/console/vuln/findings?severity=high");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var items = json.RootElement.GetProperty("items");
Assert.True(items.GetArrayLength() >= 1);
Assert.Equal("CVE-2024-12345", items[0].GetProperty("coordinates").GetProperty("advisoryId").GetString());
}
[Fact]
public async Task VulnerabilityFindingDetail_ReturnsExpandedDocument()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.AdvisoryRead, StellaOpsScopes.VexRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var response = await client.GetAsync("/console/vuln/tenant-default:advisory-ai:sha256:5d1a");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var summary = json.RootElement.GetProperty("summary");
Assert.Equal("tenant-default:advisory-ai:sha256:5d1a", summary.GetProperty("findingId").GetString());
Assert.Equal("reachable", summary.GetProperty("reachability").GetProperty("status").GetString());
var detailReachability = json.RootElement.GetProperty("reachability");
Assert.Equal("reachable", detailReachability.GetProperty("status").GetString());
}
[Fact]
public async Task VulnerabilityTicket_ReturnsDeterministicPayload()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.AdvisoryRead, StellaOpsScopes.VexRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var payload = new ConsoleVulnerabilityTicketRequest(
Selection: new[] { "tenant-default:advisory-ai:sha256:5d1a" },
TargetSystem: "servicenow",
Metadata: new Dictionary<string, string> { ["assignmentGroup"] = "runtime-security" });
var response = await client.PostAsJsonAsync("/console/vuln/tickets", payload);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.StartsWith("console-ticket::tenant-default::", json.RootElement.GetProperty("ticketId").GetString());
Assert.Equal("servicenow", payload.TargetSystem);
}
[Fact]
public async Task VexStatements_ReturnsSampleRows()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.VexRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var response = await client.GetAsync("/console/vex/statements?advisoryId=CVE-2024-12345");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var items = json.RootElement.GetProperty("items");
Assert.True(items.GetArrayLength() >= 1);
Assert.Equal("CVE-2024-12345", items[0].GetProperty("advisoryId").GetString());
}
Assert.Equal(2, events.Count);
}
[Fact]
public async Task VulnerabilityFindings_ReturnsSamplePayload()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.AdvisoryRead, StellaOpsScopes.VexRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var response = await client.GetAsync("/console/vuln/findings?severity=high");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var items = json.RootElement.GetProperty("items");
Assert.True(items.GetArrayLength() >= 1);
Assert.Equal("CVE-2024-12345", items[0].GetProperty("coordinates").GetProperty("advisoryId").GetString());
}
[Fact]
public async Task VulnerabilityFindingDetail_ReturnsExpandedDocument()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.AdvisoryRead, StellaOpsScopes.VexRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var response = await client.GetAsync("/console/vuln/tenant-default:advisory-ai:sha256:5d1a");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var summary = json.RootElement.GetProperty("summary");
Assert.Equal("tenant-default:advisory-ai:sha256:5d1a", summary.GetProperty("findingId").GetString());
Assert.Equal("reachable", summary.GetProperty("reachability").GetProperty("status").GetString());
var detailReachability = json.RootElement.GetProperty("reachability");
Assert.Equal("reachable", detailReachability.GetProperty("status").GetString());
}
[Fact]
public async Task VulnerabilityTicket_ReturnsDeterministicPayload()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.AdvisoryRead, StellaOpsScopes.VexRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var payload = new ConsoleVulnerabilityTicketRequest(
Selection: new[] { "tenant-default:advisory-ai:sha256:5d1a" },
TargetSystem: "servicenow",
Metadata: new Dictionary<string, string> { ["assignmentGroup"] = "runtime-security" });
var response = await client.PostAsJsonAsync("/console/vuln/tickets", payload);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.StartsWith("console-ticket::tenant-default::", json.RootElement.GetProperty("ticketId").GetString());
Assert.Equal("servicenow", payload.TargetSystem);
}
[Fact]
public async Task VexStatements_ReturnsSampleRows()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.VexRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var response = await client.GetAsync("/console/vex/statements?advisoryId=CVE-2024-12345");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var items = json.RootElement.GetProperty("items");
Assert.True(items.GetArrayLength() >= 1);
Assert.Equal("CVE-2024-12345", items[0].GetProperty("advisoryId").GetString());
}
[Fact]
public async Task Dashboard_ReturnsTenantScopedAggregates()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var response = await client.GetAsync("/console/dashboard");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.Equal("tenant-default", json.RootElement.GetProperty("tenant").GetString());
Assert.True(json.RootElement.TryGetProperty("generatedAt", out _));
Assert.True(json.RootElement.TryGetProperty("findings", out var findings));
Assert.True(findings.TryGetProperty("totalFindings", out _));
Assert.True(json.RootElement.TryGetProperty("vexOverrides", out _));
Assert.True(json.RootElement.TryGetProperty("advisoryDeltas", out _));
Assert.True(json.RootElement.TryGetProperty("runHealth", out _));
Assert.True(json.RootElement.TryGetProperty("policyChanges", out _));
var events = sink.Events;
var consoleEvent = Assert.Single(events, evt => evt.EventType == "authority.console.dashboard");
Assert.Equal(AuthEventOutcome.Success, consoleEvent.Outcome);
}
[Fact]
public async Task Dashboard_ReturnsBadRequest_WhenTenantHeaderMissing()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
var response = await client.GetAsync("/console/dashboard");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Dashboard_ContainsFindingsTrendData()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var response = await client.GetAsync("/console/dashboard");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var findings = json.RootElement.GetProperty("findings");
var trend = findings.GetProperty("trendLast30Days");
Assert.True(trend.GetArrayLength() > 0);
}
[Fact]
public async Task Filters_ReturnsFilterCategories()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var response = await client.GetAsync("/console/filters");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.Equal("tenant-default", json.RootElement.GetProperty("tenant").GetString());
Assert.True(json.RootElement.TryGetProperty("generatedAt", out _));
Assert.True(json.RootElement.TryGetProperty("filtersHash", out _));
var categories = json.RootElement.GetProperty("categories");
Assert.True(categories.GetArrayLength() >= 5);
var events = sink.Events;
var consoleEvent = Assert.Single(events, evt => evt.EventType == "authority.console.filters");
Assert.Equal(AuthEventOutcome.Success, consoleEvent.Outcome);
}
[Fact]
public async Task Filters_ReturnsExpectedCategoryIds()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var response = await client.GetAsync("/console/filters");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var categories = json.RootElement.GetProperty("categories");
var categoryIds = categories.EnumerateArray()
.Select(c => c.GetProperty("categoryId").GetString())
.ToList();
Assert.Contains("severity", categoryIds);
Assert.Contains("policyBadge", categoryIds);
Assert.Contains("reachability", categoryIds);
Assert.Contains("vexState", categoryIds);
Assert.Contains("kev", categoryIds);
}
[Fact]
public async Task Filters_FiltersByScopeParameter()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var response = await client.GetAsync("/console/filters?scope=severity");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var categories = json.RootElement.GetProperty("categories");
Assert.Equal(1, categories.GetArrayLength());
Assert.Equal("severity", categories[0].GetProperty("categoryId").GetString());
}
[Fact]
public async Task Filters_ReturnsBadRequest_WhenTenantHeaderMissing()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
var response = await client.GetAsync("/console/filters");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Filters_ReturnsHashForCacheValidation()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var response = await client.GetAsync("/console/filters");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var filtersHash = json.RootElement.GetProperty("filtersHash").GetString();
Assert.StartsWith("sha256:", filtersHash);
}
private static ClaimsPrincipal CreatePrincipal(
string tenant,
@@ -371,10 +588,10 @@ public sealed class ConsoleEndpointsTests
builder.Services.AddSingleton<TimeProvider>(timeProvider);
builder.Services.AddSingleton<IAuthEventSink>(sink);
builder.Services.AddSingleton<IAuthorityTenantCatalog>(new FakeTenantCatalog(tenants));
builder.Services.AddSingleton<TestPrincipalAccessor>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<StellaOpsBypassEvaluator>();
builder.Services.AddSingleton<IConsoleWorkspaceService, ConsoleWorkspaceSampleService>();
builder.Services.AddSingleton<TestPrincipalAccessor>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<StellaOpsBypassEvaluator>();
builder.Services.AddSingleton<IConsoleWorkspaceService, ConsoleWorkspaceSampleService>();
var authBuilder = builder.Services.AddAuthentication(options =>
{
@@ -400,7 +617,7 @@ public sealed class ConsoleEndpointsTests
app.UseAuthorization();
app.MapConsoleEndpoints();
await app.StartAsync();
await app.StartAsync();
return app;
}
@@ -434,11 +651,11 @@ public sealed class ConsoleEndpointsTests
private sealed class TestAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public TestAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
public TestAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
@@ -468,4 +685,4 @@ internal static class HostTestClientExtensions
internal static class TestAuthenticationDefaults
{
public const string AuthenticationScheme = "AuthorityConsoleTests";
}
}

View File

@@ -61,7 +61,7 @@ internal sealed class AuthoritySealedModeEvidenceValidator : IAuthoritySealedMod
}
var cacheKey = $"authority:sealed-mode:{sealedOptions.EvidencePath}";
if (memoryCache.TryGetValue(cacheKey, out AuthoritySealedModeValidationResult cached))
if (memoryCache.TryGetValue(cacheKey, out AuthoritySealedModeValidationResult? cached) && cached is not null)
{
return cached;
}

View File

@@ -37,41 +37,54 @@ internal static class ConsoleEndpointExtensions
.WithName("ConsoleProfile")
.WithSummary("Return the authenticated principal profile metadata.");
group.MapPost("/token/introspect", IntrospectToken)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.UiRead))
.WithName("ConsoleTokenIntrospect")
.WithSummary("Introspect the current access token and return expiry, scope, and tenant metadata.");
var vulnGroup = group.MapGroup("/vuln")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(
StellaOpsScopes.UiRead,
StellaOpsScopes.AdvisoryRead,
StellaOpsScopes.VexRead));
vulnGroup.MapGet("/findings", GetVulnerabilityFindings)
.WithName("ConsoleVulnerabilityFindings")
.WithSummary("List tenant-scoped vulnerability findings with policy/VEX metadata.");
vulnGroup.MapGet("/{findingId}", GetVulnerabilityFindingById)
.WithName("ConsoleVulnerabilityFindingDetail")
.WithSummary("Return the full finding document, including evidence and policy overlays.");
vulnGroup.MapPost("/tickets", CreateVulnerabilityTicket)
.WithName("ConsoleVulnerabilityTickets")
.WithSummary("Generate a signed payload payload for external ticketing workflows.");
var vexGroup = group.MapGroup("/vex")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(
StellaOpsScopes.UiRead,
StellaOpsScopes.VexRead));
vexGroup.MapGet("/statements", GetVexStatements)
.WithName("ConsoleVexStatements")
.WithSummary("List VEX statements impacting the tenant.");
vexGroup.MapGet("/events", StreamVexEvents)
.WithName("ConsoleVexEvents")
.WithSummary("Server-sent events feed for live VEX updates (placeholder).");
group.MapPost("/token/introspect", IntrospectToken)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.UiRead))
.WithName("ConsoleTokenIntrospect")
.WithSummary("Introspect the current access token and return expiry, scope, and tenant metadata.");
var vulnGroup = group.MapGroup("/vuln")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(
StellaOpsScopes.UiRead,
StellaOpsScopes.AdvisoryRead,
StellaOpsScopes.VexRead));
vulnGroup.MapGet("/findings", GetVulnerabilityFindings)
.WithName("ConsoleVulnerabilityFindings")
.WithSummary("List tenant-scoped vulnerability findings with policy/VEX metadata.");
vulnGroup.MapGet("/{findingId}", GetVulnerabilityFindingById)
.WithName("ConsoleVulnerabilityFindingDetail")
.WithSummary("Return the full finding document, including evidence and policy overlays.");
vulnGroup.MapPost("/tickets", CreateVulnerabilityTicket)
.WithName("ConsoleVulnerabilityTickets")
.WithSummary("Generate a signed payload payload for external ticketing workflows.");
var vexGroup = group.MapGroup("/vex")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(
StellaOpsScopes.UiRead,
StellaOpsScopes.VexRead));
vexGroup.MapGet("/statements", GetVexStatements)
.WithName("ConsoleVexStatements")
.WithSummary("List VEX statements impacting the tenant.");
vexGroup.MapGet("/events", StreamVexEvents)
.WithName("ConsoleVexEvents")
.WithSummary("Server-sent events feed for live VEX updates (placeholder).");
// Dashboard and filters endpoints (WEB-CONSOLE-23-001)
group.MapGet("/dashboard", GetDashboard)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(
StellaOpsScopes.UiRead))
.WithName("ConsoleDashboard")
.WithSummary("Tenant-scoped aggregates for findings, VEX overrides, advisory deltas, run health, and policy change log.");
group.MapGet("/filters", GetFilters)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(
StellaOpsScopes.UiRead))
.WithName("ConsoleFilters")
.WithSummary("Available filter categories with options and counts for deterministic console queries.");
}
private static async Task<IResult> GetTenants(
@@ -165,11 +178,11 @@ internal static class ConsoleEndpointExtensions
return Results.Ok(profile);
}
private static async Task<IResult> IntrospectToken(
HttpContext httpContext,
TimeProvider timeProvider,
IAuthEventSink auditSink,
CancellationToken cancellationToken)
private static async Task<IResult> IntrospectToken(
HttpContext httpContext,
TimeProvider timeProvider,
IAuthEventSink auditSink,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(timeProvider);
@@ -183,214 +196,311 @@ internal static class ConsoleEndpointExtensions
var introspection = BuildTokenIntrospection(principal, timeProvider);
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.token.introspect",
AuthEventOutcome.Success,
null,
BuildProperties(
("token.active", introspection.Active ? "true" : "false"),
("token.expires_at", FormatInstant(introspection.ExpiresAt)),
("tenant.resolved", introspection.Tenant)),
cancellationToken).ConfigureAwait(false);
return Results.Ok(introspection);
}
private static async Task<IResult> GetVulnerabilityFindings(
HttpContext httpContext,
IConsoleWorkspaceService workspaceService,
TimeProvider timeProvider,
IAuthEventSink auditSink,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(workspaceService);
var tenant = TenantHeaderFilter.GetTenant(httpContext);
if (string.IsNullOrWhiteSpace(tenant))
{
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vuln.findings",
AuthEventOutcome.Failure,
"tenant_header_missing",
BuildProperties(("tenant.header", null)),
cancellationToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
}
var query = BuildVulnerabilityQuery(httpContext.Request);
var response = await workspaceService.SearchFindingsAsync(tenant, query, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vuln.findings",
AuthEventOutcome.Success,
null,
BuildProperties(("tenant.resolved", tenant), ("pagination.next_token", response.NextPageToken)),
cancellationToken).ConfigureAwait(false);
return Results.Ok(response);
}
private static async Task<IResult> GetVulnerabilityFindingById(
HttpContext httpContext,
string findingId,
IConsoleWorkspaceService workspaceService,
TimeProvider timeProvider,
IAuthEventSink auditSink,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(workspaceService);
var tenant = TenantHeaderFilter.GetTenant(httpContext);
if (string.IsNullOrWhiteSpace(tenant))
{
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vuln.finding",
AuthEventOutcome.Failure,
"tenant_header_missing",
BuildProperties(("tenant.header", null)),
cancellationToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
}
var detail = await workspaceService.GetFindingAsync(tenant, findingId, cancellationToken).ConfigureAwait(false);
if (detail is null)
{
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vuln.finding",
AuthEventOutcome.Failure,
"finding_not_found",
BuildProperties(("tenant.resolved", tenant), ("finding.id", findingId)),
cancellationToken).ConfigureAwait(false);
return Results.NotFound(new { error = "finding_not_found", message = $"Finding '{findingId}' not found." });
}
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vuln.finding",
AuthEventOutcome.Success,
null,
BuildProperties(("tenant.resolved", tenant), ("finding.id", findingId)),
cancellationToken).ConfigureAwait(false);
return Results.Ok(detail);
}
private static async Task<IResult> CreateVulnerabilityTicket(
HttpContext httpContext,
ConsoleVulnerabilityTicketRequest request,
IConsoleWorkspaceService workspaceService,
TimeProvider timeProvider,
IAuthEventSink auditSink,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(workspaceService);
if (request is null || request.Selection.Count == 0)
{
return Results.BadRequest(new { error = "invalid_request", message = "At least one finding must be selected." });
}
var tenant = TenantHeaderFilter.GetTenant(httpContext);
if (string.IsNullOrWhiteSpace(tenant))
{
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vuln.ticket",
AuthEventOutcome.Failure,
"tenant_header_missing",
BuildProperties(("tenant.header", null)),
cancellationToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
}
var ticket = await workspaceService.CreateTicketAsync(tenant, request, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vuln.ticket",
AuthEventOutcome.Success,
null,
BuildProperties(
("tenant.resolved", tenant),
("ticket.id", ticket.TicketId),
("ticket.selection.count", request.Selection.Count.ToString(CultureInfo.InvariantCulture))),
cancellationToken).ConfigureAwait(false);
return Results.Ok(ticket);
}
private static async Task<IResult> GetVexStatements(
HttpContext httpContext,
IConsoleWorkspaceService workspaceService,
TimeProvider timeProvider,
IAuthEventSink auditSink,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(workspaceService);
var tenant = TenantHeaderFilter.GetTenant(httpContext);
if (string.IsNullOrWhiteSpace(tenant))
{
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vex.statements",
AuthEventOutcome.Failure,
"tenant_header_missing",
BuildProperties(("tenant.header", null)),
cancellationToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
}
var query = BuildVexQuery(httpContext.Request);
var response = await workspaceService.GetVexStatementsAsync(tenant, query, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vex.statements",
AuthEventOutcome.Success,
null,
BuildProperties(("tenant.resolved", tenant), ("pagination.next_token", response.NextPageToken)),
cancellationToken).ConfigureAwait(false);
return Results.Ok(response);
}
private static IResult StreamVexEvents() =>
Results.StatusCode(StatusCodes.Status501NotImplemented);
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.token.introspect",
AuthEventOutcome.Success,
null,
BuildProperties(
("token.active", introspection.Active ? "true" : "false"),
("token.expires_at", FormatInstant(introspection.ExpiresAt)),
("tenant.resolved", introspection.Tenant)),
cancellationToken).ConfigureAwait(false);
return Results.Ok(introspection);
}
private static async Task<IResult> GetVulnerabilityFindings(
HttpContext httpContext,
IConsoleWorkspaceService workspaceService,
TimeProvider timeProvider,
IAuthEventSink auditSink,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(workspaceService);
var tenant = TenantHeaderFilter.GetTenant(httpContext);
if (string.IsNullOrWhiteSpace(tenant))
{
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vuln.findings",
AuthEventOutcome.Failure,
"tenant_header_missing",
BuildProperties(("tenant.header", null)),
cancellationToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
}
var query = BuildVulnerabilityQuery(httpContext.Request);
var response = await workspaceService.SearchFindingsAsync(tenant, query, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vuln.findings",
AuthEventOutcome.Success,
null,
BuildProperties(("tenant.resolved", tenant), ("pagination.next_token", response.NextPageToken)),
cancellationToken).ConfigureAwait(false);
return Results.Ok(response);
}
private static async Task<IResult> GetVulnerabilityFindingById(
HttpContext httpContext,
string findingId,
IConsoleWorkspaceService workspaceService,
TimeProvider timeProvider,
IAuthEventSink auditSink,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(workspaceService);
var tenant = TenantHeaderFilter.GetTenant(httpContext);
if (string.IsNullOrWhiteSpace(tenant))
{
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vuln.finding",
AuthEventOutcome.Failure,
"tenant_header_missing",
BuildProperties(("tenant.header", null)),
cancellationToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
}
var detail = await workspaceService.GetFindingAsync(tenant, findingId, cancellationToken).ConfigureAwait(false);
if (detail is null)
{
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vuln.finding",
AuthEventOutcome.Failure,
"finding_not_found",
BuildProperties(("tenant.resolved", tenant), ("finding.id", findingId)),
cancellationToken).ConfigureAwait(false);
return Results.NotFound(new { error = "finding_not_found", message = $"Finding '{findingId}' not found." });
}
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vuln.finding",
AuthEventOutcome.Success,
null,
BuildProperties(("tenant.resolved", tenant), ("finding.id", findingId)),
cancellationToken).ConfigureAwait(false);
return Results.Ok(detail);
}
private static async Task<IResult> CreateVulnerabilityTicket(
HttpContext httpContext,
ConsoleVulnerabilityTicketRequest request,
IConsoleWorkspaceService workspaceService,
TimeProvider timeProvider,
IAuthEventSink auditSink,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(workspaceService);
if (request is null || request.Selection.Count == 0)
{
return Results.BadRequest(new { error = "invalid_request", message = "At least one finding must be selected." });
}
var tenant = TenantHeaderFilter.GetTenant(httpContext);
if (string.IsNullOrWhiteSpace(tenant))
{
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vuln.ticket",
AuthEventOutcome.Failure,
"tenant_header_missing",
BuildProperties(("tenant.header", null)),
cancellationToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
}
var ticket = await workspaceService.CreateTicketAsync(tenant, request, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vuln.ticket",
AuthEventOutcome.Success,
null,
BuildProperties(
("tenant.resolved", tenant),
("ticket.id", ticket.TicketId),
("ticket.selection.count", request.Selection.Count.ToString(CultureInfo.InvariantCulture))),
cancellationToken).ConfigureAwait(false);
return Results.Ok(ticket);
}
private static async Task<IResult> GetVexStatements(
HttpContext httpContext,
IConsoleWorkspaceService workspaceService,
TimeProvider timeProvider,
IAuthEventSink auditSink,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(workspaceService);
var tenant = TenantHeaderFilter.GetTenant(httpContext);
if (string.IsNullOrWhiteSpace(tenant))
{
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vex.statements",
AuthEventOutcome.Failure,
"tenant_header_missing",
BuildProperties(("tenant.header", null)),
cancellationToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
}
var query = BuildVexQuery(httpContext.Request);
var response = await workspaceService.GetVexStatementsAsync(tenant, query, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vex.statements",
AuthEventOutcome.Success,
null,
BuildProperties(("tenant.resolved", tenant), ("pagination.next_token", response.NextPageToken)),
cancellationToken).ConfigureAwait(false);
return Results.Ok(response);
}
private static IResult StreamVexEvents() =>
Results.StatusCode(StatusCodes.Status501NotImplemented);
private static async Task<IResult> GetDashboard(
HttpContext httpContext,
IConsoleWorkspaceService workspaceService,
TimeProvider timeProvider,
IAuthEventSink auditSink,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(workspaceService);
var tenant = TenantHeaderFilter.GetTenant(httpContext);
if (string.IsNullOrWhiteSpace(tenant))
{
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.dashboard",
AuthEventOutcome.Failure,
"tenant_header_missing",
BuildProperties(("tenant.header", null)),
cancellationToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
}
var dashboard = await workspaceService.GetDashboardAsync(tenant, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.dashboard",
AuthEventOutcome.Success,
null,
BuildProperties(
("tenant.resolved", tenant),
("dashboard.findings_count", dashboard.Findings.TotalFindings.ToString(CultureInfo.InvariantCulture))),
cancellationToken).ConfigureAwait(false);
return Results.Ok(dashboard);
}
private static async Task<IResult> GetFilters(
HttpContext httpContext,
IConsoleWorkspaceService workspaceService,
TimeProvider timeProvider,
IAuthEventSink auditSink,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(workspaceService);
var tenant = TenantHeaderFilter.GetTenant(httpContext);
if (string.IsNullOrWhiteSpace(tenant))
{
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.filters",
AuthEventOutcome.Failure,
"tenant_header_missing",
BuildProperties(("tenant.header", null)),
cancellationToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
}
var query = BuildFiltersQuery(httpContext.Request);
var filters = await workspaceService.GetFiltersAsync(tenant, query, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.filters",
AuthEventOutcome.Success,
null,
BuildProperties(
("tenant.resolved", tenant),
("filters.hash", filters.FiltersHash),
("filters.categories_count", filters.Categories.Count.ToString(CultureInfo.InvariantCulture))),
cancellationToken).ConfigureAwait(false);
return Results.Ok(filters);
}
private static ConsoleFiltersQuery BuildFiltersQuery(HttpRequest request)
{
var scope = request.Query.TryGetValue("scope", out var scopeValues) ? scopeValues.FirstOrDefault() : null;
var includeEmpty = request.Query.TryGetValue("includeEmpty", out var includeValues) &&
bool.TryParse(includeValues.FirstOrDefault(), out var include) && include;
return new ConsoleFiltersQuery(scope, includeEmpty);
}
private static ConsoleProfileResponse BuildProfile(ClaimsPrincipal principal, TimeProvider timeProvider)
{
@@ -455,9 +565,9 @@ internal static class ConsoleEndpointExtensions
FreshAuth: freshAuth);
}
private static bool DetermineFreshAuth(ClaimsPrincipal principal, DateTimeOffset now)
{
var flag = principal.FindFirst("stellaops:fresh_auth") ?? principal.FindFirst("fresh_auth");
private static bool DetermineFreshAuth(ClaimsPrincipal principal, DateTimeOffset now)
{
var flag = principal.FindFirst("stellaops:fresh_auth") ?? principal.FindFirst("fresh_auth");
if (flag is not null && bool.TryParse(flag.Value, out var freshFlag))
{
if (freshFlag)
@@ -478,67 +588,67 @@ internal static class ConsoleEndpointExtensions
return authTime.Value.Add(ttl) > now;
}
const int defaultFreshAuthWindowSeconds = 300;
return authTime.Value.AddSeconds(defaultFreshAuthWindowSeconds) > now;
}
private static ConsoleVulnerabilityQuery BuildVulnerabilityQuery(HttpRequest request)
{
var builder = new ConsoleVulnerabilityQueryBuilder()
.SetPageSize(ParseInt(request.Query["pageSize"], 50))
.SetPageToken(request.Query.TryGetValue("pageToken", out var tokenValues) ? tokenValues.FirstOrDefault() : null)
.AddSeverity(ReadMulti(request, "severity"))
.AddPolicyBadges(ReadMulti(request, "policyBadge"))
.AddReachability(ReadMulti(request, "reachability"))
.AddProducts(ReadMulti(request, "product"))
.AddVexStates(ReadMulti(request, "vexState"));
var search = request.Query.TryGetValue("search", out var searchValues)
? searchValues
.Where(value => !string.IsNullOrWhiteSpace(value))
.SelectMany(value => value!.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
: Array.Empty<string>();
builder.AddSearchTerms(search);
return builder.Build();
}
private static ConsoleVexQuery BuildVexQuery(HttpRequest request)
{
var builder = new ConsoleVexQueryBuilder()
.SetPageSize(ParseInt(request.Query["pageSize"], 50))
.SetPageToken(request.Query.TryGetValue("pageToken", out var pageValues) ? pageValues.FirstOrDefault() : null)
.AddAdvisories(ReadMulti(request, "advisoryId"))
.AddTypes(ReadMulti(request, "statementType"))
.AddStates(ReadMulti(request, "state"));
return builder.Build();
}
private static IEnumerable<string> ReadMulti(HttpRequest request, string key)
{
if (!request.Query.TryGetValue(key, out var values))
{
return Array.Empty<string>();
}
return values
.Where(value => !string.IsNullOrWhiteSpace(value))
.SelectMany(value => value!.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Where(value => value.Length > 0);
}
private static int ParseInt(StringValues values, int fallback)
{
if (values.Count == 0)
{
return fallback;
}
return int.TryParse(values[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var number)
? number
: fallback;
}
const int defaultFreshAuthWindowSeconds = 300;
return authTime.Value.AddSeconds(defaultFreshAuthWindowSeconds) > now;
}
private static ConsoleVulnerabilityQuery BuildVulnerabilityQuery(HttpRequest request)
{
var builder = new ConsoleVulnerabilityQueryBuilder()
.SetPageSize(ParseInt(request.Query["pageSize"], 50))
.SetPageToken(request.Query.TryGetValue("pageToken", out var tokenValues) ? tokenValues.FirstOrDefault() : null)
.AddSeverity(ReadMulti(request, "severity"))
.AddPolicyBadges(ReadMulti(request, "policyBadge"))
.AddReachability(ReadMulti(request, "reachability"))
.AddProducts(ReadMulti(request, "product"))
.AddVexStates(ReadMulti(request, "vexState"));
var search = request.Query.TryGetValue("search", out var searchValues)
? searchValues
.Where(value => !string.IsNullOrWhiteSpace(value))
.SelectMany(value => value!.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
: Array.Empty<string>();
builder.AddSearchTerms(search);
return builder.Build();
}
private static ConsoleVexQuery BuildVexQuery(HttpRequest request)
{
var builder = new ConsoleVexQueryBuilder()
.SetPageSize(ParseInt(request.Query["pageSize"], 50))
.SetPageToken(request.Query.TryGetValue("pageToken", out var pageValues) ? pageValues.FirstOrDefault() : null)
.AddAdvisories(ReadMulti(request, "advisoryId"))
.AddTypes(ReadMulti(request, "statementType"))
.AddStates(ReadMulti(request, "state"));
return builder.Build();
}
private static IEnumerable<string> ReadMulti(HttpRequest request, string key)
{
if (!request.Query.TryGetValue(key, out var values))
{
return Array.Empty<string>();
}
return values
.Where(value => !string.IsNullOrWhiteSpace(value))
.SelectMany(value => value!.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Where(value => value.Length > 0);
}
private static int ParseInt(StringValues values, int fallback)
{
if (values.Count == 0)
{
return fallback;
}
return int.TryParse(values[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var number)
? number
: fallback;
}
private static IReadOnlyList<string> ExtractRoles(ClaimsPrincipal principal)
{

View File

@@ -183,6 +183,22 @@ internal interface IConsoleWorkspaceService
string tenant,
ConsoleVexQuery query,
CancellationToken cancellationToken);
/// <summary>
/// Get tenant-scoped dashboard aggregates including findings, VEX overrides,
/// advisory deltas, run health, and policy change log.
/// </summary>
Task<ConsoleDashboardResponse> GetDashboardAsync(
string tenant,
CancellationToken cancellationToken);
/// <summary>
/// Get available filter categories with options and counts for console queries.
/// </summary>
Task<ConsoleFiltersResponse> GetFiltersAsync(
string tenant,
ConsoleFiltersQuery query,
CancellationToken cancellationToken);
}
internal sealed class ConsoleVulnerabilityQueryBuilder
@@ -302,3 +318,167 @@ internal sealed class ConsoleVexQueryBuilder
_pageSize,
_pageToken);
}
// ============================================================================
// Dashboard Models (WEB-CONSOLE-23-001)
// ============================================================================
/// <summary>
/// Dashboard response containing tenant-scoped aggregates for findings, VEX overrides,
/// advisory deltas, run health, and policy change log.
/// </summary>
internal sealed record ConsoleDashboardResponse(
string Tenant,
DateTimeOffset GeneratedAt,
ConsoleDashboardFindingsSummary Findings,
ConsoleDashboardVexSummary VexOverrides,
ConsoleDashboardAdvisorySummary AdvisoryDeltas,
ConsoleDashboardRunHealth RunHealth,
ConsoleDashboardPolicyChangeLog PolicyChanges);
/// <summary>
/// Aggregated findings summary for dashboard.
/// </summary>
internal sealed record ConsoleDashboardFindingsSummary(
int TotalFindings,
int CriticalCount,
int HighCount,
int MediumCount,
int LowCount,
int InformationalCount,
int NewLastDay,
int NewLastWeek,
int ResolvedLastWeek,
IReadOnlyList<ConsoleDashboardTrendPoint> TrendLast30Days);
/// <summary>
/// A single trend data point.
/// </summary>
internal sealed record ConsoleDashboardTrendPoint(
DateTimeOffset Date,
int Open,
int Resolved,
int New);
/// <summary>
/// VEX overrides summary for dashboard.
/// </summary>
internal sealed record ConsoleDashboardVexSummary(
int TotalStatements,
int NotAffectedCount,
int FixedCount,
int UnderInvestigationCount,
int AffectedCount,
int AutomatedCount,
int ManualCount,
DateTimeOffset? LastStatementUpdated);
/// <summary>
/// Advisory delta summary for dashboard.
/// </summary>
internal sealed record ConsoleDashboardAdvisorySummary(
int TotalAdvisories,
int NewLastDay,
int NewLastWeek,
int UpdatedLastWeek,
int KevCount,
IReadOnlyList<ConsoleDashboardAdvisoryItem> RecentAdvisories);
/// <summary>
/// A recent advisory item for dashboard display.
/// </summary>
internal sealed record ConsoleDashboardAdvisoryItem(
string AdvisoryId,
string Severity,
string Summary,
bool Kev,
int AffectedFindings,
DateTimeOffset PublishedAt);
/// <summary>
/// Run health summary for dashboard.
/// </summary>
internal sealed record ConsoleDashboardRunHealth(
int TotalRuns,
int SuccessfulRuns,
int FailedRuns,
int RunningRuns,
int PendingRuns,
double SuccessRatePercent,
TimeSpan? AverageRunDuration,
DateTimeOffset? LastRunCompletedAt,
IReadOnlyList<ConsoleDashboardRecentRun> RecentRuns);
/// <summary>
/// A recent run item for dashboard display.
/// </summary>
internal sealed record ConsoleDashboardRecentRun(
string RunId,
string RunType,
string Status,
DateTimeOffset StartedAt,
DateTimeOffset? CompletedAt,
TimeSpan? Duration,
int FindingsProcessed);
/// <summary>
/// Policy change log summary for dashboard.
/// </summary>
internal sealed record ConsoleDashboardPolicyChangeLog(
int TotalPolicies,
int ActivePolicies,
int ChangesLastWeek,
DateTimeOffset? LastPolicyUpdated,
IReadOnlyList<ConsoleDashboardPolicyChange> RecentChanges);
/// <summary>
/// A recent policy change for dashboard display.
/// </summary>
internal sealed record ConsoleDashboardPolicyChange(
string PolicyId,
string PolicyName,
string ChangeType,
string ChangedBy,
DateTimeOffset ChangedAt,
string? Description);
// ============================================================================
// Filters Models (WEB-CONSOLE-23-001)
// ============================================================================
/// <summary>
/// Available filters for console queries with counts and deterministic ordering.
/// </summary>
internal sealed record ConsoleFiltersResponse(
string Tenant,
DateTimeOffset GeneratedAt,
string FiltersHash,
IReadOnlyList<ConsoleFilterCategory> Categories);
/// <summary>
/// A filter category with available options.
/// </summary>
internal sealed record ConsoleFilterCategory(
string CategoryId,
string DisplayName,
string FilterType,
bool MultiSelect,
IReadOnlyList<ConsoleFilterOption> Options);
/// <summary>
/// A single filter option with count and metadata.
/// </summary>
internal sealed record ConsoleFilterOption(
string Value,
string DisplayName,
int Count,
bool IsDefault,
string? Description,
string? IconHint);
/// <summary>
/// Query for filters endpoint.
/// </summary>
internal sealed record ConsoleFiltersQuery(
string? Scope,
bool IncludeEmptyCategories);

View File

@@ -277,6 +277,265 @@ internal sealed class ConsoleWorkspaceSampleService : IConsoleWorkspaceService
return Task.FromResult(page);
}
public Task<ConsoleDashboardResponse> GetDashboardAsync(
string tenant,
CancellationToken cancellationToken)
{
var findings = SampleFindings.Where(detail => IsTenantMatch(tenant, detail.Summary)).ToList();
var statements = SampleStatements.Where(s => string.Equals(s.Tenant, tenant, StringComparison.OrdinalIgnoreCase)).ToList();
// Build findings summary
var findingsSummary = new ConsoleDashboardFindingsSummary(
TotalFindings: findings.Count,
CriticalCount: findings.Count(f => string.Equals(f.Summary.Severity, "critical", StringComparison.OrdinalIgnoreCase)),
HighCount: findings.Count(f => string.Equals(f.Summary.Severity, "high", StringComparison.OrdinalIgnoreCase)),
MediumCount: findings.Count(f => string.Equals(f.Summary.Severity, "medium", StringComparison.OrdinalIgnoreCase)),
LowCount: findings.Count(f => string.Equals(f.Summary.Severity, "low", StringComparison.OrdinalIgnoreCase)),
InformationalCount: findings.Count(f => string.Equals(f.Summary.Severity, "info", StringComparison.OrdinalIgnoreCase)),
NewLastDay: 1,
NewLastWeek: 2,
ResolvedLastWeek: 0,
TrendLast30Days: GenerateSampleTrend());
// Build VEX summary
var vexSummary = new ConsoleDashboardVexSummary(
TotalStatements: statements.Count,
NotAffectedCount: statements.Count(s => string.Equals(s.State, "not_affected", StringComparison.OrdinalIgnoreCase)),
FixedCount: statements.Count(s => string.Equals(s.State, "fixed", StringComparison.OrdinalIgnoreCase)),
UnderInvestigationCount: statements.Count(s => string.Equals(s.State, "under_investigation", StringComparison.OrdinalIgnoreCase)),
AffectedCount: statements.Count(s => string.Equals(s.State, "affected", StringComparison.OrdinalIgnoreCase)),
AutomatedCount: statements.Count(s => string.Equals(s.Source.Type, "advisory_ai", StringComparison.OrdinalIgnoreCase)),
ManualCount: statements.Count(s => !string.Equals(s.Source.Type, "advisory_ai", StringComparison.OrdinalIgnoreCase)),
LastStatementUpdated: statements.OrderByDescending(s => s.LastUpdated).FirstOrDefault()?.LastUpdated);
// Build advisory summary
var advisorySummary = new ConsoleDashboardAdvisorySummary(
TotalAdvisories: findings.Select(f => f.Summary.Coordinates.AdvisoryId).Distinct().Count(),
NewLastDay: 1,
NewLastWeek: 2,
UpdatedLastWeek: 1,
KevCount: findings.Count(f => f.Summary.Kev),
RecentAdvisories: findings
.Select(f => new ConsoleDashboardAdvisoryItem(
AdvisoryId: f.Summary.Coordinates.AdvisoryId,
Severity: f.Summary.Severity,
Summary: f.Summary.Summary,
Kev: f.Summary.Kev,
AffectedFindings: 1,
PublishedAt: f.Summary.Timestamps.FirstSeen))
.DistinctBy(a => a.AdvisoryId)
.OrderByDescending(a => a.PublishedAt)
.Take(5)
.ToImmutableArray());
// Build run health
var runHealth = new ConsoleDashboardRunHealth(
TotalRuns: 10,
SuccessfulRuns: 8,
FailedRuns: 1,
RunningRuns: 1,
PendingRuns: 0,
SuccessRatePercent: 80.0,
AverageRunDuration: TimeSpan.FromMinutes(5),
LastRunCompletedAt: DateTimeOffset.Parse("2025-11-08T12:00:00Z"),
RecentRuns: ImmutableArray.Create(
new ConsoleDashboardRecentRun(
RunId: "run::2025-11-08::001",
RunType: "scan",
Status: "completed",
StartedAt: DateTimeOffset.Parse("2025-11-08T11:55:00Z"),
CompletedAt: DateTimeOffset.Parse("2025-11-08T12:00:00Z"),
Duration: TimeSpan.FromMinutes(5),
FindingsProcessed: 150),
new ConsoleDashboardRecentRun(
RunId: "run::2025-11-08::002",
RunType: "policy_eval",
Status: "running",
StartedAt: DateTimeOffset.Parse("2025-11-08T12:05:00Z"),
CompletedAt: null,
Duration: null,
FindingsProcessed: 75)));
// Build policy change log
var policyChangeLog = new ConsoleDashboardPolicyChangeLog(
TotalPolicies: 5,
ActivePolicies: 4,
ChangesLastWeek: 2,
LastPolicyUpdated: DateTimeOffset.Parse("2025-11-07T15:30:00Z"),
RecentChanges: ImmutableArray.Create(
new ConsoleDashboardPolicyChange(
PolicyId: "policy://tenant-default/runtime-hardening",
PolicyName: "Runtime Hardening",
ChangeType: "updated",
ChangedBy: "admin@stella-ops.org",
ChangedAt: DateTimeOffset.Parse("2025-11-07T15:30:00Z"),
Description: "Added KEV check rule"),
new ConsoleDashboardPolicyChange(
PolicyId: "policy://tenant-default/network-hardening",
PolicyName: "Network Hardening",
ChangeType: "activated",
ChangedBy: "admin@stella-ops.org",
ChangedAt: DateTimeOffset.Parse("2025-11-06T10:00:00Z"),
Description: null)));
var dashboard = new ConsoleDashboardResponse(
Tenant: tenant,
GeneratedAt: DateTimeOffset.UtcNow,
Findings: findingsSummary,
VexOverrides: vexSummary,
AdvisoryDeltas: advisorySummary,
RunHealth: runHealth,
PolicyChanges: policyChangeLog);
return Task.FromResult(dashboard);
}
public Task<ConsoleFiltersResponse> GetFiltersAsync(
string tenant,
ConsoleFiltersQuery query,
CancellationToken cancellationToken)
{
var findings = SampleFindings.Where(detail => IsTenantMatch(tenant, detail.Summary)).ToList();
var categories = new List<ConsoleFilterCategory>
{
new ConsoleFilterCategory(
CategoryId: "severity",
DisplayName: "Severity",
FilterType: "enum",
MultiSelect: true,
Options: BuildFilterOptions(findings, f => f.Summary.Severity, new (string, string, string?)[]
{
("critical", "Critical", "critical_icon"),
("high", "High", "high_icon"),
("medium", "Medium", "medium_icon"),
("low", "Low", "low_icon"),
("info", "Informational", "info_icon")
}, query.IncludeEmptyCategories)),
new ConsoleFilterCategory(
CategoryId: "policyBadge",
DisplayName: "Policy Status",
FilterType: "enum",
MultiSelect: true,
Options: BuildFilterOptions(findings, f => f.Summary.PolicyBadge, new (string, string, string?)[]
{
("fail", "Fail", "fail_icon"),
("warn", "Warning", "warn_icon"),
("pass", "Pass", "pass_icon"),
("waived", "Waived", "waived_icon")
}, query.IncludeEmptyCategories)),
new ConsoleFilterCategory(
CategoryId: "reachability",
DisplayName: "Reachability",
FilterType: "enum",
MultiSelect: true,
Options: BuildFilterOptions(findings, f => f.Summary.Reachability?.Status ?? "unknown", new (string, string, string?)[]
{
("reachable", "Reachable", "reachable_icon"),
("unreachable", "Unreachable", "unreachable_icon"),
("unknown", "Unknown", "unknown_icon")
}, query.IncludeEmptyCategories)),
new ConsoleFilterCategory(
CategoryId: "vexState",
DisplayName: "VEX State",
FilterType: "enum",
MultiSelect: true,
Options: BuildFilterOptions(findings, f => f.Summary.Vex?.State ?? "none", new (string, string, string?)[]
{
("not_affected", "Not Affected", "not_affected_icon"),
("fixed", "Fixed", "fixed_icon"),
("under_investigation", "Under Investigation", "investigating_icon"),
("affected", "Affected", "affected_icon"),
("none", "No VEX", null)
}, query.IncludeEmptyCategories)),
new ConsoleFilterCategory(
CategoryId: "kev",
DisplayName: "Known Exploited",
FilterType: "boolean",
MultiSelect: false,
Options: ImmutableArray.Create(
new ConsoleFilterOption("true", "KEV Listed", findings.Count(f => f.Summary.Kev), false, "Known Exploited Vulnerability", "kev_icon"),
new ConsoleFilterOption("false", "Not KEV", findings.Count(f => !f.Summary.Kev), true, null, null)))
};
// Filter by scope if specified
if (!string.IsNullOrWhiteSpace(query.Scope))
{
categories = categories
.Where(c => string.Equals(c.CategoryId, query.Scope, StringComparison.OrdinalIgnoreCase))
.ToList();
}
var filtersHash = ComputeFiltersHash(categories);
var response = new ConsoleFiltersResponse(
Tenant: tenant,
GeneratedAt: DateTimeOffset.UtcNow,
FiltersHash: filtersHash,
Categories: categories.ToImmutableArray());
return Task.FromResult(response);
}
private static ImmutableArray<ConsoleDashboardTrendPoint> GenerateSampleTrend()
{
var points = new List<ConsoleDashboardTrendPoint>();
var baseDate = DateTimeOffset.Parse("2025-10-09T00:00:00Z");
for (int i = 0; i < 30; i++)
{
points.Add(new ConsoleDashboardTrendPoint(
Date: baseDate.AddDays(i),
Open: 2 + (i % 3),
Resolved: i % 5 == 0 ? 1 : 0,
New: i % 7 == 0 ? 1 : 0));
}
return points.ToImmutableArray();
}
private static ImmutableArray<ConsoleFilterOption> BuildFilterOptions(
List<ConsoleVulnerabilityFindingDetail> findings,
Func<ConsoleVulnerabilityFindingDetail, string> selector,
(string value, string displayName, string? icon)[] definitions,
bool includeEmpty)
{
var counts = findings
.GroupBy(selector, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
var options = new List<ConsoleFilterOption>();
foreach (var (value, displayName, icon) in definitions)
{
var count = counts.TryGetValue(value, out var c) ? c : 0;
if (count > 0 || includeEmpty)
{
options.Add(new ConsoleFilterOption(
Value: value,
DisplayName: displayName,
Count: count,
IsDefault: false,
Description: null,
IconHint: icon));
}
}
return options.ToImmutableArray();
}
private static string ComputeFiltersHash(List<ConsoleFilterCategory> categories)
{
using var sha256 = SHA256.Create();
var joined = string.Join("|", categories.SelectMany(c =>
c.Options.Select(o => $"{c.CategoryId}:{o.Value}:{o.Count}")));
var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(joined));
return $"sha256:{Convert.ToHexString(hash[..8]).ToLowerInvariant()}";
}
private static bool MatchesSeverity(ConsoleVulnerabilityFindingDetail detail, ConsoleVulnerabilityQuery query) =>
query.Severity.Count == 0 ||
query.Severity.Any(sev => string.Equals(sev, detail.Summary.Severity, StringComparison.OrdinalIgnoreCase));

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Services.Models;
@@ -9,11 +10,12 @@ using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
/// <summary>
/// HTTP client for Authority console endpoints (CLI-TEN-47-001).
/// HTTP client for Authority console endpoints (CLI-TEN-47-001, CLI-TEN-49-001).
/// </summary>
internal sealed class AuthorityConsoleClient : IAuthorityConsoleClient
{
private readonly HttpClient _httpClient;
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public AuthorityConsoleClient(HttpClient httpClient)
{
@@ -38,4 +40,73 @@ internal sealed class AuthorityConsoleClient : IAuthorityConsoleClient
return result?.Tenants ?? Array.Empty<TenantInfo>();
}
public async Task<TokenMintResponse> MintTokenAsync(TokenMintRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "console/token/mint")
{
Content = JsonContent.Create(request, options: JsonOptions)
};
if (!string.IsNullOrWhiteSpace(request.Tenant))
{
httpRequest.Headers.Add("X-StellaOps-Tenant", request.Tenant.Trim().ToLowerInvariant());
}
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content
.ReadFromJsonAsync<TokenMintResponse>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? throw new InvalidOperationException("Token mint response was empty.");
}
public async Task<TokenDelegateResponse> DelegateTokenAsync(TokenDelegateRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "console/token/delegate")
{
Content = JsonContent.Create(request, options: JsonOptions)
};
if (!string.IsNullOrWhiteSpace(request.Tenant))
{
httpRequest.Headers.Add("X-StellaOps-Tenant", request.Tenant.Trim().ToLowerInvariant());
}
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content
.ReadFromJsonAsync<TokenDelegateResponse>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? throw new InvalidOperationException("Token delegation response was empty.");
}
public async Task<TokenIntrospectionResponse?> IntrospectTokenAsync(string? tenant, CancellationToken cancellationToken)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "console/token/introspect");
if (!string.IsNullOrWhiteSpace(tenant))
{
httpRequest.Headers.Add("X-StellaOps-Tenant", tenant.Trim().ToLowerInvariant());
}
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
return null;
}
return await response.Content
.ReadFromJsonAsync<TokenIntrospectionResponse>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
}
}

View File

@@ -18,10 +18,10 @@ using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services.Models;
using StellaOps.Cli.Services.Models.AdvisoryAi;
using StellaOps.Cli.Services.Models.Ruby;
using StellaOps.Cli.Services.Models.Transport;
using StellaOps.Cli.Services.Models;
using StellaOps.Cli.Services.Models.AdvisoryAi;
using StellaOps.Cli.Services.Models.Ruby;
using StellaOps.Cli.Services.Models.Transport;
namespace StellaOps.Cli.Services;
@@ -32,12 +32,12 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
private static readonly IReadOnlyDictionary<string, object?> EmptyMetadata =
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(0, StringComparer.OrdinalIgnoreCase));
private const string OperatorReasonParameterName = "operator_reason";
private const string OperatorTicketParameterName = "operator_ticket";
private const string BackfillReasonParameterName = "backfill_reason";
private const string BackfillTicketParameterName = "backfill_ticket";
private const string AdvisoryScopesHeader = "X-StellaOps-Scopes";
private const string AdvisoryRunScope = "advisory:run";
private const string OperatorReasonParameterName = "operator_reason";
private const string OperatorTicketParameterName = "operator_ticket";
private const string BackfillReasonParameterName = "backfill_reason";
private const string BackfillTicketParameterName = "backfill_ticket";
private const string AdvisoryScopesHeader = "X-StellaOps-Scopes";
private const string AdvisoryRunScope = "advisory:run";
private readonly HttpClient _httpClient;
private readonly StellaOpsCliOptions _options;
@@ -859,9 +859,9 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
return MapPolicyFindingExplain(document);
}
public async Task<EntryTraceResponseModel?> GetEntryTraceAsync(string scanId, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
public async Task<EntryTraceResponseModel?> GetEntryTraceAsync(string scanId, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
if (string.IsNullOrWhiteSpace(scanId))
{
@@ -883,174 +883,174 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
throw new InvalidOperationException(failure);
}
var result = await response.Content.ReadFromJsonAsync<EntryTraceResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
if (result is null)
{
throw new InvalidOperationException("EntryTrace response payload was empty.");
}
return result;
}
public async Task<RubyPackageInventoryModel?> GetRubyPackagesAsync(string scanId, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
if (string.IsNullOrWhiteSpace(scanId))
{
throw new ArgumentException("Scan identifier is required.", nameof(scanId));
}
var encodedScanId = Uri.EscapeDataString(scanId);
using var request = CreateRequest(HttpMethod.Get, $"api/scans/{encodedScanId}/ruby-packages");
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
if (!response.IsSuccessStatusCode)
{
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException(failure);
}
var inventory = await response.Content
.ReadFromJsonAsync<RubyPackageInventoryModel>(SerializerOptions, cancellationToken)
.ConfigureAwait(false);
if (inventory is null)
{
throw new InvalidOperationException("Ruby package response payload was empty.");
}
var normalizedScanId = string.IsNullOrWhiteSpace(inventory.ScanId) ? scanId : inventory.ScanId;
var normalizedDigest = inventory.ImageDigest ?? string.Empty;
var packages = inventory.Packages ?? Array.Empty<RubyPackageArtifactModel>();
return inventory with
{
ScanId = normalizedScanId,
ImageDigest = normalizedDigest,
Packages = packages
};
}
public async Task<AdvisoryPipelinePlanResponseModel> CreateAdvisoryPipelinePlanAsync(
AdvisoryAiTaskType taskType,
AdvisoryPipelinePlanRequestModel request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var taskSegment = taskType.ToString().ToLowerInvariant();
var relative = $"v1/advisory-ai/pipeline/{taskSegment}";
var payload = new AdvisoryPipelinePlanRequestModel
{
TaskType = taskType,
AdvisoryKey = string.IsNullOrWhiteSpace(request.AdvisoryKey) ? string.Empty : request.AdvisoryKey.Trim(),
ArtifactId = string.IsNullOrWhiteSpace(request.ArtifactId) ? null : request.ArtifactId!.Trim(),
ArtifactPurl = string.IsNullOrWhiteSpace(request.ArtifactPurl) ? null : request.ArtifactPurl!.Trim(),
PolicyVersion = string.IsNullOrWhiteSpace(request.PolicyVersion) ? null : request.PolicyVersion!.Trim(),
Profile = string.IsNullOrWhiteSpace(request.Profile) ? "default" : request.Profile!.Trim(),
PreferredSections = request.PreferredSections is null
? null
: request.PreferredSections
.Where(static section => !string.IsNullOrWhiteSpace(section))
.Select(static section => section.Trim())
.ToArray(),
ForceRefresh = request.ForceRefresh
};
using var httpRequest = CreateRequest(HttpMethod.Post, relative);
ApplyAdvisoryAiEndpoint(httpRequest, taskType);
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
httpRequest.Content = JsonContent.Create(payload, options: SerializerOptions);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException(failure);
}
try
{
var plan = await response.Content.ReadFromJsonAsync<AdvisoryPipelinePlanResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
if (plan is null)
{
throw new InvalidOperationException("Advisory AI plan response was empty.");
}
return plan;
}
catch (JsonException ex)
{
var raw = response.Content is null
? string.Empty
: await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to parse advisory plan response. {ex.Message}", ex)
{
Data = { ["payload"] = raw }
};
}
}
public async Task<AdvisoryPipelineOutputModel?> TryGetAdvisoryPipelineOutputAsync(
string cacheKey,
AdvisoryAiTaskType taskType,
string profile,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(cacheKey))
{
throw new ArgumentException("Cache key is required.", nameof(cacheKey));
}
var encodedKey = Uri.EscapeDataString(cacheKey);
var taskSegment = Uri.EscapeDataString(taskType.ToString().ToLowerInvariant());
var resolvedProfile = string.IsNullOrWhiteSpace(profile) ? "default" : profile.Trim();
var relative = $"v1/advisory-ai/outputs/{encodedKey}?taskType={taskSegment}&profile={Uri.EscapeDataString(resolvedProfile)}";
using var request = CreateRequest(HttpMethod.Get, relative);
ApplyAdvisoryAiEndpoint(request, taskType);
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
if (!response.IsSuccessStatusCode)
{
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException(failure);
}
try
{
return await response.Content.ReadFromJsonAsync<AdvisoryPipelineOutputModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
}
catch (JsonException ex)
{
var raw = response.Content is null
? string.Empty
: await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to parse advisory output response. {ex.Message}", ex)
{
Data = { ["payload"] = raw }
};
}
}
public async Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
var result = await response.Content.ReadFromJsonAsync<EntryTraceResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
if (result is null)
{
throw new InvalidOperationException("EntryTrace response payload was empty.");
}
return result;
}
public async Task<RubyPackageInventoryModel?> GetRubyPackagesAsync(string scanId, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
if (string.IsNullOrWhiteSpace(scanId))
{
throw new ArgumentException("Scan identifier is required.", nameof(scanId));
}
var encodedScanId = Uri.EscapeDataString(scanId);
using var request = CreateRequest(HttpMethod.Get, $"api/scans/{encodedScanId}/ruby-packages");
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
if (!response.IsSuccessStatusCode)
{
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException(failure);
}
var inventory = await response.Content
.ReadFromJsonAsync<RubyPackageInventoryModel>(SerializerOptions, cancellationToken)
.ConfigureAwait(false);
if (inventory is null)
{
throw new InvalidOperationException("Ruby package response payload was empty.");
}
var normalizedScanId = string.IsNullOrWhiteSpace(inventory.ScanId) ? scanId : inventory.ScanId;
var normalizedDigest = inventory.ImageDigest ?? string.Empty;
var packages = inventory.Packages ?? Array.Empty<RubyPackageArtifactModel>();
return inventory with
{
ScanId = normalizedScanId,
ImageDigest = normalizedDigest,
Packages = packages
};
}
public async Task<AdvisoryPipelinePlanResponseModel> CreateAdvisoryPipelinePlanAsync(
AdvisoryAiTaskType taskType,
AdvisoryPipelinePlanRequestModel request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var taskSegment = taskType.ToString().ToLowerInvariant();
var relative = $"v1/advisory-ai/pipeline/{taskSegment}";
var payload = new AdvisoryPipelinePlanRequestModel
{
TaskType = taskType,
AdvisoryKey = string.IsNullOrWhiteSpace(request.AdvisoryKey) ? string.Empty : request.AdvisoryKey.Trim(),
ArtifactId = string.IsNullOrWhiteSpace(request.ArtifactId) ? null : request.ArtifactId!.Trim(),
ArtifactPurl = string.IsNullOrWhiteSpace(request.ArtifactPurl) ? null : request.ArtifactPurl!.Trim(),
PolicyVersion = string.IsNullOrWhiteSpace(request.PolicyVersion) ? null : request.PolicyVersion!.Trim(),
Profile = string.IsNullOrWhiteSpace(request.Profile) ? "default" : request.Profile!.Trim(),
PreferredSections = request.PreferredSections is null
? null
: request.PreferredSections
.Where(static section => !string.IsNullOrWhiteSpace(section))
.Select(static section => section.Trim())
.ToArray(),
ForceRefresh = request.ForceRefresh
};
using var httpRequest = CreateRequest(HttpMethod.Post, relative);
ApplyAdvisoryAiEndpoint(httpRequest, taskType);
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
httpRequest.Content = JsonContent.Create(payload, options: SerializerOptions);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException(failure);
}
try
{
var plan = await response.Content.ReadFromJsonAsync<AdvisoryPipelinePlanResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
if (plan is null)
{
throw new InvalidOperationException("Advisory AI plan response was empty.");
}
return plan;
}
catch (JsonException ex)
{
var raw = response.Content is null
? string.Empty
: await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to parse advisory plan response. {ex.Message}", ex)
{
Data = { ["payload"] = raw }
};
}
}
public async Task<AdvisoryPipelineOutputModel?> TryGetAdvisoryPipelineOutputAsync(
string cacheKey,
AdvisoryAiTaskType taskType,
string profile,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(cacheKey))
{
throw new ArgumentException("Cache key is required.", nameof(cacheKey));
}
var encodedKey = Uri.EscapeDataString(cacheKey);
var taskSegment = Uri.EscapeDataString(taskType.ToString().ToLowerInvariant());
var resolvedProfile = string.IsNullOrWhiteSpace(profile) ? "default" : profile.Trim();
var relative = $"v1/advisory-ai/outputs/{encodedKey}?taskType={taskSegment}&profile={Uri.EscapeDataString(resolvedProfile)}";
using var request = CreateRequest(HttpMethod.Get, relative);
ApplyAdvisoryAiEndpoint(request, taskType);
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
if (!response.IsSuccessStatusCode)
{
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException(failure);
}
try
{
return await response.Content.ReadFromJsonAsync<AdvisoryPipelineOutputModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
}
catch (JsonException ex)
{
var raw = response.Content is null
? string.Empty
: await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to parse advisory output response. {ex.Message}", ex)
{
Data = { ["payload"] = raw }
};
}
}
public async Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
var query = includeDisabled ? "?includeDisabled=true" : string.Empty;
using var request = CreateRequest(HttpMethod.Get, $"excititor/providers{query}");
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
@@ -1937,44 +1937,44 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
private void ApplyAdvisoryAiEndpoint(HttpRequestMessage request, AdvisoryAiTaskType taskType)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
var requestUri = request.RequestUri ?? throw new InvalidOperationException("Request URI was not initialized.");
if (!string.IsNullOrWhiteSpace(_options.AdvisoryAiUrl) &&
Uri.TryCreate(_options.AdvisoryAiUrl, UriKind.Absolute, out var advisoryBase))
{
if (!requestUri.IsAbsoluteUri)
{
request.RequestUri = new Uri(advisoryBase, requestUri.ToString());
}
}
else if (!string.IsNullOrWhiteSpace(_options.AdvisoryAiUrl))
{
throw new InvalidOperationException($"Advisory AI URL '{_options.AdvisoryAiUrl}' is not a valid absolute URI.");
}
else
{
EnsureBackendConfigured();
}
var taskScope = $"advisory:{taskType.ToString().ToLowerInvariant()}";
var combined = $"{AdvisoryRunScope} {taskScope}";
if (request.Headers.Contains(AdvisoryScopesHeader))
{
request.Headers.Remove(AdvisoryScopesHeader);
}
request.Headers.TryAddWithoutValidation(AdvisoryScopesHeader, combined);
}
private HttpRequestMessage CreateRequest(HttpMethod method, string relativeUri)
private void ApplyAdvisoryAiEndpoint(HttpRequestMessage request, AdvisoryAiTaskType taskType)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
var requestUri = request.RequestUri ?? throw new InvalidOperationException("Request URI was not initialized.");
if (!string.IsNullOrWhiteSpace(_options.AdvisoryAiUrl) &&
Uri.TryCreate(_options.AdvisoryAiUrl, UriKind.Absolute, out var advisoryBase))
{
if (!requestUri.IsAbsoluteUri)
{
request.RequestUri = new Uri(advisoryBase, requestUri.ToString());
}
}
else if (!string.IsNullOrWhiteSpace(_options.AdvisoryAiUrl))
{
throw new InvalidOperationException($"Advisory AI URL '{_options.AdvisoryAiUrl}' is not a valid absolute URI.");
}
else
{
EnsureBackendConfigured();
}
var taskScope = $"advisory:{taskType.ToString().ToLowerInvariant()}";
var combined = $"{AdvisoryRunScope} {taskScope}";
if (request.Headers.Contains(AdvisoryScopesHeader))
{
request.Headers.Remove(AdvisoryScopesHeader);
}
request.Headers.TryAddWithoutValidation(AdvisoryScopesHeader, combined);
}
private HttpRequestMessage CreateRequest(HttpMethod method, string relativeUri)
{
if (!Uri.TryCreate(relativeUri, UriKind.RelativeOrAbsolute, out var requestUri))
{
@@ -2857,4 +2857,469 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
var fallbackSeconds = Math.Min(60, Math.Pow(2, attempt));
return TimeSpan.FromSeconds(fallbackSeconds);
}
// CLI-VEX-30-001: VEX consensus list
public async Task<VexConsensusListResponse> ListVexConsensusAsync(VexConsensusListRequest request, string? tenant, CancellationToken cancellationToken)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
EnsureBackendConfigured();
var queryParams = new List<string>();
if (!string.IsNullOrWhiteSpace(request.VulnerabilityId))
queryParams.Add($"vulnerabilityId={Uri.EscapeDataString(request.VulnerabilityId)}");
if (!string.IsNullOrWhiteSpace(request.ProductKey))
queryParams.Add($"productKey={Uri.EscapeDataString(request.ProductKey)}");
if (!string.IsNullOrWhiteSpace(request.Purl))
queryParams.Add($"purl={Uri.EscapeDataString(request.Purl)}");
if (!string.IsNullOrWhiteSpace(request.Status))
queryParams.Add($"status={Uri.EscapeDataString(request.Status)}");
if (!string.IsNullOrWhiteSpace(request.PolicyVersion))
queryParams.Add($"policyVersion={Uri.EscapeDataString(request.PolicyVersion)}");
if (request.Limit.HasValue)
queryParams.Add($"limit={request.Limit.Value}");
if (request.Offset.HasValue)
queryParams.Add($"offset={request.Offset.Value}");
var queryString = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
var relative = $"api/vex/consensus{queryString}";
using var httpRequest = CreateRequest(HttpMethod.Get, relative);
if (!string.IsNullOrWhiteSpace(tenant))
{
httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", tenant.Trim());
}
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"VEX consensus list failed: {message}");
}
VexConsensusListResponse? result;
try
{
result = await response.Content.ReadFromJsonAsync<VexConsensusListResponse>(SerializerOptions, cancellationToken).ConfigureAwait(false);
}
catch (JsonException ex)
{
var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to parse VEX consensus list response: {ex.Message}", ex)
{
Data = { ["payload"] = raw }
};
}
if (result is null)
{
throw new InvalidOperationException("VEX consensus list response was empty.");
}
return result;
}
// CLI-VEX-30-002: VEX consensus detail
public async Task<VexConsensusDetailResponse?> GetVexConsensusAsync(string vulnerabilityId, string productKey, string? tenant, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(vulnerabilityId))
{
throw new ArgumentException("Vulnerability ID must be provided.", nameof(vulnerabilityId));
}
if (string.IsNullOrWhiteSpace(productKey))
{
throw new ArgumentException("Product key must be provided.", nameof(productKey));
}
EnsureBackendConfigured();
var encodedVulnId = Uri.EscapeDataString(vulnerabilityId.Trim());
var encodedProductKey = Uri.EscapeDataString(productKey.Trim());
var relative = $"api/vex/consensus/{encodedVulnId}/{encodedProductKey}";
using var httpRequest = CreateRequest(HttpMethod.Get, relative);
if (!string.IsNullOrWhiteSpace(tenant))
{
httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", tenant.Trim());
}
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
if (!response.IsSuccessStatusCode)
{
var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"VEX consensus get failed: {message}");
}
VexConsensusDetailResponse? result;
try
{
result = await response.Content.ReadFromJsonAsync<VexConsensusDetailResponse>(SerializerOptions, cancellationToken).ConfigureAwait(false);
}
catch (JsonException ex)
{
var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to parse VEX consensus detail response: {ex.Message}", ex)
{
Data = { ["payload"] = raw }
};
}
return result;
}
// CLI-VEX-30-003: VEX simulation
public async Task<VexSimulationResponse> SimulateVexConsensusAsync(VexSimulationRequest request, string? tenant, CancellationToken cancellationToken)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
EnsureBackendConfigured();
var relative = "api/vex/consensus/simulate";
using var httpRequest = CreateRequest(HttpMethod.Post, relative);
if (!string.IsNullOrWhiteSpace(tenant))
{
httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", tenant.Trim());
}
var jsonContent = JsonSerializer.Serialize(request, SerializerOptions);
httpRequest.Content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"VEX consensus simulation failed: {message}");
}
VexSimulationResponse? result;
try
{
result = await response.Content.ReadFromJsonAsync<VexSimulationResponse>(SerializerOptions, cancellationToken).ConfigureAwait(false);
}
catch (JsonException ex)
{
var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to parse VEX simulation response: {ex.Message}", ex)
{
Data = { ["payload"] = raw }
};
}
if (result is null)
{
throw new InvalidOperationException("VEX simulation response was empty.");
}
return result;
}
// CLI-VEX-30-004: VEX export
public async Task<VexExportResponse> ExportVexConsensusAsync(VexExportRequest request, string? tenant, CancellationToken cancellationToken)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
EnsureBackendConfigured();
var relative = "api/vex/consensus/export";
using var httpRequest = CreateRequest(HttpMethod.Post, relative);
if (!string.IsNullOrWhiteSpace(tenant))
{
httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", tenant.Trim());
}
var jsonContent = JsonSerializer.Serialize(request, SerializerOptions);
httpRequest.Content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"VEX consensus export failed: {message}");
}
VexExportResponse? result;
try
{
result = await response.Content.ReadFromJsonAsync<VexExportResponse>(SerializerOptions, cancellationToken).ConfigureAwait(false);
}
catch (JsonException ex)
{
var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to parse VEX export response: {ex.Message}", ex)
{
Data = { ["payload"] = raw }
};
}
if (result is null)
{
throw new InvalidOperationException("VEX export response was empty.");
}
return result;
}
public async Task<Stream> DownloadVexExportAsync(string exportId, string? tenant, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(exportId))
{
throw new ArgumentException("Export ID must be provided.", nameof(exportId));
}
EnsureBackendConfigured();
var encodedExportId = Uri.EscapeDataString(exportId.Trim());
var relative = $"api/vex/consensus/export/{encodedExportId}/download";
using var httpRequest = CreateRequest(HttpMethod.Get, relative);
if (!string.IsNullOrWhiteSpace(tenant))
{
httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", tenant.Trim());
}
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
var response = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"VEX export download failed: {message}");
}
return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
}
// CLI-VULN-29-001: Vulnerability explorer list
public async Task<VulnListResponse> ListVulnerabilitiesAsync(VulnListRequest request, string? tenant, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
var queryParams = new List<string>();
if (!string.IsNullOrWhiteSpace(request.VulnerabilityId))
queryParams.Add($"vulnerabilityId={Uri.EscapeDataString(request.VulnerabilityId)}");
if (!string.IsNullOrWhiteSpace(request.Severity))
queryParams.Add($"severity={Uri.EscapeDataString(request.Severity)}");
if (!string.IsNullOrWhiteSpace(request.Status))
queryParams.Add($"status={Uri.EscapeDataString(request.Status)}");
if (!string.IsNullOrWhiteSpace(request.Purl))
queryParams.Add($"purl={Uri.EscapeDataString(request.Purl)}");
if (!string.IsNullOrWhiteSpace(request.Cpe))
queryParams.Add($"cpe={Uri.EscapeDataString(request.Cpe)}");
if (!string.IsNullOrWhiteSpace(request.SbomId))
queryParams.Add($"sbomId={Uri.EscapeDataString(request.SbomId)}");
if (!string.IsNullOrWhiteSpace(request.PolicyId))
queryParams.Add($"policyId={Uri.EscapeDataString(request.PolicyId)}");
if (request.PolicyVersion.HasValue)
queryParams.Add($"policyVersion={request.PolicyVersion.Value}");
if (!string.IsNullOrWhiteSpace(request.GroupBy))
queryParams.Add($"groupBy={Uri.EscapeDataString(request.GroupBy)}");
if (request.Limit.HasValue)
queryParams.Add($"limit={request.Limit.Value}");
if (request.Offset.HasValue)
queryParams.Add($"offset={request.Offset.Value}");
if (!string.IsNullOrWhiteSpace(request.Cursor))
queryParams.Add($"cursor={Uri.EscapeDataString(request.Cursor)}");
var relative = "api/vuln";
if (queryParams.Count > 0)
relative += "?" + string.Join("&", queryParams);
using var httpRequest = CreateRequest(HttpMethod.Get, relative);
if (!string.IsNullOrWhiteSpace(tenant))
{
httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", tenant.Trim());
}
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to list vulnerabilities: {message}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var result = JsonSerializer.Deserialize<VulnListResponse>(json, SerializerOptions);
return result ?? new VulnListResponse(Array.Empty<VulnItem>(), 0, 0, 0, false);
}
// CLI-VULN-29-002: Vulnerability detail
public async Task<VulnDetailResponse?> GetVulnerabilityAsync(string vulnerabilityId, string? tenant, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(vulnerabilityId))
{
throw new ArgumentException("Vulnerability ID must be provided.", nameof(vulnerabilityId));
}
EnsureBackendConfigured();
var encodedVulnId = Uri.EscapeDataString(vulnerabilityId.Trim());
var relative = $"api/vuln/{encodedVulnId}";
using var httpRequest = CreateRequest(HttpMethod.Get, relative);
if (!string.IsNullOrWhiteSpace(tenant))
{
httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", tenant.Trim());
}
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
if (!response.IsSuccessStatusCode)
{
var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to get vulnerability details: {message}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return JsonSerializer.Deserialize<VulnDetailResponse>(json, SerializerOptions);
}
// CLI-VULN-29-003: Vulnerability workflow operations
public async Task<VulnWorkflowResponse> ExecuteVulnWorkflowAsync(VulnWorkflowRequest request, string? tenant, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
var relative = "api/vuln/workflow";
var jsonPayload = JsonSerializer.Serialize(request, SerializerOptions);
using var httpRequest = CreateRequest(HttpMethod.Post, relative);
httpRequest.Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json");
if (!string.IsNullOrWhiteSpace(tenant))
{
httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", tenant.Trim());
}
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Workflow operation failed: {message}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var result = JsonSerializer.Deserialize<VulnWorkflowResponse>(json, SerializerOptions);
return result ?? new VulnWorkflowResponse(false, request.Action, 0);
}
// CLI-VULN-29-004: Vulnerability simulation
public async Task<VulnSimulationResponse> SimulateVulnerabilitiesAsync(VulnSimulationRequest request, string? tenant, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
var relative = "api/vuln/simulate";
var jsonPayload = JsonSerializer.Serialize(request, SerializerOptions);
using var httpRequest = CreateRequest(HttpMethod.Post, relative);
httpRequest.Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json");
if (!string.IsNullOrWhiteSpace(tenant))
{
httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", tenant.Trim());
}
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Vulnerability simulation failed: {message}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var result = JsonSerializer.Deserialize<VulnSimulationResponse>(json, SerializerOptions);
return result ?? new VulnSimulationResponse(Array.Empty<VulnSimulationDelta>(), new VulnSimulationSummary(0, 0, 0, 0, 0));
}
// CLI-VULN-29-005: Vulnerability export
public async Task<VulnExportResponse> ExportVulnerabilitiesAsync(VulnExportRequest request, string? tenant, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
var relative = "api/vuln/export";
var jsonPayload = JsonSerializer.Serialize(request, SerializerOptions);
using var httpRequest = CreateRequest(HttpMethod.Post, relative);
httpRequest.Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json");
if (!string.IsNullOrWhiteSpace(tenant))
{
httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", tenant.Trim());
}
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Vulnerability export failed: {message}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var result = JsonSerializer.Deserialize<VulnExportResponse>(json, SerializerOptions);
return result ?? throw new InvalidOperationException("Failed to parse export response.");
}
public async Task<Stream> DownloadVulnExportAsync(string exportId, string? tenant, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(exportId))
{
throw new ArgumentException("Export ID must be provided.", nameof(exportId));
}
EnsureBackendConfigured();
var encodedExportId = Uri.EscapeDataString(exportId.Trim());
var relative = $"api/vuln/export/{encodedExportId}/download";
using var httpRequest = CreateRequest(HttpMethod.Get, relative);
if (!string.IsNullOrWhiteSpace(tenant))
{
httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", tenant.Trim());
}
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
var response = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Vulnerability export download failed: {message}");
}
return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -6,7 +6,7 @@ using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
/// <summary>
/// Client for Authority console endpoints (CLI-TEN-47-001).
/// Client for Authority console endpoints (CLI-TEN-47-001, CLI-TEN-49-001).
/// </summary>
internal interface IAuthorityConsoleClient
{
@@ -14,4 +14,19 @@ internal interface IAuthorityConsoleClient
/// Lists available tenants for the authenticated principal.
/// </summary>
Task<IReadOnlyList<TenantInfo>> ListTenantsAsync(string tenant, CancellationToken cancellationToken);
/// <summary>
/// Mints a service account token (CLI-TEN-49-001).
/// </summary>
Task<TokenMintResponse> MintTokenAsync(TokenMintRequest request, CancellationToken cancellationToken);
/// <summary>
/// Delegates a token to another principal (CLI-TEN-49-001).
/// </summary>
Task<TokenDelegateResponse> DelegateTokenAsync(TokenDelegateRequest request, CancellationToken cancellationToken);
/// <summary>
/// Introspects the current token for impersonation/delegation info (CLI-TEN-49-001).
/// </summary>
Task<TokenIntrospectionResponse?> IntrospectTokenAsync(string? tenant, CancellationToken cancellationToken);
}

View File

@@ -6,11 +6,11 @@ using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services.Models;
using StellaOps.Cli.Services.Models.AdvisoryAi;
using StellaOps.Cli.Services.Models.Ruby;
namespace StellaOps.Cli.Services;
internal interface IBackendOperationsClient
{
namespace StellaOps.Cli.Services;
internal interface IBackendOperationsClient
{
Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken);
Task UploadScanResultsAsync(string filePath, CancellationToken cancellationToken);
@@ -54,4 +54,33 @@ internal interface IBackendOperationsClient
Task<AdvisoryPipelinePlanResponseModel> CreateAdvisoryPipelinePlanAsync(AdvisoryAiTaskType taskType, AdvisoryPipelinePlanRequestModel request, CancellationToken cancellationToken);
Task<AdvisoryPipelineOutputModel?> TryGetAdvisoryPipelineOutputAsync(string cacheKey, AdvisoryAiTaskType taskType, string profile, CancellationToken cancellationToken);
// CLI-VEX-30-001: VEX consensus operations
Task<VexConsensusListResponse> ListVexConsensusAsync(VexConsensusListRequest request, string? tenant, CancellationToken cancellationToken);
// CLI-VEX-30-002: VEX consensus detail
Task<VexConsensusDetailResponse?> GetVexConsensusAsync(string vulnerabilityId, string productKey, string? tenant, CancellationToken cancellationToken);
// CLI-VEX-30-003: VEX simulation
Task<VexSimulationResponse> SimulateVexConsensusAsync(VexSimulationRequest request, string? tenant, CancellationToken cancellationToken);
// CLI-VEX-30-004: VEX export
Task<VexExportResponse> ExportVexConsensusAsync(VexExportRequest request, string? tenant, CancellationToken cancellationToken);
Task<Stream> DownloadVexExportAsync(string exportId, string? tenant, CancellationToken cancellationToken);
// CLI-VULN-29-001: Vulnerability explorer list
Task<VulnListResponse> ListVulnerabilitiesAsync(VulnListRequest request, string? tenant, CancellationToken cancellationToken);
// CLI-VULN-29-002: Vulnerability detail
Task<VulnDetailResponse?> GetVulnerabilityAsync(string vulnerabilityId, string? tenant, CancellationToken cancellationToken);
// CLI-VULN-29-003: Vulnerability workflow operations
Task<VulnWorkflowResponse> ExecuteVulnWorkflowAsync(VulnWorkflowRequest request, string? tenant, CancellationToken cancellationToken);
// CLI-VULN-29-004: Vulnerability simulation
Task<VulnSimulationResponse> SimulateVulnerabilitiesAsync(VulnSimulationRequest request, string? tenant, CancellationToken cancellationToken);
// CLI-VULN-29-005: Vulnerability export
Task<VulnExportResponse> ExportVulnerabilitiesAsync(VulnExportRequest request, string? tenant, CancellationToken cancellationToken);
Task<Stream> DownloadVulnExportAsync(string exportId, string? tenant, CancellationToken cancellationToken);
}

View File

@@ -35,3 +35,60 @@ internal sealed record TenantProfile
[JsonPropertyName("lastUpdated")]
public DateTimeOffset? LastUpdated { get; init; }
}
// CLI-TEN-49-001: Token minting and delegation models
/// <summary>
/// Request to mint a service account token.
/// </summary>
internal sealed record TokenMintRequest(
[property: JsonPropertyName("serviceAccountId")] string ServiceAccountId,
[property: JsonPropertyName("scopes")] IReadOnlyList<string> Scopes,
[property: JsonPropertyName("expiresInSeconds")] int? ExpiresInSeconds = null,
[property: JsonPropertyName("tenant")] string? Tenant = null,
[property: JsonPropertyName("reason")] string? Reason = null);
/// <summary>
/// Response from token minting.
/// </summary>
internal sealed record TokenMintResponse(
[property: JsonPropertyName("accessToken")] string AccessToken,
[property: JsonPropertyName("tokenType")] string TokenType,
[property: JsonPropertyName("expiresAt")] DateTimeOffset ExpiresAt,
[property: JsonPropertyName("scopes")] IReadOnlyList<string> Scopes,
[property: JsonPropertyName("tokenId")] string? TokenId = null);
/// <summary>
/// Request to delegate a token to another principal.
/// </summary>
internal sealed record TokenDelegateRequest(
[property: JsonPropertyName("delegateTo")] string DelegateTo,
[property: JsonPropertyName("scopes")] IReadOnlyList<string> Scopes,
[property: JsonPropertyName("expiresInSeconds")] int? ExpiresInSeconds = null,
[property: JsonPropertyName("tenant")] string? Tenant = null,
[property: JsonPropertyName("reason")] string? Reason = null);
/// <summary>
/// Response from token delegation.
/// </summary>
internal sealed record TokenDelegateResponse(
[property: JsonPropertyName("accessToken")] string AccessToken,
[property: JsonPropertyName("tokenType")] string TokenType,
[property: JsonPropertyName("expiresAt")] DateTimeOffset ExpiresAt,
[property: JsonPropertyName("delegationId")] string DelegationId,
[property: JsonPropertyName("originalSubject")] string OriginalSubject,
[property: JsonPropertyName("delegatedSubject")] string DelegatedSubject,
[property: JsonPropertyName("scopes")] IReadOnlyList<string> Scopes);
/// <summary>
/// Token introspection response for impersonation banner.
/// </summary>
internal sealed record TokenIntrospectionResponse(
[property: JsonPropertyName("active")] bool Active,
[property: JsonPropertyName("sub")] string? Subject = null,
[property: JsonPropertyName("clientId")] string? ClientId = null,
[property: JsonPropertyName("scope")] string? Scope = null,
[property: JsonPropertyName("exp")] long? ExpiresAt = null,
[property: JsonPropertyName("iat")] long? IssuedAt = null,
[property: JsonPropertyName("delegatedBy")] string? DelegatedBy = null,
[property: JsonPropertyName("delegationReason")] string? DelegationReason = null);

View File

@@ -0,0 +1,258 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
// CLI-VEX-30-001: VEX consensus models for CLI
/// <summary>
/// VEX consensus list request parameters.
/// </summary>
internal sealed record VexConsensusListRequest(
[property: JsonPropertyName("vulnerabilityId")] string? VulnerabilityId = null,
[property: JsonPropertyName("productKey")] string? ProductKey = null,
[property: JsonPropertyName("purl")] string? Purl = null,
[property: JsonPropertyName("status")] string? Status = null,
[property: JsonPropertyName("policyVersion")] string? PolicyVersion = null,
[property: JsonPropertyName("limit")] int? Limit = null,
[property: JsonPropertyName("offset")] int? Offset = null);
/// <summary>
/// Paginated VEX consensus list response.
/// </summary>
internal sealed record VexConsensusListResponse(
[property: JsonPropertyName("items")] IReadOnlyList<VexConsensusItem> Items,
[property: JsonPropertyName("total")] int Total,
[property: JsonPropertyName("limit")] int Limit,
[property: JsonPropertyName("offset")] int Offset,
[property: JsonPropertyName("hasMore")] bool HasMore);
/// <summary>
/// VEX consensus item from the API.
/// </summary>
internal sealed record VexConsensusItem(
[property: JsonPropertyName("vulnerabilityId")] string VulnerabilityId,
[property: JsonPropertyName("product")] VexProductInfo Product,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("calculatedAt")] DateTimeOffset CalculatedAt,
[property: JsonPropertyName("sources")] IReadOnlyList<VexConsensusSourceInfo> Sources,
[property: JsonPropertyName("conflicts")] IReadOnlyList<VexConsensusConflictInfo>? Conflicts = null,
[property: JsonPropertyName("policyVersion")] string? PolicyVersion = null,
[property: JsonPropertyName("policyDigest")] string? PolicyDigest = null,
[property: JsonPropertyName("summary")] string? Summary = null);
/// <summary>
/// VEX product information.
/// </summary>
internal sealed record VexProductInfo(
[property: JsonPropertyName("key")] string Key,
[property: JsonPropertyName("name")] string? Name = null,
[property: JsonPropertyName("version")] string? Version = null,
[property: JsonPropertyName("purl")] string? Purl = null,
[property: JsonPropertyName("cpe")] string? Cpe = null);
/// <summary>
/// VEX consensus source (accepted claim).
/// </summary>
internal sealed record VexConsensusSourceInfo(
[property: JsonPropertyName("providerId")] string ProviderId,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("documentDigest")] string? DocumentDigest = null,
[property: JsonPropertyName("weight")] double Weight = 1.0,
[property: JsonPropertyName("justification")] string? Justification = null,
[property: JsonPropertyName("detail")] string? Detail = null,
[property: JsonPropertyName("confidence")] VexConfidenceInfo? Confidence = null);
/// <summary>
/// VEX consensus conflict (rejected claim).
/// </summary>
internal sealed record VexConsensusConflictInfo(
[property: JsonPropertyName("providerId")] string ProviderId,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("documentDigest")] string? DocumentDigest = null,
[property: JsonPropertyName("justification")] string? Justification = null,
[property: JsonPropertyName("detail")] string? Detail = null,
[property: JsonPropertyName("reason")] string? Reason = null);
/// <summary>
/// VEX confidence information.
/// </summary>
internal sealed record VexConfidenceInfo(
[property: JsonPropertyName("level")] string? Level = null,
[property: JsonPropertyName("score")] double? Score = null,
[property: JsonPropertyName("method")] string? Method = null);
// CLI-VEX-30-002: VEX consensus detail models
/// <summary>
/// Detailed VEX consensus response including quorum, evidence, rationale, and signature status.
/// </summary>
internal sealed record VexConsensusDetailResponse(
[property: JsonPropertyName("vulnerabilityId")] string VulnerabilityId,
[property: JsonPropertyName("product")] VexProductInfo Product,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("calculatedAt")] DateTimeOffset CalculatedAt,
[property: JsonPropertyName("sources")] IReadOnlyList<VexConsensusSourceInfo> Sources,
[property: JsonPropertyName("conflicts")] IReadOnlyList<VexConsensusConflictInfo>? Conflicts = null,
[property: JsonPropertyName("policyVersion")] string? PolicyVersion = null,
[property: JsonPropertyName("policyDigest")] string? PolicyDigest = null,
[property: JsonPropertyName("summary")] string? Summary = null,
[property: JsonPropertyName("quorum")] VexQuorumInfo? Quorum = null,
[property: JsonPropertyName("rationale")] VexRationaleInfo? Rationale = null,
[property: JsonPropertyName("signature")] VexSignatureInfo? Signature = null,
[property: JsonPropertyName("evidence")] IReadOnlyList<VexEvidenceInfo>? Evidence = null);
/// <summary>
/// VEX quorum information showing how consensus was reached.
/// </summary>
internal sealed record VexQuorumInfo(
[property: JsonPropertyName("required")] int Required,
[property: JsonPropertyName("achieved")] int Achieved,
[property: JsonPropertyName("threshold")] double Threshold,
[property: JsonPropertyName("totalWeight")] double TotalWeight,
[property: JsonPropertyName("weightAchieved")] double WeightAchieved,
[property: JsonPropertyName("participatingProviders")] IReadOnlyList<string>? ParticipatingProviders = null);
/// <summary>
/// VEX rationale explaining the consensus decision.
/// </summary>
internal sealed record VexRationaleInfo(
[property: JsonPropertyName("text")] string? Text = null,
[property: JsonPropertyName("justifications")] IReadOnlyList<string>? Justifications = null,
[property: JsonPropertyName("policyRules")] IReadOnlyList<string>? PolicyRules = null);
/// <summary>
/// VEX signature status information.
/// </summary>
internal sealed record VexSignatureInfo(
[property: JsonPropertyName("signed")] bool Signed,
[property: JsonPropertyName("algorithm")] string? Algorithm = null,
[property: JsonPropertyName("keyId")] string? KeyId = null,
[property: JsonPropertyName("signedAt")] DateTimeOffset? SignedAt = null,
[property: JsonPropertyName("verificationStatus")] string? VerificationStatus = null,
[property: JsonPropertyName("certificateChain")] IReadOnlyList<string>? CertificateChain = null);
/// <summary>
/// VEX evidence supporting the consensus decision.
/// </summary>
internal sealed record VexEvidenceInfo(
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("providerId")] string ProviderId,
[property: JsonPropertyName("documentId")] string? DocumentId = null,
[property: JsonPropertyName("documentDigest")] string? DocumentDigest = null,
[property: JsonPropertyName("timestamp")] DateTimeOffset? Timestamp = null,
[property: JsonPropertyName("content")] string? Content = null);
// CLI-VEX-30-003: VEX simulation models
/// <summary>
/// VEX simulation request with trust/threshold overrides.
/// </summary>
internal sealed record VexSimulationRequest(
[property: JsonPropertyName("vulnerabilityId")] string? VulnerabilityId = null,
[property: JsonPropertyName("productKey")] string? ProductKey = null,
[property: JsonPropertyName("purl")] string? Purl = null,
[property: JsonPropertyName("trustOverrides")] IReadOnlyDictionary<string, double>? TrustOverrides = null,
[property: JsonPropertyName("thresholdOverride")] double? ThresholdOverride = null,
[property: JsonPropertyName("quorumOverride")] int? QuorumOverride = null,
[property: JsonPropertyName("excludeProviders")] IReadOnlyList<string>? ExcludeProviders = null,
[property: JsonPropertyName("includeOnly")] IReadOnlyList<string>? IncludeOnly = null);
/// <summary>
/// VEX simulation response showing before/after comparison.
/// </summary>
internal sealed record VexSimulationResponse(
[property: JsonPropertyName("items")] IReadOnlyList<VexSimulationResultItem> Items,
[property: JsonPropertyName("parameters")] VexSimulationParameters Parameters,
[property: JsonPropertyName("summary")] VexSimulationSummary Summary);
/// <summary>
/// Individual VEX simulation result showing the delta.
/// </summary>
internal sealed record VexSimulationResultItem(
[property: JsonPropertyName("vulnerabilityId")] string VulnerabilityId,
[property: JsonPropertyName("product")] VexProductInfo Product,
[property: JsonPropertyName("before")] VexSimulationState Before,
[property: JsonPropertyName("after")] VexSimulationState After,
[property: JsonPropertyName("changed")] bool Changed,
[property: JsonPropertyName("changeType")] string? ChangeType = null);
/// <summary>
/// VEX state for simulation comparison.
/// </summary>
internal sealed record VexSimulationState(
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("quorumAchieved")] int QuorumAchieved,
[property: JsonPropertyName("weightAchieved")] double WeightAchieved,
[property: JsonPropertyName("sources")] IReadOnlyList<string>? Sources = null);
/// <summary>
/// Parameters used in the simulation.
/// </summary>
internal sealed record VexSimulationParameters(
[property: JsonPropertyName("threshold")] double Threshold,
[property: JsonPropertyName("quorum")] int Quorum,
[property: JsonPropertyName("trustWeights")] IReadOnlyDictionary<string, double>? TrustWeights = null,
[property: JsonPropertyName("excludedProviders")] IReadOnlyList<string>? ExcludedProviders = null);
/// <summary>
/// Summary of simulation results.
/// </summary>
internal sealed record VexSimulationSummary(
[property: JsonPropertyName("totalEvaluated")] int TotalEvaluated,
[property: JsonPropertyName("totalChanged")] int TotalChanged,
[property: JsonPropertyName("statusUpgrades")] int StatusUpgrades,
[property: JsonPropertyName("statusDowngrades")] int StatusDowngrades,
[property: JsonPropertyName("noChange")] int NoChange);
// CLI-VEX-30-004: VEX export models
/// <summary>
/// VEX export request parameters.
/// </summary>
internal sealed record VexExportRequest(
[property: JsonPropertyName("vulnerabilityIds")] IReadOnlyList<string>? VulnerabilityIds = null,
[property: JsonPropertyName("productKeys")] IReadOnlyList<string>? ProductKeys = null,
[property: JsonPropertyName("purls")] IReadOnlyList<string>? Purls = null,
[property: JsonPropertyName("statuses")] IReadOnlyList<string>? Statuses = null,
[property: JsonPropertyName("policyVersion")] string? PolicyVersion = null,
[property: JsonPropertyName("signed")] bool Signed = true,
[property: JsonPropertyName("format")] string Format = "ndjson");
/// <summary>
/// VEX export response with download information.
/// </summary>
internal sealed record VexExportResponse(
[property: JsonPropertyName("exportId")] string ExportId,
[property: JsonPropertyName("downloadUrl")] string? DownloadUrl = null,
[property: JsonPropertyName("format")] string Format = "ndjson",
[property: JsonPropertyName("itemCount")] int ItemCount = 0,
[property: JsonPropertyName("signed")] bool Signed = false,
[property: JsonPropertyName("signatureAlgorithm")] string? SignatureAlgorithm = null,
[property: JsonPropertyName("signatureKeyId")] string? SignatureKeyId = null,
[property: JsonPropertyName("digest")] string? Digest = null,
[property: JsonPropertyName("digestAlgorithm")] string? DigestAlgorithm = null,
[property: JsonPropertyName("expiresAt")] DateTimeOffset? ExpiresAt = null);
/// <summary>
/// VEX export signature verification request.
/// </summary>
internal sealed record VexExportVerifyRequest(
[property: JsonPropertyName("filePath")] string FilePath,
[property: JsonPropertyName("signaturePath")] string? SignaturePath = null,
[property: JsonPropertyName("expectedDigest")] string? ExpectedDigest = null,
[property: JsonPropertyName("publicKeyPath")] string? PublicKeyPath = null);
/// <summary>
/// VEX export signature verification result.
/// </summary>
internal sealed record VexExportVerifyResult(
[property: JsonPropertyName("valid")] bool Valid,
[property: JsonPropertyName("signatureStatus")] string SignatureStatus,
[property: JsonPropertyName("digestMatch")] bool? DigestMatch = null,
[property: JsonPropertyName("actualDigest")] string? ActualDigest = null,
[property: JsonPropertyName("expectedDigest")] string? ExpectedDigest = null,
[property: JsonPropertyName("keyId")] string? KeyId = null,
[property: JsonPropertyName("signedAt")] DateTimeOffset? SignedAt = null,
[property: JsonPropertyName("errors")] IReadOnlyList<string>? Errors = null);

View File

@@ -0,0 +1,291 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
// CLI-VULN-29-001: Vulnerability Explorer models for CLI
/// <summary>
/// Vulnerability list request parameters.
/// </summary>
internal sealed record VulnListRequest(
[property: JsonPropertyName("vulnerabilityId")] string? VulnerabilityId = null,
[property: JsonPropertyName("severity")] string? Severity = null,
[property: JsonPropertyName("status")] string? Status = null,
[property: JsonPropertyName("purl")] string? Purl = null,
[property: JsonPropertyName("cpe")] string? Cpe = null,
[property: JsonPropertyName("sbomId")] string? SbomId = null,
[property: JsonPropertyName("policyId")] string? PolicyId = null,
[property: JsonPropertyName("policyVersion")] int? PolicyVersion = null,
[property: JsonPropertyName("groupBy")] string? GroupBy = null,
[property: JsonPropertyName("limit")] int? Limit = null,
[property: JsonPropertyName("offset")] int? Offset = null,
[property: JsonPropertyName("cursor")] string? Cursor = null);
/// <summary>
/// Paginated vulnerability list response.
/// </summary>
internal sealed record VulnListResponse(
[property: JsonPropertyName("items")] IReadOnlyList<VulnItem> Items,
[property: JsonPropertyName("total")] int Total,
[property: JsonPropertyName("limit")] int Limit,
[property: JsonPropertyName("offset")] int Offset,
[property: JsonPropertyName("hasMore")] bool HasMore,
[property: JsonPropertyName("nextCursor")] string? NextCursor = null,
[property: JsonPropertyName("grouping")] VulnGroupingInfo? Grouping = null);
/// <summary>
/// Individual vulnerability item from the explorer.
/// </summary>
internal sealed record VulnItem(
[property: JsonPropertyName("vulnerabilityId")] string VulnerabilityId,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("severity")] VulnSeverityInfo Severity,
[property: JsonPropertyName("affectedPackages")] IReadOnlyList<VulnAffectedPackage> AffectedPackages,
[property: JsonPropertyName("vexStatus")] string? VexStatus = null,
[property: JsonPropertyName("policyFindingId")] string? PolicyFindingId = null,
[property: JsonPropertyName("aliases")] IReadOnlyList<string>? Aliases = null,
[property: JsonPropertyName("summary")] string? Summary = null,
[property: JsonPropertyName("publishedAt")] DateTimeOffset? PublishedAt = null,
[property: JsonPropertyName("updatedAt")] DateTimeOffset? UpdatedAt = null,
[property: JsonPropertyName("assignee")] string? Assignee = null,
[property: JsonPropertyName("dueDate")] DateTimeOffset? DueDate = null,
[property: JsonPropertyName("tags")] IReadOnlyList<string>? Tags = null);
/// <summary>
/// Vulnerability severity information.
/// </summary>
internal sealed record VulnSeverityInfo(
[property: JsonPropertyName("level")] string Level,
[property: JsonPropertyName("score")] double? Score = null,
[property: JsonPropertyName("vector")] string? Vector = null,
[property: JsonPropertyName("source")] string? Source = null);
/// <summary>
/// Affected package information.
/// </summary>
internal sealed record VulnAffectedPackage(
[property: JsonPropertyName("purl")] string? Purl = null,
[property: JsonPropertyName("cpe")] string? Cpe = null,
[property: JsonPropertyName("name")] string? Name = null,
[property: JsonPropertyName("version")] string? Version = null,
[property: JsonPropertyName("fixedIn")] string? FixedIn = null,
[property: JsonPropertyName("sbomId")] string? SbomId = null,
[property: JsonPropertyName("pathCount")] int? PathCount = null);
/// <summary>
/// Grouping information for aggregated results.
/// </summary>
internal sealed record VulnGroupingInfo(
[property: JsonPropertyName("field")] string Field,
[property: JsonPropertyName("groups")] IReadOnlyList<VulnGroup> Groups);
/// <summary>
/// A group in aggregated results.
/// </summary>
internal sealed record VulnGroup(
[property: JsonPropertyName("key")] string Key,
[property: JsonPropertyName("count")] int Count,
[property: JsonPropertyName("criticalCount")] int? CriticalCount = null,
[property: JsonPropertyName("highCount")] int? HighCount = null,
[property: JsonPropertyName("mediumCount")] int? MediumCount = null,
[property: JsonPropertyName("lowCount")] int? LowCount = null);
// CLI-VULN-29-002: Vulnerability detail models
/// <summary>
/// Detailed vulnerability response including evidence, rationale, paths, and ledger.
/// </summary>
internal sealed record VulnDetailResponse(
[property: JsonPropertyName("vulnerabilityId")] string VulnerabilityId,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("severity")] VulnSeverityInfo Severity,
[property: JsonPropertyName("affectedPackages")] IReadOnlyList<VulnAffectedPackage> AffectedPackages,
[property: JsonPropertyName("vexStatus")] string? VexStatus = null,
[property: JsonPropertyName("policyFindingId")] string? PolicyFindingId = null,
[property: JsonPropertyName("aliases")] IReadOnlyList<string>? Aliases = null,
[property: JsonPropertyName("summary")] string? Summary = null,
[property: JsonPropertyName("description")] string? Description = null,
[property: JsonPropertyName("publishedAt")] DateTimeOffset? PublishedAt = null,
[property: JsonPropertyName("updatedAt")] DateTimeOffset? UpdatedAt = null,
[property: JsonPropertyName("assignee")] string? Assignee = null,
[property: JsonPropertyName("dueDate")] DateTimeOffset? DueDate = null,
[property: JsonPropertyName("tags")] IReadOnlyList<string>? Tags = null,
[property: JsonPropertyName("evidence")] IReadOnlyList<VulnEvidenceInfo>? Evidence = null,
[property: JsonPropertyName("policyRationale")] VulnPolicyRationale? PolicyRationale = null,
[property: JsonPropertyName("dependencyPaths")] IReadOnlyList<VulnDependencyPath>? DependencyPaths = null,
[property: JsonPropertyName("ledger")] IReadOnlyList<VulnLedgerEntry>? Ledger = null,
[property: JsonPropertyName("references")] IReadOnlyList<VulnReference>? References = null);
/// <summary>
/// Evidence supporting the vulnerability assessment.
/// </summary>
internal sealed record VulnEvidenceInfo(
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("source")] string Source,
[property: JsonPropertyName("documentId")] string? DocumentId = null,
[property: JsonPropertyName("documentDigest")] string? DocumentDigest = null,
[property: JsonPropertyName("timestamp")] DateTimeOffset? Timestamp = null,
[property: JsonPropertyName("content")] string? Content = null);
/// <summary>
/// Policy rationale explaining the status decision.
/// </summary>
internal sealed record VulnPolicyRationale(
[property: JsonPropertyName("policyId")] string PolicyId,
[property: JsonPropertyName("policyVersion")] int PolicyVersion,
[property: JsonPropertyName("rules")] IReadOnlyList<VulnPolicyRuleResult>? Rules = null,
[property: JsonPropertyName("summary")] string? Summary = null);
/// <summary>
/// Result of a policy rule evaluation.
/// </summary>
internal sealed record VulnPolicyRuleResult(
[property: JsonPropertyName("rule")] string Rule,
[property: JsonPropertyName("result")] string Result,
[property: JsonPropertyName("weight")] double? Weight = null,
[property: JsonPropertyName("reason")] string? Reason = null);
/// <summary>
/// Dependency path showing how the vulnerable package is included.
/// </summary>
internal sealed record VulnDependencyPath(
[property: JsonPropertyName("path")] IReadOnlyList<string> Path,
[property: JsonPropertyName("sbomId")] string? SbomId = null,
[property: JsonPropertyName("depth")] int? Depth = null);
/// <summary>
/// Ledger entry tracking vulnerability workflow history.
/// </summary>
internal sealed record VulnLedgerEntry(
[property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp,
[property: JsonPropertyName("action")] string Action,
[property: JsonPropertyName("actor")] string? Actor = null,
[property: JsonPropertyName("fromStatus")] string? FromStatus = null,
[property: JsonPropertyName("toStatus")] string? ToStatus = null,
[property: JsonPropertyName("comment")] string? Comment = null,
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string>? Metadata = null);
/// <summary>
/// Reference link for the vulnerability.
/// </summary>
internal sealed record VulnReference(
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("url")] string Url,
[property: JsonPropertyName("title")] string? Title = null);
// CLI-VULN-29-003: Vulnerability workflow models
/// <summary>
/// Workflow action request for vulnerability operations.
/// </summary>
internal sealed record VulnWorkflowRequest(
[property: JsonPropertyName("action")] string Action,
[property: JsonPropertyName("vulnerabilityIds")] IReadOnlyList<string>? VulnerabilityIds = null,
[property: JsonPropertyName("filter")] VulnFilterSpec? Filter = null,
[property: JsonPropertyName("assignee")] string? Assignee = null,
[property: JsonPropertyName("comment")] string? Comment = null,
[property: JsonPropertyName("dueDate")] DateTimeOffset? DueDate = null,
[property: JsonPropertyName("justification")] string? Justification = null,
[property: JsonPropertyName("fixVersion")] string? FixVersion = null,
[property: JsonPropertyName("idempotencyKey")] string? IdempotencyKey = null);
/// <summary>
/// Filter specification for bulk workflow operations.
/// </summary>
internal sealed record VulnFilterSpec(
[property: JsonPropertyName("severity")] string? Severity = null,
[property: JsonPropertyName("status")] string? Status = null,
[property: JsonPropertyName("purl")] string? Purl = null,
[property: JsonPropertyName("sbomId")] string? SbomId = null,
[property: JsonPropertyName("policyId")] string? PolicyId = null);
/// <summary>
/// Workflow action response with affected items.
/// </summary>
internal sealed record VulnWorkflowResponse(
[property: JsonPropertyName("success")] bool Success,
[property: JsonPropertyName("action")] string Action,
[property: JsonPropertyName("affectedCount")] int AffectedCount,
[property: JsonPropertyName("affectedIds")] IReadOnlyList<string>? AffectedIds = null,
[property: JsonPropertyName("errors")] IReadOnlyList<VulnWorkflowError>? Errors = null,
[property: JsonPropertyName("idempotencyKey")] string? IdempotencyKey = null);
/// <summary>
/// Error detail for workflow operations.
/// </summary>
internal sealed record VulnWorkflowError(
[property: JsonPropertyName("vulnerabilityId")] string VulnerabilityId,
[property: JsonPropertyName("code")] string Code,
[property: JsonPropertyName("message")] string Message);
// CLI-VULN-29-004: Vulnerability simulation models
/// <summary>
/// Simulation request for policy/VEX changes.
/// </summary>
internal sealed record VulnSimulationRequest(
[property: JsonPropertyName("policyId")] string? PolicyId = null,
[property: JsonPropertyName("policyVersion")] int? PolicyVersion = null,
[property: JsonPropertyName("vexOverrides")] IReadOnlyDictionary<string, string>? VexOverrides = null,
[property: JsonPropertyName("severityThreshold")] string? SeverityThreshold = null,
[property: JsonPropertyName("sbomIds")] IReadOnlyList<string>? SbomIds = null,
[property: JsonPropertyName("outputMarkdown")] bool OutputMarkdown = false);
/// <summary>
/// Simulation response showing deltas.
/// </summary>
internal sealed record VulnSimulationResponse(
[property: JsonPropertyName("items")] IReadOnlyList<VulnSimulationDelta> Items,
[property: JsonPropertyName("summary")] VulnSimulationSummary Summary,
[property: JsonPropertyName("markdownReport")] string? MarkdownReport = null);
/// <summary>
/// Individual delta in simulation results.
/// </summary>
internal sealed record VulnSimulationDelta(
[property: JsonPropertyName("vulnerabilityId")] string VulnerabilityId,
[property: JsonPropertyName("beforeStatus")] string BeforeStatus,
[property: JsonPropertyName("afterStatus")] string AfterStatus,
[property: JsonPropertyName("changed")] bool Changed,
[property: JsonPropertyName("changeReason")] string? ChangeReason = null);
/// <summary>
/// Summary of simulation results.
/// </summary>
internal sealed record VulnSimulationSummary(
[property: JsonPropertyName("totalEvaluated")] int TotalEvaluated,
[property: JsonPropertyName("totalChanged")] int TotalChanged,
[property: JsonPropertyName("statusUpgrades")] int StatusUpgrades,
[property: JsonPropertyName("statusDowngrades")] int StatusDowngrades,
[property: JsonPropertyName("noChange")] int NoChange);
// CLI-VULN-29-005: Vulnerability export models
/// <summary>
/// Export request for vulnerability evidence bundles.
/// </summary>
internal sealed record VulnExportRequest(
[property: JsonPropertyName("vulnerabilityIds")] IReadOnlyList<string>? VulnerabilityIds = null,
[property: JsonPropertyName("sbomIds")] IReadOnlyList<string>? SbomIds = null,
[property: JsonPropertyName("policyId")] string? PolicyId = null,
[property: JsonPropertyName("format")] string Format = "ndjson",
[property: JsonPropertyName("includeEvidence")] bool IncludeEvidence = true,
[property: JsonPropertyName("includeLedger")] bool IncludeLedger = true,
[property: JsonPropertyName("signed")] bool Signed = true);
/// <summary>
/// Export response with download information.
/// </summary>
internal sealed record VulnExportResponse(
[property: JsonPropertyName("exportId")] string ExportId,
[property: JsonPropertyName("downloadUrl")] string? DownloadUrl = null,
[property: JsonPropertyName("format")] string Format = "ndjson",
[property: JsonPropertyName("itemCount")] int ItemCount = 0,
[property: JsonPropertyName("signed")] bool Signed = false,
[property: JsonPropertyName("signatureAlgorithm")] string? SignatureAlgorithm = null,
[property: JsonPropertyName("signatureKeyId")] string? SignatureKeyId = null,
[property: JsonPropertyName("digest")] string? Digest = null,
[property: JsonPropertyName("digestAlgorithm")] string? DigestAlgorithm = null,
[property: JsonPropertyName("expiresAt")] DateTimeOffset? ExpiresAt = null);

View File

@@ -27,6 +27,7 @@ internal static class CliMetrics
private static readonly Counter<long> RubyInspectCounter = Meter.CreateCounter<long>("stellaops.cli.ruby.inspect.count");
private static readonly Counter<long> RubyResolveCounter = Meter.CreateCounter<long>("stellaops.cli.ruby.resolve.count");
private static readonly Counter<long> PhpInspectCounter = Meter.CreateCounter<long>("stellaops.cli.php.inspect.count");
private static readonly Counter<long> PythonInspectCounter = Meter.CreateCounter<long>("stellaops.cli.python.inspect.count");
private static readonly Histogram<double> CommandDurationHistogram = Meter.CreateHistogram<double>("stellaops.cli.command.duration.ms");
public static void RecordScannerDownload(string channel, bool fromCache)
@@ -150,6 +151,12 @@ internal static class CliMetrics
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
});
public static void RecordPythonInspect(string outcome)
=> PythonInspectCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
});
public static IDisposable MeasureCommandDuration(string command)
{
var start = DateTime.UtcNow;

View File

@@ -3284,6 +3284,20 @@ private readonly record struct LinksetObservationSummary(
static async Task InitializeMongoAsync(WebApplication app)
{
// Skip Mongo initialization in testing/bypass mode.
var isTesting = string.Equals(
Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"),
"Testing",
StringComparison.OrdinalIgnoreCase);
var bypass = string.Equals(
Environment.GetEnvironmentVariable("CONCELIER_BYPASS_MONGO"),
"1",
StringComparison.OrdinalIgnoreCase);
if (isTesting || bypass)
{
return;
}
await using var scope = app.Services.CreateAsyncScope();
var bootstrapper = scope.ServiceProvider.GetRequiredService<MongoBootstrapper>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("MongoBootstrapper");

View File

@@ -10,16 +10,20 @@ namespace StellaOps.Concelier.Core.Linksets;
/// <summary>
/// Contract-matching payload for <c>advisory.linkset.updated@1</c> events.
/// Per LNM-21-005, emits delta descriptions + observation ids (tenant + provenance only).
/// Enhanced per CONCELIER-POLICY-23-002 with idempotent IDs, confidence summaries, and tenant metadata.
/// </summary>
public sealed record AdvisoryLinksetUpdatedEvent(
Guid EventId,
string IdempotencyKey,
string TenantId,
AdvisoryLinksetTenantMetadata TenantMetadata,
string LinksetId,
string AdvisoryId,
string Source,
ImmutableArray<string> ObservationIds,
AdvisoryLinksetDelta Delta,
double? Confidence,
AdvisoryLinksetConfidenceSummary ConfidenceSummary,
ImmutableArray<AdvisoryLinksetConflictSummary> Conflicts,
AdvisoryLinksetProvenanceSummary Provenance,
DateTimeOffset CreatedAt,
@@ -43,16 +47,22 @@ public sealed record AdvisoryLinksetUpdatedEvent(
var delta = ComputeDelta(linkset, previousLinkset);
var conflicts = BuildConflictSummaries(linkset.Conflicts);
var provenance = BuildProvenance(linkset.Provenance);
var tenantMetadata = BuildTenantMetadata(linkset.TenantId, tenantUrn);
var confidenceSummary = BuildConfidenceSummary(linkset.Confidence, conflicts.Length);
var idempotencyKey = ComputeIdempotencyKey(linksetId, linkset, delta);
return new AdvisoryLinksetUpdatedEvent(
EventId: Guid.NewGuid(),
IdempotencyKey: idempotencyKey,
TenantId: tenantUrn,
TenantMetadata: tenantMetadata,
LinksetId: linksetId,
AdvisoryId: linkset.AdvisoryId,
Source: linkset.Source,
ObservationIds: linkset.ObservationIds,
Delta: delta,
Confidence: linkset.Confidence,
ConfidenceSummary: confidenceSummary,
Conflicts: conflicts,
Provenance: provenance,
CreatedAt: linkset.CreatedAt,
@@ -61,6 +71,139 @@ public sealed record AdvisoryLinksetUpdatedEvent(
TraceId: traceId);
}
/// <summary>
/// Computes a deterministic idempotency key for safe replay.
/// The key is derived from linkset identity + content hash so replaying the same change yields the same key.
/// </summary>
private static string ComputeIdempotencyKey(string linksetId, AdvisoryLinkset linkset, AdvisoryLinksetDelta delta)
{
var sb = new StringBuilder(256);
sb.Append(linksetId);
sb.Append('|');
sb.Append(linkset.TenantId);
sb.Append('|');
sb.Append(linkset.AdvisoryId);
sb.Append('|');
sb.Append(linkset.Source);
sb.Append('|');
sb.Append(linkset.CreatedAt.ToUniversalTime().Ticks);
sb.Append('|');
sb.Append(delta.Type);
sb.Append('|');
// Include observation IDs in sorted order for determinism
foreach (var obsId in linkset.ObservationIds.OrderBy(id => id, StringComparer.Ordinal))
{
sb.Append(obsId);
sb.Append(',');
}
// Include provenance hash if available
if (linkset.Provenance?.PolicyHash is not null)
{
sb.Append('|');
sb.Append(linkset.Provenance.PolicyHash);
}
var input = Encoding.UTF8.GetBytes(sb.ToString());
var hash = SHA256.HashData(input);
return Convert.ToHexString(hash).ToLowerInvariant();
}
/// <summary>
/// Builds tenant metadata for policy consumers.
/// </summary>
private static AdvisoryLinksetTenantMetadata BuildTenantMetadata(string tenantId, string tenantUrn)
{
// Extract tenant identifier from URN if present
var rawId = tenantUrn.StartsWith("urn:tenant:", StringComparison.Ordinal)
? tenantUrn["urn:tenant:".Length..]
: tenantId;
return new AdvisoryLinksetTenantMetadata(
TenantUrn: tenantUrn,
TenantId: rawId,
Namespace: ExtractNamespace(rawId));
}
/// <summary>
/// Extracts namespace prefix from tenant ID (e.g., "org:acme" → "org").
/// </summary>
private static string? ExtractNamespace(string tenantId)
{
var colonIndex = tenantId.IndexOf(':');
return colonIndex > 0 ? tenantId[..colonIndex] : null;
}
/// <summary>
/// Builds confidence summary with tier classification and contributing factors.
/// </summary>
private static AdvisoryLinksetConfidenceSummary BuildConfidenceSummary(double? confidence, int conflictCount)
{
var tier = ClassifyConfidenceTier(confidence);
var factors = BuildConfidenceFactors(confidence, conflictCount);
return new AdvisoryLinksetConfidenceSummary(
Value: confidence,
Tier: tier,
ConflictCount: conflictCount,
Factors: factors);
}
/// <summary>
/// Classifies confidence into tiers for policy rules.
/// </summary>
private static string ClassifyConfidenceTier(double? confidence) => confidence switch
{
null => "unknown",
>= 0.9 => "high",
>= 0.7 => "medium",
>= 0.5 => "low",
_ => "very-low"
};
/// <summary>
/// Builds human-readable factors contributing to confidence score.
/// </summary>
private static ImmutableArray<string> BuildConfidenceFactors(double? confidence, int conflictCount)
{
var factors = ImmutableArray.CreateBuilder<string>();
if (confidence is null)
{
factors.Add("no-confidence-data");
return factors.ToImmutable();
}
if (confidence >= 0.9)
{
factors.Add("strong-alias-correlation");
}
else if (confidence >= 0.7)
{
factors.Add("moderate-alias-correlation");
}
else if (confidence >= 0.5)
{
factors.Add("weak-alias-correlation");
}
else
{
factors.Add("minimal-correlation");
}
if (conflictCount > 0)
{
factors.Add($"has-{conflictCount}-conflict{(conflictCount > 1 ? "s" : "")}");
}
else
{
factors.Add("no-conflicts");
}
return factors.ToImmutable();
}
private static AdvisoryLinksetDelta ComputeDelta(AdvisoryLinkset current, AdvisoryLinkset? previous)
{
if (previous is null)
@@ -166,3 +309,26 @@ public sealed record AdvisoryLinksetProvenanceSummary(
ImmutableArray<string> ObservationHashes,
string? ToolVersion,
string? PolicyHash);
/// <summary>
/// Tenant metadata for policy replay and multi-tenant filtering.
/// Per CONCELIER-POLICY-23-002.
/// </summary>
public sealed record AdvisoryLinksetTenantMetadata(
string TenantUrn,
string TenantId,
string? Namespace);
/// <summary>
/// Confidence summary with tier classification for policy rules.
/// Per CONCELIER-POLICY-23-002.
/// </summary>
/// <param name="Value">Raw confidence score (0.0 - 1.0).</param>
/// <param name="Tier">Confidence tier: high (≥0.9), medium (≥0.7), low (≥0.5), very-low (&lt;0.5), unknown (null).</param>
/// <param name="ConflictCount">Number of conflicts detected in the linkset.</param>
/// <param name="Factors">Human-readable factors contributing to confidence score.</param>
public sealed record AdvisoryLinksetConfidenceSummary(
double? Value,
string Tier,
int ConflictCount,
ImmutableArray<string> Factors);

View File

@@ -0,0 +1,48 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Concelier.Core.Linksets;
/// <summary>
/// Stores and retrieves policy delta checkpoints for deterministic replay.
/// Consumers use checkpoints to track their position in the linkset stream.
/// </summary>
public interface IPolicyDeltaCheckpointStore
{
/// <summary>
/// Gets a checkpoint by consumer and tenant, creating one if it does not exist.
/// </summary>
Task<PolicyDeltaCheckpoint> GetOrCreateAsync(
string tenantId,
string consumerId,
CancellationToken cancellationToken);
/// <summary>
/// Gets a checkpoint by its unique ID.
/// </summary>
Task<PolicyDeltaCheckpoint?> GetAsync(
string checkpointId,
CancellationToken cancellationToken);
/// <summary>
/// Updates a checkpoint after processing a batch of linksets.
/// </summary>
Task<PolicyDeltaCheckpoint> UpdateAsync(
PolicyDeltaCheckpoint checkpoint,
CancellationToken cancellationToken);
/// <summary>
/// Lists all checkpoints for a given tenant.
/// </summary>
Task<IReadOnlyList<PolicyDeltaCheckpoint>> ListByTenantAsync(
string tenantId,
CancellationToken cancellationToken);
/// <summary>
/// Deletes a checkpoint (for cleanup or reset scenarios).
/// </summary>
Task<bool> DeleteAsync(
string checkpointId,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,86 @@
using System;
namespace StellaOps.Concelier.Core.Linksets;
/// <summary>
/// Represents a checkpoint for tracking policy delta consumption.
/// Enables deterministic replay by persisting the last processed position.
/// </summary>
public sealed record PolicyDeltaCheckpoint(
/// <summary>Unique identifier for this checkpoint (typically consumerId + tenant).</summary>
string CheckpointId,
/// <summary>Tenant scope for this checkpoint.</summary>
string TenantId,
/// <summary>Consumer identifier (e.g., "policy-engine", "vuln-explorer").</summary>
string ConsumerId,
/// <summary>Last processed linkset CreatedAt timestamp for cursor-based pagination.</summary>
DateTimeOffset? LastCreatedAt,
/// <summary>Last processed advisory ID (tie-breaker when CreatedAt matches).</summary>
string? LastAdvisoryId,
/// <summary>MongoDB change-stream resume token for real-time delta subscriptions.</summary>
string? ResumeToken,
/// <summary>Sequence number for ordering events within the same timestamp.</summary>
long SequenceNumber,
/// <summary>When this checkpoint was last updated.</summary>
DateTimeOffset UpdatedAt,
/// <summary>Count of linksets processed since checkpoint creation.</summary>
long ProcessedCount,
/// <summary>Hash of the last processed batch for integrity verification.</summary>
string? LastBatchHash)
{
public static PolicyDeltaCheckpoint CreateNew(string tenantId, string consumerId, DateTimeOffset now) =>
new(
CheckpointId: $"{consumerId}:{tenantId}",
TenantId: tenantId,
ConsumerId: consumerId,
LastCreatedAt: null,
LastAdvisoryId: null,
ResumeToken: null,
SequenceNumber: 0,
UpdatedAt: now,
ProcessedCount: 0,
LastBatchHash: null);
/// <summary>
/// Creates an <see cref="AdvisoryLinksetCursor"/> from this checkpoint for pagination.
/// Returns null if no position has been recorded yet.
/// </summary>
public AdvisoryLinksetCursor? ToCursor() =>
LastCreatedAt.HasValue && !string.IsNullOrEmpty(LastAdvisoryId)
? new AdvisoryLinksetCursor(LastCreatedAt.Value, LastAdvisoryId)
: null;
/// <summary>
/// Advances the checkpoint to a new position after processing a batch.
/// </summary>
public PolicyDeltaCheckpoint Advance(
DateTimeOffset lastCreatedAt,
string lastAdvisoryId,
long batchCount,
string? batchHash,
DateTimeOffset now) =>
this with
{
LastCreatedAt = lastCreatedAt,
LastAdvisoryId = lastAdvisoryId,
SequenceNumber = SequenceNumber + batchCount,
UpdatedAt = now,
ProcessedCount = ProcessedCount + batchCount,
LastBatchHash = batchHash
};
/// <summary>
/// Updates the resume token for change-stream subscriptions.
/// </summary>
public PolicyDeltaCheckpoint WithResumeToken(string resumeToken, DateTimeOffset now) =>
this with { ResumeToken = resumeToken, UpdatedAt = now };
}

View File

@@ -0,0 +1,111 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Concelier.Core.Risk;
/// <summary>
/// Provider interface for extracting vendor risk signals from observations.
/// Per CONCELIER-RISK-66-001, surfaces fact-only CVSS/KEV/fix data with provenance.
/// </summary>
public interface IVendorRiskSignalProvider
{
/// <summary>
/// Extracts risk signals from a specific observation.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="observationId">Observation identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Risk signal with CVSS, KEV, and fix data.</returns>
Task<VendorRiskSignal?> GetByObservationAsync(
string tenantId,
string observationId,
CancellationToken cancellationToken);
/// <summary>
/// Extracts risk signals from all observations for an advisory.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="advisoryId">Advisory identifier (e.g., CVE-2024-1234).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Collection of risk signals from all vendor observations.</returns>
Task<IReadOnlyList<VendorRiskSignal>> GetByAdvisoryAsync(
string tenantId,
string advisoryId,
CancellationToken cancellationToken);
/// <summary>
/// Extracts aggregated risk signals for a linkset.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="linksetId">Linkset identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Collection of risk signals from linked observations.</returns>
Task<IReadOnlyList<VendorRiskSignal>> GetByLinksetAsync(
string tenantId,
string linksetId,
CancellationToken cancellationToken);
}
/// <summary>
/// Aggregated risk signal view combining multiple vendor observations.
/// </summary>
public sealed record AggregatedRiskView(
string TenantId,
string AdvisoryId,
IReadOnlyList<VendorRiskSignal> VendorSignals)
{
/// <summary>
/// Gets all unique CVSS scores across vendors with their provenance.
/// </summary>
public IReadOnlyList<VendorCvssScore> AllCvssScores =>
VendorSignals
.SelectMany(s => s.CvssScores)
.OrderByDescending(c => c.Score)
.ToList();
/// <summary>
/// Gets the highest CVSS score from any vendor.
/// </summary>
public VendorCvssScore? HighestCvssScore =>
AllCvssScores.FirstOrDefault();
/// <summary>
/// Indicates if any vendor reports KEV status.
/// </summary>
public bool IsKnownExploited =>
VendorSignals.Any(s => s.IsKnownExploited);
/// <summary>
/// Gets all KEV status entries from vendors.
/// </summary>
public IReadOnlyList<VendorKevStatus> KevStatuses =>
VendorSignals
.Where(s => s.KevStatus is not null)
.Select(s => s.KevStatus!)
.ToList();
/// <summary>
/// Indicates if any vendor reports a fix available.
/// </summary>
public bool HasFixAvailable =>
VendorSignals.Any(s => s.HasFixAvailable);
/// <summary>
/// Gets all fix availability entries from vendors.
/// </summary>
public IReadOnlyList<VendorFixAvailability> AllFixAvailability =>
VendorSignals
.SelectMany(s => s.FixAvailability)
.ToList();
/// <summary>
/// Gets vendors that provided risk data.
/// </summary>
public IReadOnlyList<string> ContributingVendors =>
VendorSignals
.Select(s => s.Provenance.Vendor)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(v => v, StringComparer.OrdinalIgnoreCase)
.ToList();
}

View File

@@ -0,0 +1,169 @@
using System;
using System.Collections.Immutable;
namespace StellaOps.Concelier.Core.Risk;
/// <summary>
/// Vendor-provided risk signal for an advisory observation.
/// Per CONCELIER-RISK-66-001, surfaces CVSS/KEV/fix data exactly as published with provenance anchors.
/// </summary>
/// <remarks>
/// This model is fact-only: no inference, weighting, or prioritization.
/// All data traces back to a specific vendor observation with provenance.
/// </remarks>
public sealed record VendorRiskSignal(
string TenantId,
string AdvisoryId,
string ObservationId,
VendorRiskProvenance Provenance,
ImmutableArray<VendorCvssScore> CvssScores,
VendorKevStatus? KevStatus,
ImmutableArray<VendorFixAvailability> FixAvailability,
DateTimeOffset ExtractedAt)
{
/// <summary>
/// Creates a risk signal with no data (for observations without risk metadata).
/// </summary>
public static VendorRiskSignal Empty(
string tenantId,
string advisoryId,
string observationId,
VendorRiskProvenance provenance,
DateTimeOffset extractedAt)
{
return new VendorRiskSignal(
TenantId: tenantId,
AdvisoryId: advisoryId,
ObservationId: observationId,
Provenance: provenance,
CvssScores: ImmutableArray<VendorCvssScore>.Empty,
KevStatus: null,
FixAvailability: ImmutableArray<VendorFixAvailability>.Empty,
ExtractedAt: extractedAt);
}
/// <summary>
/// Gets the highest severity CVSS score if any.
/// </summary>
public VendorCvssScore? HighestCvssScore => CvssScores.IsDefaultOrEmpty
? null
: CvssScores.MaxBy(s => s.Score);
/// <summary>
/// Indicates if any fix is available from any vendor.
/// </summary>
public bool HasFixAvailable => !FixAvailability.IsDefaultOrEmpty &&
FixAvailability.Any(f => f.Status == FixStatus.Available);
/// <summary>
/// Indicates if this advisory is in the KEV list.
/// </summary>
public bool IsKnownExploited => KevStatus?.InKev == true;
}
/// <summary>
/// Provenance anchor for vendor risk data.
/// </summary>
public sealed record VendorRiskProvenance(
string Vendor,
string Source,
string ObservationHash,
DateTimeOffset FetchedAt,
string? IngestJobId,
string? UpstreamId);
/// <summary>
/// Vendor-provided CVSS score with version information.
/// </summary>
public sealed record VendorCvssScore(
string System,
double Score,
string? Vector,
string? Severity,
VendorRiskProvenance Provenance)
{
/// <summary>
/// Normalizes the system name to a standard format.
/// </summary>
public string NormalizedSystem => System?.ToLowerInvariant() switch
{
"cvss_v2" or "cvssv2" or "cvss2" => "cvss_v2",
"cvss_v30" or "cvssv30" or "cvss30" or "cvss_v3" or "cvssv3" or "cvss3" => "cvss_v30",
"cvss_v31" or "cvssv31" or "cvss31" => "cvss_v31",
"cvss_v40" or "cvssv40" or "cvss40" or "cvss_v4" or "cvssv4" or "cvss4" => "cvss_v40",
var s => s ?? "unknown"
};
/// <summary>
/// Derives severity tier from score (if not provided by vendor).
/// </summary>
public string EffectiveSeverity => Severity ?? DeriveFromScore(Score, NormalizedSystem);
private static string DeriveFromScore(double score, string system)
{
// CVSS v2 uses different thresholds
if (system == "cvss_v2")
{
return score switch
{
>= 7.0 => "high",
>= 4.0 => "medium",
_ => "low"
};
}
// CVSS v3.x and v4.x thresholds
return score switch
{
>= 9.0 => "critical",
>= 7.0 => "high",
>= 4.0 => "medium",
>= 0.1 => "low",
_ => "none"
};
}
}
/// <summary>
/// KEV (Known Exploited Vulnerabilities) status from vendor data.
/// </summary>
public sealed record VendorKevStatus(
bool InKev,
DateTimeOffset? DateAdded,
DateTimeOffset? DueDate,
string? KnownRansomwareCampaignUse,
string? Notes,
VendorRiskProvenance Provenance);
/// <summary>
/// Fix availability information from vendor.
/// </summary>
public sealed record VendorFixAvailability(
FixStatus Status,
string? FixedVersion,
string? AdvisoryUrl,
DateTimeOffset? FixReleasedAt,
string? Package,
string? Ecosystem,
VendorRiskProvenance Provenance);
/// <summary>
/// Fix availability status.
/// </summary>
public enum FixStatus
{
/// <summary>Fix status unknown.</summary>
Unknown,
/// <summary>Fix is available.</summary>
Available,
/// <summary>No fix available yet.</summary>
NotAvailable,
/// <summary>Will not be fixed (end of life, etc.).</summary>
WillNotFix,
/// <summary>Fix is in progress.</summary>
InProgress
}

View File

@@ -0,0 +1,263 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Text.Json;
namespace StellaOps.Concelier.Core.Risk;
/// <summary>
/// Extracts vendor risk signals from observation data.
/// Per CONCELIER-RISK-66-001, extracts fact-only CVSS/KEV/fix data with provenance.
/// </summary>
public static class VendorRiskSignalExtractor
{
/// <summary>
/// Extracts a vendor risk signal from observation data.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="advisoryId">Advisory identifier.</param>
/// <param name="observationId">Observation identifier.</param>
/// <param name="vendor">Vendor name.</param>
/// <param name="source">Source identifier.</param>
/// <param name="observationHash">Content hash for provenance.</param>
/// <param name="fetchedAt">When the data was fetched.</param>
/// <param name="ingestJobId">Optional ingest job ID.</param>
/// <param name="upstreamId">Optional upstream ID.</param>
/// <param name="severities">Severity data from observation.</param>
/// <param name="rawContent">Raw JSON content for KEV/fix extraction.</param>
/// <param name="now">Current timestamp.</param>
/// <returns>Extracted vendor risk signal.</returns>
public static VendorRiskSignal Extract(
string tenantId,
string advisoryId,
string observationId,
string vendor,
string source,
string observationHash,
DateTimeOffset fetchedAt,
string? ingestJobId,
string? upstreamId,
IReadOnlyList<SeverityInput>? severities,
JsonElement? rawContent,
DateTimeOffset now)
{
var provenance = new VendorRiskProvenance(
Vendor: vendor,
Source: source,
ObservationHash: observationHash,
FetchedAt: fetchedAt,
IngestJobId: ingestJobId,
UpstreamId: upstreamId);
var cvssScores = ExtractCvssScores(severities, provenance);
var kevStatus = ExtractKevStatus(rawContent, provenance);
var fixAvailability = ExtractFixAvailability(rawContent, provenance);
return new VendorRiskSignal(
TenantId: tenantId,
AdvisoryId: advisoryId,
ObservationId: observationId,
Provenance: provenance,
CvssScores: cvssScores,
KevStatus: kevStatus,
FixAvailability: fixAvailability,
ExtractedAt: now);
}
private static ImmutableArray<VendorCvssScore> ExtractCvssScores(
IReadOnlyList<SeverityInput>? severities,
VendorRiskProvenance provenance)
{
if (severities is null || severities.Count == 0)
{
return ImmutableArray<VendorCvssScore>.Empty;
}
var builder = ImmutableArray.CreateBuilder<VendorCvssScore>(severities.Count);
foreach (var severity in severities)
{
if (string.IsNullOrWhiteSpace(severity.System))
{
continue;
}
builder.Add(new VendorCvssScore(
System: severity.System,
Score: severity.Score,
Vector: severity.Vector,
Severity: severity.Severity,
Provenance: provenance));
}
return builder.ToImmutable();
}
private static VendorKevStatus? ExtractKevStatus(
JsonElement? rawContent,
VendorRiskProvenance provenance)
{
if (rawContent is null || rawContent.Value.ValueKind != JsonValueKind.Object)
{
return null;
}
var content = rawContent.Value;
// Try common KEV data locations in raw content
// NVD format: cisa_exploit_add, cisa_required_action, cisa_vulnerability_name
if (TryGetProperty(content, "cisa_exploit_add", out var cisaAdd) ||
TryGetProperty(content, "database_specific", out var dbSpecific) && TryGetProperty(dbSpecific, "cisa", out cisaAdd))
{
return new VendorKevStatus(
InKev: true,
DateAdded: TryParseDate(cisaAdd),
DueDate: TryGetDateProperty(content, "cisa_action_due"),
KnownRansomwareCampaignUse: TryGetStringProperty(content, "cisa_ransomware"),
Notes: TryGetStringProperty(content, "cisa_vulnerability_name"),
Provenance: provenance);
}
// OSV/GitHub format: database_specific.kev
if (TryGetProperty(content, "database_specific", out var osv) &&
TryGetProperty(osv, "kev", out var kev))
{
var inKev = kev.ValueKind == JsonValueKind.True ||
(kev.ValueKind == JsonValueKind.Object && TryGetProperty(kev, "in_kev", out var inKevProp) && inKevProp.ValueKind == JsonValueKind.True);
if (inKev)
{
return new VendorKevStatus(
InKev: true,
DateAdded: kev.ValueKind == JsonValueKind.Object ? TryGetDateProperty(kev, "date_added") : null,
DueDate: kev.ValueKind == JsonValueKind.Object ? TryGetDateProperty(kev, "due_date") : null,
KnownRansomwareCampaignUse: kev.ValueKind == JsonValueKind.Object ? TryGetStringProperty(kev, "ransomware") : null,
Notes: null,
Provenance: provenance);
}
}
return null;
}
private static ImmutableArray<VendorFixAvailability> ExtractFixAvailability(
JsonElement? rawContent,
VendorRiskProvenance provenance)
{
if (rawContent is null || rawContent.Value.ValueKind != JsonValueKind.Object)
{
return ImmutableArray<VendorFixAvailability>.Empty;
}
var content = rawContent.Value;
var builder = ImmutableArray.CreateBuilder<VendorFixAvailability>();
// OSV format: affected[].ranges[].events[{fixed: "version"}]
if (TryGetProperty(content, "affected", out var affected) && affected.ValueKind == JsonValueKind.Array)
{
foreach (var aff in affected.EnumerateArray())
{
var package = TryGetStringProperty(aff, "package", "name") ?? TryGetStringProperty(aff, "purl");
var ecosystem = TryGetStringProperty(aff, "package", "ecosystem");
if (TryGetProperty(aff, "ranges", out var ranges) && ranges.ValueKind == JsonValueKind.Array)
{
foreach (var range in ranges.EnumerateArray())
{
if (TryGetProperty(range, "events", out var events) && events.ValueKind == JsonValueKind.Array)
{
foreach (var evt in events.EnumerateArray())
{
if (TryGetProperty(evt, "fixed", out var fixedVersion))
{
builder.Add(new VendorFixAvailability(
Status: FixStatus.Available,
FixedVersion: fixedVersion.GetString(),
AdvisoryUrl: null,
FixReleasedAt: null,
Package: package,
Ecosystem: ecosystem,
Provenance: provenance));
}
}
}
}
}
// Also check versions[] for fixed versions
if (TryGetProperty(aff, "versions", out var versions) && versions.ValueKind == JsonValueKind.Array)
{
// Fixed versions may be indicated by absence from versions array
// This is less reliable, so we only use it if no range data exists
}
}
}
// NVD format: configurations with fix status
if (TryGetProperty(content, "configurations", out var configs) && configs.ValueKind == JsonValueKind.Array)
{
// NVD configurations don't directly indicate fixes, but CPE matches can imply them
// This would require more complex parsing - defer to vendor-specific connectors
}
return builder.ToImmutable();
}
private static bool TryGetProperty(JsonElement element, string propertyName, out JsonElement value)
{
value = default;
if (element.ValueKind != JsonValueKind.Object)
{
return false;
}
return element.TryGetProperty(propertyName, out value);
}
private static string? TryGetStringProperty(JsonElement element, params string[] path)
{
var current = element;
foreach (var segment in path)
{
if (!TryGetProperty(current, segment, out current))
{
return null;
}
}
return current.ValueKind == JsonValueKind.String ? current.GetString() : null;
}
private static DateTimeOffset? TryGetDateProperty(JsonElement element, string propertyName)
{
if (!TryGetProperty(element, propertyName, out var value))
{
return null;
}
return TryParseDate(value);
}
private static DateTimeOffset? TryParseDate(JsonElement element)
{
if (element.ValueKind == JsonValueKind.String)
{
var str = element.GetString();
if (DateTimeOffset.TryParse(str, out var date))
{
return date;
}
}
return null;
}
}
/// <summary>
/// Input for severity extraction from observation data.
/// </summary>
public sealed record SeverityInput(
string System,
double Score,
string? Vector,
string? Severity);

View File

@@ -0,0 +1,109 @@
using System.Collections.Immutable;
namespace StellaOps.Concelier.Core.Tenancy;
/// <summary>
/// Response model for /capabilities/tenant endpoint.
/// Per AUTH-TEN-47-001 and CONCELIER-TEN-48-001: echoes tenantId, scopes, and mergeAllowed=false when LNM is enabled.
/// </summary>
public sealed record TenantCapabilitiesResponse(
string TenantId,
string TenantUrn,
ImmutableArray<string> Scopes,
bool MergeAllowed,
bool OfflineAllowed,
TenantCapabilitiesMode Mode,
DateTimeOffset GeneratedAt)
{
/// <summary>
/// Creates a Link-Not-Merge capabilities response.
/// </summary>
public static TenantCapabilitiesResponse ForLinkNotMerge(
TenantScope scope,
DateTimeOffset now)
{
return new TenantCapabilitiesResponse(
TenantId: scope.TenantId,
TenantUrn: scope.TenantUrn,
Scopes: scope.Scopes,
MergeAllowed: false, // Always false in LNM mode
OfflineAllowed: scope.Capabilities.OfflineAllowed,
Mode: TenantCapabilitiesMode.LinkNotMerge,
GeneratedAt: now);
}
}
/// <summary>
/// Operating mode for tenant capabilities.
/// </summary>
public enum TenantCapabilitiesMode
{
/// <summary>Link-Not-Merge mode - no advisory merging.</summary>
LinkNotMerge,
/// <summary>Legacy merge mode (deprecated).</summary>
LegacyMerge
}
/// <summary>
/// Interface for tenant capabilities provider.
/// </summary>
public interface ITenantCapabilitiesProvider
{
/// <summary>
/// Gets the current capabilities for the tenant scope.
/// </summary>
TenantCapabilitiesResponse GetCapabilities(TenantScope scope);
/// <summary>
/// Validates that the tenant scope is allowed to perform the requested operation.
/// </summary>
/// <param name="scope">Tenant scope to validate.</param>
/// <param name="requiredScopes">Required scopes for the operation.</param>
/// <exception cref="TenantScopeException">Thrown if validation fails.</exception>
void ValidateScope(TenantScope scope, params string[] requiredScopes);
}
/// <summary>
/// Default implementation of tenant capabilities provider for Link-Not-Merge mode.
/// </summary>
public sealed class LinkNotMergeTenantCapabilitiesProvider : ITenantCapabilitiesProvider
{
private readonly TimeProvider _timeProvider;
public LinkNotMergeTenantCapabilitiesProvider(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public TenantCapabilitiesResponse GetCapabilities(TenantScope scope)
{
ArgumentNullException.ThrowIfNull(scope);
scope.Validate();
// In Link-Not-Merge mode, merge is never allowed
// This enforces the contract even if the token claims mergeAllowed=true
return TenantCapabilitiesResponse.ForLinkNotMerge(scope, _timeProvider.GetUtcNow());
}
public void ValidateScope(TenantScope scope, params string[] requiredScopes)
{
ArgumentNullException.ThrowIfNull(scope);
scope.Validate();
if (requiredScopes.Length == 0)
{
return;
}
var hasRequired = requiredScopes.Any(required =>
scope.Scopes.Any(s => s.Equals(required, StringComparison.OrdinalIgnoreCase)));
if (!hasRequired)
{
throw new TenantScopeException(
"auth/insufficient-scope",
$"Required scope missing. Need one of: {string.Join(", ", requiredScopes)}");
}
}
}

View File

@@ -0,0 +1,123 @@
using System;
using System.Collections.Immutable;
namespace StellaOps.Concelier.Core.Tenancy;
/// <summary>
/// Tenant scope data per AUTH-TEN-47-001 contract.
/// Per CONCELIER-TEN-48-001, enforces tenant scoping through normalization/linking.
/// </summary>
public sealed record TenantScope(
string TenantId,
string Issuer,
ImmutableArray<string> Scopes,
TenantCapabilities Capabilities,
TenantAttribution? Attribution,
DateTimeOffset IssuedAt,
DateTimeOffset ExpiresAt)
{
/// <summary>
/// Validates that the tenant scope is well-formed.
/// </summary>
public void Validate()
{
if (string.IsNullOrWhiteSpace(TenantId))
{
throw new TenantScopeException("auth/tenant-scope-missing", "TenantId is required");
}
if (string.IsNullOrWhiteSpace(Issuer))
{
throw new TenantScopeException("auth/tenant-scope-missing", "Issuer is required");
}
if (Scopes.IsDefaultOrEmpty)
{
throw new TenantScopeException("auth/tenant-scope-missing", "Scopes are required");
}
if (!HasRequiredScope())
{
throw new TenantScopeException("auth/tenant-scope-missing", "Required concelier scope missing");
}
if (ExpiresAt <= DateTimeOffset.UtcNow)
{
throw new TenantScopeException("auth/token-expired", "Token has expired");
}
}
/// <summary>
/// Checks if the scope has at least one required Concelier scope.
/// </summary>
public bool HasRequiredScope()
{
return Scopes.Any(s =>
s.StartsWith("concelier.", StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// Checks if the scope allows read access.
/// </summary>
public bool CanRead =>
Scopes.Any(s => s.Equals("concelier.read", StringComparison.OrdinalIgnoreCase) ||
s.Equals("concelier.linkset.read", StringComparison.OrdinalIgnoreCase));
/// <summary>
/// Checks if the scope allows write access.
/// </summary>
public bool CanWrite =>
Scopes.Any(s => s.Equals("concelier.linkset.write", StringComparison.OrdinalIgnoreCase));
/// <summary>
/// Checks if the scope allows tenant admin access.
/// </summary>
public bool CanAdminTenant =>
Scopes.Any(s => s.Equals("concelier.tenant.admin", StringComparison.OrdinalIgnoreCase));
/// <summary>
/// Gets the canonical tenant URN format.
/// </summary>
public string TenantUrn => TenantId.StartsWith("urn:tenant:", StringComparison.Ordinal)
? TenantId
: $"urn:tenant:{TenantId}";
}
/// <summary>
/// Tenant capabilities per AUTH-TEN-47-001 contract.
/// </summary>
public sealed record TenantCapabilities(
bool MergeAllowed = false,
bool OfflineAllowed = true)
{
/// <summary>
/// Default capabilities for Link-Not-Merge mode.
/// </summary>
public static TenantCapabilities Default { get; } = new(
MergeAllowed: false,
OfflineAllowed: true);
}
/// <summary>
/// Tenant attribution for audit logging.
/// </summary>
public sealed record TenantAttribution(
string? Actor,
string? TraceId);
/// <summary>
/// Exception thrown when tenant scope validation fails.
/// </summary>
public sealed class TenantScopeException : Exception
{
public TenantScopeException(string errorCode, string message)
: base(message)
{
ErrorCode = errorCode;
}
/// <summary>
/// Error code for API responses (e.g., auth/tenant-scope-missing).
/// </summary>
public string ErrorCode { get; }
}

View File

@@ -0,0 +1,105 @@
using System;
namespace StellaOps.Concelier.Core.Tenancy;
/// <summary>
/// Normalizes tenant identifiers for consistent storage and lookup.
/// Per CONCELIER-TEN-48-001: enforces tenant scoping through normalization.
/// </summary>
public static class TenantScopeNormalizer
{
private const string TenantUrnPrefix = "urn:tenant:";
/// <summary>
/// Normalizes a tenant identifier to canonical URN format.
/// </summary>
/// <param name="tenantId">Raw tenant identifier.</param>
/// <returns>Normalized tenant URN.</returns>
public static string NormalizeToUrn(string tenantId)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
throw new ArgumentException("Tenant ID cannot be empty", nameof(tenantId));
}
var trimmed = tenantId.Trim();
// Already in URN format
if (trimmed.StartsWith(TenantUrnPrefix, StringComparison.Ordinal))
{
return trimmed.ToLowerInvariant();
}
// Convert to URN format
return $"{TenantUrnPrefix}{trimmed.ToLowerInvariant()}";
}
/// <summary>
/// Extracts the raw tenant identifier from a URN.
/// </summary>
/// <param name="tenantUrn">Tenant URN.</param>
/// <returns>Raw tenant identifier.</returns>
public static string ExtractFromUrn(string tenantUrn)
{
if (string.IsNullOrWhiteSpace(tenantUrn))
{
throw new ArgumentException("Tenant URN cannot be empty", nameof(tenantUrn));
}
var trimmed = tenantUrn.Trim();
if (trimmed.StartsWith(TenantUrnPrefix, StringComparison.OrdinalIgnoreCase))
{
return trimmed[TenantUrnPrefix.Length..].ToLowerInvariant();
}
return trimmed.ToLowerInvariant();
}
/// <summary>
/// Normalizes a tenant identifier for storage (lowercase, no URN prefix).
/// </summary>
/// <param name="tenantId">Raw tenant identifier or URN.</param>
/// <returns>Normalized tenant ID for storage.</returns>
public static string NormalizeForStorage(string tenantId)
{
return ExtractFromUrn(tenantId);
}
/// <summary>
/// Validates that two tenant identifiers refer to the same tenant.
/// </summary>
/// <param name="tenantId1">First tenant identifier.</param>
/// <param name="tenantId2">Second tenant identifier.</param>
/// <returns>True if both refer to the same tenant.</returns>
public static bool AreEqual(string? tenantId1, string? tenantId2)
{
if (string.IsNullOrWhiteSpace(tenantId1) || string.IsNullOrWhiteSpace(tenantId2))
{
return false;
}
var normalized1 = NormalizeForStorage(tenantId1);
var normalized2 = NormalizeForStorage(tenantId2);
return string.Equals(normalized1, normalized2, StringComparison.Ordinal);
}
/// <summary>
/// Validates that the provided tenant ID matches the scope's tenant.
/// </summary>
/// <param name="requestTenantId">Tenant ID from request.</param>
/// <param name="scope">Authenticated tenant scope.</param>
/// <exception cref="TenantScopeException">Thrown if tenant IDs don't match.</exception>
public static void ValidateTenantMatch(string requestTenantId, TenantScope scope)
{
ArgumentNullException.ThrowIfNull(scope);
if (!AreEqual(requestTenantId, scope.TenantId))
{
throw new TenantScopeException(
"auth/tenant-mismatch",
"Request tenant ID does not match authenticated tenant scope");
}
}
}

View File

@@ -31,6 +31,9 @@ This module owns the persistent shape of Concelier's MongoDB database. Upgrades
| `20251117_advisory_linksets_tenant_lower` | Lowercases `advisory_linksets.tenantId` to align writes with lookup filters. |
| `20251116_link_not_merge_collections` | Ensures `advisory_observations` and `advisory_linksets` collections exist with JSON schema validators and baseline indexes for LNM. |
| `20251127_lnm_sharding_and_ttl` | Adds hashed shard key indexes on `tenantId` for horizontal scaling and optional TTL indexes on `ingestedAt`/`createdAt` for storage retention. Creates `advisory_linkset_events` collection for linkset event outbox (LNM-21-101-DEV). |
| `20251127_lnm_legacy_backfill` | Backfills `advisory_observations` from `advisory_raw` documents and creates/updates `advisory_linksets` by grouping observations. Seeds `backfill_marker` tombstones on migrated documents for rollback tracking (LNM-21-102-DEV). |
| `20251128_policy_delta_checkpoints` | Creates `policy_delta_checkpoints` collection with tenant/consumer indexes for deterministic policy delta tracking. Supports cursor-based pagination and change-stream resume tokens for policy consumers (CONCELIER-POLICY-20-003). |
| `20251128_policy_lookup_indexes` | Adds secondary indexes for policy lookup patterns: alias multikey index on observations, confidence/severity indexes on linksets. Supports efficient policy joins without cached verdicts (CONCELIER-POLICY-23-001). |
## Operator Runbook
@@ -44,6 +47,11 @@ This module owns the persistent shape of Concelier's MongoDB database. Upgrades
- To re-run a migration in a lab, delete the corresponding document from `schema_migrations` and restart the service. **Do not** do this in production unless the migration body is known to be idempotent and safe.
- When changing retention settings (`RawDocumentRetention`), deploy the new configuration and restart Concelier. The migration runner will adjust indexes on the next boot.
- For the event-log collections (`advisory_statements`, `advisory_conflicts`), rollback is simply `db.advisory_statements.drop()` / `db.advisory_conflicts.drop()` followed by a restart if you must revert to the pre-event-log schema (only in labs). Production rollbacks should instead gate merge features that rely on these collections.
- For `20251127_lnm_legacy_backfill` rollback, use the provided Offline Kit script:
```bash
mongo concelier ops/devops/scripts/rollback-lnm-backfill.js
```
This script removes backfilled observations and linksets by querying the `backfill_marker` field (`lnm_21_102_dev`), then clears the tombstone markers from `advisory_raw`. After rollback, delete `20251127_lnm_legacy_backfill` from `schema_migrations` and restart.
- If migrations fail, restart with `Logging__LogLevel__StellaOps.Concelier.Storage.Mongo.Migrations=Debug` to surface diagnostic output. Remediate underlying index/collection drift before retrying.
## Validating an Upgrade

View File

@@ -0,0 +1,81 @@
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Concelier.Storage.Mongo.PolicyDelta;
namespace StellaOps.Concelier.Storage.Mongo.Migrations;
/// <summary>
/// Creates the policy_delta_checkpoints collection with indexes for deterministic policy delta tracking.
/// </summary>
internal sealed class EnsurePolicyDeltaCheckpointsCollectionMigration : IMongoMigration
{
public string Id => "20251128_policy_delta_checkpoints";
public string Description =>
"Creates policy_delta_checkpoints collection with tenant/consumer indexes for deterministic policy deltas (CONCELIER-POLICY-20-003).";
public async Task ApplyAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
var collectionName = MongoStorageDefaults.Collections.PolicyDeltaCheckpoints;
// Ensure collection exists
var collectionNames = await database
.ListCollectionNames(cancellationToken: cancellationToken)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var exists = collectionNames.Contains(collectionName);
if (!exists)
{
await database.CreateCollectionAsync(collectionName, cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
var collection = database.GetCollection<PolicyDeltaCheckpointDocument>(collectionName);
// Index: tenantId for listing checkpoints by tenant
var tenantIndex = new CreateIndexModel<PolicyDeltaCheckpointDocument>(
Builders<PolicyDeltaCheckpointDocument>.IndexKeys.Ascending(d => d.TenantId),
new CreateIndexOptions
{
Name = "ix_tenantId",
Background = true
});
// Index: consumerId for querying checkpoints by consumer
var consumerIndex = new CreateIndexModel<PolicyDeltaCheckpointDocument>(
Builders<PolicyDeltaCheckpointDocument>.IndexKeys.Ascending(d => d.ConsumerId),
new CreateIndexOptions
{
Name = "ix_consumerId",
Background = true
});
// Compound index: (tenantId, consumerId) for efficient lookups
var compoundIndex = new CreateIndexModel<PolicyDeltaCheckpointDocument>(
Builders<PolicyDeltaCheckpointDocument>.IndexKeys
.Ascending(d => d.TenantId)
.Ascending(d => d.ConsumerId),
new CreateIndexOptions
{
Name = "ix_tenantId_consumerId",
Background = true
});
// Index: updatedAt for maintenance queries (stale checkpoint detection)
var updatedAtIndex = new CreateIndexModel<PolicyDeltaCheckpointDocument>(
Builders<PolicyDeltaCheckpointDocument>.IndexKeys.Ascending(d => d.UpdatedAt),
new CreateIndexOptions
{
Name = "ix_updatedAt",
Background = true
});
await collection.Indexes.CreateManyAsync(
[tenantIndex, consumerIndex, compoundIndex, updatedAtIndex],
cancellationToken)
.ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,131 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Bson;
using MongoDB.Driver;
namespace StellaOps.Concelier.Storage.Mongo.Migrations;
/// <summary>
/// Adds secondary indexes for policy lookup patterns: alias lookups, confidence filtering, and severity-based queries.
/// Supports efficient policy joins without cached verdicts per CONCELIER-POLICY-23-001.
/// </summary>
/// <remarks>
/// Query patterns supported:
/// <list type="bullet">
/// <item>Find observations by alias (CVE-ID, GHSA-ID): db.advisory_observations.find({"linkset.aliases": "cve-2024-1234"})</item>
/// <item>Find linksets by confidence range: db.advisory_linksets.find({"confidence": {$gte: 0.7}})</item>
/// <item>Find linksets by provider severity: db.advisory_linksets.find({"normalized.severities.system": "cvss_v31", "normalized.severities.score": {$gte: 7.0}})</item>
/// <item>Find linksets by tenant and advisory with confidence: db.advisory_linksets.find({"tenantId": "...", "advisoryId": "...", "confidence": {$gte: 0.5}})</item>
/// </list>
/// </remarks>
internal sealed class EnsurePolicyLookupIndexesMigration : IMongoMigration
{
public string Id => "20251128_policy_lookup_indexes";
public string Description => "Add secondary indexes for alias, confidence, and severity-based policy lookups (CONCELIER-POLICY-23-001)";
public async Task ApplyAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(database);
await EnsureObservationPolicyIndexesAsync(database, cancellationToken).ConfigureAwait(false);
await EnsureLinksetPolicyIndexesAsync(database, cancellationToken).ConfigureAwait(false);
}
private static async Task EnsureObservationPolicyIndexesAsync(IMongoDatabase database, CancellationToken ct)
{
var collection = database.GetCollection<BsonDocument>(MongoStorageDefaults.Collections.AdvisoryObservations);
var indexes = new List<CreateIndexModel<BsonDocument>>
{
// Multikey index on linkset.aliases for alias-based lookups (CVE-ID, GHSA-ID, etc.)
// Query pattern: db.advisory_observations.find({"linkset.aliases": "cve-2024-1234"})
new(new BsonDocument("linkset.aliases", 1),
new CreateIndexOptions
{
Name = "obs_linkset_aliases",
Background = true,
Sparse = true
}),
// Compound index for tenant + alias lookups
// Query pattern: db.advisory_observations.find({"tenant": "...", "linkset.aliases": "cve-2024-1234"})
new(new BsonDocument { { "tenant", 1 }, { "linkset.aliases", 1 } },
new CreateIndexOptions
{
Name = "obs_tenant_aliases",
Background = true
})
};
await collection.Indexes.CreateManyAsync(indexes, cancellationToken: ct).ConfigureAwait(false);
}
private static async Task EnsureLinksetPolicyIndexesAsync(IMongoDatabase database, CancellationToken ct)
{
var collection = database.GetCollection<BsonDocument>(MongoStorageDefaults.Collections.AdvisoryLinksets);
var indexes = new List<CreateIndexModel<BsonDocument>>
{
// Index on confidence for confidence-based filtering
// Query pattern: db.advisory_linksets.find({"confidence": {$gte: 0.7}})
new(new BsonDocument("confidence", -1),
new CreateIndexOptions
{
Name = "linkset_confidence",
Background = true,
Sparse = true
}),
// Compound index for tenant + confidence lookups
// Query pattern: db.advisory_linksets.find({"tenantId": "...", "confidence": {$gte: 0.7}})
new(new BsonDocument { { "tenantId", 1 }, { "confidence", -1 } },
new CreateIndexOptions
{
Name = "linkset_tenant_confidence",
Background = true
}),
// Index on normalized.severities.system for severity system filtering
// Query pattern: db.advisory_linksets.find({"normalized.severities.system": "cvss_v31"})
new(new BsonDocument("normalized.severities.system", 1),
new CreateIndexOptions
{
Name = "linkset_severity_system",
Background = true,
Sparse = true
}),
// Compound index for severity system + score for range queries
// Query pattern: db.advisory_linksets.find({"normalized.severities.system": "cvss_v31", "normalized.severities.score": {$gte: 7.0}})
new(new BsonDocument { { "normalized.severities.system", 1 }, { "normalized.severities.score", -1 } },
new CreateIndexOptions
{
Name = "linkset_severity_system_score",
Background = true,
Sparse = true
}),
// Compound index for tenant + advisory + confidence (policy delta queries)
// Query pattern: db.advisory_linksets.find({"tenantId": "...", "advisoryId": "...", "confidence": {$gte: 0.5}})
new(new BsonDocument { { "tenantId", 1 }, { "advisoryId", 1 }, { "confidence", -1 } },
new CreateIndexOptions
{
Name = "linkset_tenant_advisory_confidence",
Background = true
}),
// Index for createdAt-based pagination (policy delta cursors)
// Query pattern: db.advisory_linksets.find({"tenantId": "...", "createdAt": {$gt: ISODate("...")}}).sort({"createdAt": 1})
new(new BsonDocument { { "tenantId", 1 }, { "createdAt", 1 } },
new CreateIndexOptions
{
Name = "linkset_tenant_createdAt",
Background = true
})
};
await collection.Indexes.CreateManyAsync(indexes, cancellationToken: ct).ConfigureAwait(false);
}
}

View File

@@ -1,13 +1,13 @@
namespace StellaOps.Concelier.Storage.Mongo;
public static class MongoStorageDefaults
{
public const string DefaultDatabaseName = "concelier";
public static class Collections
{
public const string Source = "source";
public const string SourceState = "source_state";
namespace StellaOps.Concelier.Storage.Mongo;
public static class MongoStorageDefaults
{
public const string DefaultDatabaseName = "concelier";
public static class Collections
{
public const string Source = "source";
public const string SourceState = "source_state";
public const string Document = "document";
public const string Dto = "dto";
public const string Advisory = "advisory";
@@ -15,10 +15,10 @@ public static class MongoStorageDefaults
public const string Alias = "alias";
public const string Affected = "affected";
public const string Reference = "reference";
public const string KevFlag = "kev_flag";
public const string RuFlags = "ru_flags";
public const string JpFlags = "jp_flags";
public const string PsirtFlags = "psirt_flags";
public const string KevFlag = "kev_flag";
public const string RuFlags = "ru_flags";
public const string JpFlags = "jp_flags";
public const string PsirtFlags = "psirt_flags";
public const string MergeEvent = "merge_event";
public const string ExportState = "export_state";
public const string Locks = "locks";
@@ -33,5 +33,6 @@ public static class MongoStorageDefaults
public const string OrchestratorRegistry = "orchestrator_registry";
public const string OrchestratorCommands = "orchestrator_commands";
public const string OrchestratorHeartbeats = "orchestrator_heartbeats";
public const string PolicyDeltaCheckpoints = "policy_delta_checkpoints";
}
}

View File

@@ -0,0 +1,135 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using StellaOps.Concelier.Core.Linksets;
namespace StellaOps.Concelier.Storage.Mongo.PolicyDelta;
/// <summary>
/// MongoDB implementation of <see cref="IPolicyDeltaCheckpointStore"/>.
/// </summary>
internal sealed class MongoPolicyDeltaCheckpointStore : IPolicyDeltaCheckpointStore
{
private readonly IMongoCollection<PolicyDeltaCheckpointDocument> _collection;
private readonly TimeProvider _timeProvider;
public MongoPolicyDeltaCheckpointStore(IMongoDatabase database, TimeProvider timeProvider)
{
ArgumentNullException.ThrowIfNull(database);
ArgumentNullException.ThrowIfNull(timeProvider);
_collection = database.GetCollection<PolicyDeltaCheckpointDocument>(
MongoStorageDefaults.Collections.PolicyDeltaCheckpoints);
_timeProvider = timeProvider;
}
public async Task<PolicyDeltaCheckpoint> GetOrCreateAsync(
string tenantId,
string consumerId,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(consumerId);
var checkpointId = $"{consumerId}:{tenantId}";
var existing = await _collection
.Find(d => d.Id == checkpointId)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
if (existing is not null)
{
return existing.ToRecord();
}
var now = _timeProvider.GetUtcNow();
var checkpoint = PolicyDeltaCheckpoint.CreateNew(tenantId, consumerId, now);
var document = PolicyDeltaCheckpointDocument.FromRecord(checkpoint);
try
{
await _collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
return checkpoint;
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
// Race condition: another process created the checkpoint concurrently.
existing = await _collection
.Find(d => d.Id == checkpointId)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return existing?.ToRecord() ?? checkpoint;
}
}
public async Task<PolicyDeltaCheckpoint?> GetAsync(
string checkpointId,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(checkpointId);
var document = await _collection
.Find(d => d.Id == checkpointId)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return document?.ToRecord();
}
public async Task<PolicyDeltaCheckpoint> UpdateAsync(
PolicyDeltaCheckpoint checkpoint,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(checkpoint);
var document = PolicyDeltaCheckpointDocument.FromRecord(checkpoint);
var options = new ReplaceOptions { IsUpsert = true };
await _collection
.ReplaceOneAsync(
d => d.Id == checkpoint.CheckpointId,
document,
options,
cancellationToken)
.ConfigureAwait(false);
return checkpoint;
}
public async Task<IReadOnlyList<PolicyDeltaCheckpoint>> ListByTenantAsync(
string tenantId,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var documents = await _collection
.Find(d => d.TenantId == tenantId)
.SortBy(d => d.ConsumerId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var results = new List<PolicyDeltaCheckpoint>(documents.Count);
foreach (var doc in documents)
{
results.Add(doc.ToRecord());
}
return results;
}
public async Task<bool> DeleteAsync(
string checkpointId,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(checkpointId);
var result = await _collection
.DeleteOneAsync(d => d.Id == checkpointId, cancellationToken)
.ConfigureAwait(false);
return result.DeletedCount > 0;
}
}

View File

@@ -0,0 +1,78 @@
using System;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using StellaOps.Concelier.Core.Linksets;
namespace StellaOps.Concelier.Storage.Mongo.PolicyDelta;
/// <summary>
/// MongoDB document for storing policy delta checkpoints.
/// </summary>
[BsonIgnoreExtraElements]
internal sealed class PolicyDeltaCheckpointDocument
{
/// <summary>
/// Unique identifier: {consumerId}:{tenantId}
/// </summary>
[BsonId]
public string Id { get; set; } = string.Empty;
[BsonElement("tenantId")]
public string TenantId { get; set; } = string.Empty;
[BsonElement("consumerId")]
public string ConsumerId { get; set; } = string.Empty;
[BsonElement("lastCreatedAt")]
[BsonIgnoreIfNull]
public DateTime? LastCreatedAt { get; set; }
[BsonElement("lastAdvisoryId")]
[BsonIgnoreIfNull]
public string? LastAdvisoryId { get; set; }
[BsonElement("resumeToken")]
[BsonIgnoreIfNull]
public string? ResumeToken { get; set; }
[BsonElement("sequenceNumber")]
public long SequenceNumber { get; set; }
[BsonElement("updatedAt")]
public DateTime UpdatedAt { get; set; }
[BsonElement("processedCount")]
public long ProcessedCount { get; set; }
[BsonElement("lastBatchHash")]
[BsonIgnoreIfNull]
public string? LastBatchHash { get; set; }
public PolicyDeltaCheckpoint ToRecord() =>
new(
CheckpointId: Id,
TenantId: TenantId,
ConsumerId: ConsumerId,
LastCreatedAt: LastCreatedAt.HasValue ? new DateTimeOffset(LastCreatedAt.Value, TimeSpan.Zero) : null,
LastAdvisoryId: LastAdvisoryId,
ResumeToken: ResumeToken,
SequenceNumber: SequenceNumber,
UpdatedAt: new DateTimeOffset(UpdatedAt, TimeSpan.Zero),
ProcessedCount: ProcessedCount,
LastBatchHash: LastBatchHash);
public static PolicyDeltaCheckpointDocument FromRecord(PolicyDeltaCheckpoint record) =>
new()
{
Id = record.CheckpointId,
TenantId = record.TenantId,
ConsumerId = record.ConsumerId,
LastCreatedAt = record.LastCreatedAt?.UtcDateTime,
LastAdvisoryId = record.LastAdvisoryId,
ResumeToken = record.ResumeToken,
SequenceNumber = record.SequenceNumber,
UpdatedAt = record.UpdatedAt.UtcDateTime,
ProcessedCount = record.ProcessedCount,
LastBatchHash = record.LastBatchHash
};
}

View File

@@ -24,6 +24,8 @@ using StellaOps.Concelier.Storage.Mongo.Observations;
using StellaOps.Concelier.Core.Observations;
using StellaOps.Concelier.Storage.Mongo.Linksets;
using StellaOps.Concelier.Storage.Mongo.Orchestrator;
using StellaOps.Concelier.Storage.Mongo.PolicyDelta;
using StellaOps.Concelier.Core.Linksets;
namespace StellaOps.Concelier.Storage.Mongo;
@@ -190,8 +192,12 @@ public static class ServiceCollectionExtensions
services.AddSingleton<IMongoMigration, EnsureOrchestratorCollectionsMigration>();
services.AddSingleton<IMongoMigration, EnsureLinkNotMergeCollectionsMigration>();
services.AddSingleton<IMongoMigration, EnsureLinkNotMergeShardingAndTtlMigration>();
services.AddSingleton<IMongoMigration, EnsureLegacyAdvisoriesBackfillMigration>();
services.AddSingleton<IMongoMigration, EnsurePolicyDeltaCheckpointsCollectionMigration>();
services.AddSingleton<IMongoMigration, EnsurePolicyLookupIndexesMigration>();
services.AddSingleton<IOrchestratorRegistryStore, MongoOrchestratorRegistryStore>();
services.AddSingleton<IPolicyDeltaCheckpointStore, MongoPolicyDeltaCheckpointStore>();
services.AddSingleton<IHostedService, AdvisoryObservationTransportWorker>();

View File

@@ -0,0 +1,220 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using StellaOps.PolicyDsl;
namespace StellaOps.Policy.Engine.Compilation;
/// <summary>
/// Extended compile output metadata for policy analysis, coverage tracking, and editor support.
/// </summary>
public sealed record PolicyCompileMetadata(
PolicySymbolTable SymbolTable,
PolicyRuleIndex RuleIndex,
PolicyDocumentation Documentation,
PolicyRuleCoverageMetadata CoverageMetadata,
PolicyDeterministicHashes Hashes);
/// <summary>
/// Deterministic hashes for policy identity and change detection.
/// </summary>
public sealed record PolicyDeterministicHashes(
/// <summary>SHA256 of canonical IR JSON representation.</summary>
string ContentHash,
/// <summary>SHA256 of rule structure only (names, priorities, conditions).</summary>
string StructureHash,
/// <summary>SHA256 of rule names and priorities (for ordering verification).</summary>
string OrderingHash,
/// <summary>Combined hash for complete identity verification.</summary>
string IdentityHash);
/// <summary>
/// Symbol table containing all identifiers, functions, and their usages.
/// </summary>
public sealed record PolicySymbolTable(
ImmutableArray<PolicySymbol> Symbols,
ImmutableArray<PolicyFunctionSignature> BuiltInFunctions,
ImmutableArray<PolicyVariableDefinition> Variables,
ImmutableDictionary<string, ImmutableArray<PolicySymbolReference>> ReferencesByName);
/// <summary>
/// A symbol in the policy DSL (identifier, function, variable, etc.).
/// </summary>
public sealed record PolicySymbol(
string Name,
PolicySymbolKind Kind,
string? Type,
PolicySymbolScope Scope,
ImmutableArray<PolicySymbolReference> References);
/// <summary>
/// Symbol kinds in the policy DSL.
/// </summary>
public enum PolicySymbolKind
{
Variable,
Function,
Profile,
ProfileMap,
ProfileEnv,
ProfileScalar,
Rule,
Metadata,
Setting,
Parameter,
BuiltIn
}
/// <summary>
/// Symbol scope information.
/// </summary>
public sealed record PolicySymbolScope(
string? RuleName,
string? ProfileName,
bool IsGlobal);
/// <summary>
/// Reference to a symbol usage in the policy.
/// </summary>
public sealed record PolicySymbolReference(
string SymbolName,
string Context,
int? LineNumber,
int? ColumnNumber,
PolicySymbolUsage Usage);
/// <summary>
/// How a symbol is used.
/// </summary>
public enum PolicySymbolUsage
{
Definition,
Read,
Write,
Invocation,
MemberAccess
}
/// <summary>
/// Built-in function signature for autocomplete.
/// </summary>
public sealed record PolicyFunctionSignature(
string Name,
string Description,
ImmutableArray<PolicyParameterInfo> Parameters,
string ReturnType,
ImmutableArray<string> Examples);
/// <summary>
/// Parameter information for function signatures.
/// </summary>
public sealed record PolicyParameterInfo(
string Name,
string Type,
bool IsOptional,
string? DefaultValue,
string Description);
/// <summary>
/// Variable definition extracted from policy.
/// </summary>
public sealed record PolicyVariableDefinition(
string Name,
string? InferredType,
string? InitialValue,
string DefinedInRule,
bool IsAssignment);
/// <summary>
/// Rule index for fast lookup and editor autocomplete.
/// </summary>
public sealed record PolicyRuleIndex(
ImmutableArray<PolicyRuleEntry> Rules,
ImmutableDictionary<string, PolicyRuleEntry> ByName,
ImmutableDictionary<int, ImmutableArray<PolicyRuleEntry>> ByPriority,
ImmutableArray<string> ActionTypes,
ImmutableArray<string> UsedIdentifiers);
/// <summary>
/// Index entry for a single rule.
/// </summary>
public sealed record PolicyRuleEntry(
string Name,
int Priority,
int Index,
string ConditionSummary,
ImmutableArray<string> ThenActionTypes,
ImmutableArray<string> ElseActionTypes,
string Justification,
ImmutableArray<string> ReferencedIdentifiers,
ImmutableArray<string> ReferencedFunctions);
/// <summary>
/// Extracted documentation from policy source.
/// </summary>
public sealed record PolicyDocumentation(
string? PolicyDescription,
ImmutableArray<string> Tags,
string? Author,
ImmutableDictionary<string, string> CustomMetadata,
ImmutableArray<PolicyRuleDocumentation> RuleDocumentation,
ImmutableArray<PolicyProfileDocumentation> ProfileDocumentation);
/// <summary>
/// Documentation for a single rule.
/// </summary>
public sealed record PolicyRuleDocumentation(
string RuleName,
int Priority,
string Justification,
string ConditionDescription,
ImmutableArray<string> ActionDescriptions);
/// <summary>
/// Documentation for a profile.
/// </summary>
public sealed record PolicyProfileDocumentation(
string ProfileName,
ImmutableArray<string> MapNames,
ImmutableArray<string> EnvNames,
ImmutableArray<string> ScalarNames);
/// <summary>
/// Rule coverage metadata for tracking test coverage.
/// </summary>
public sealed record PolicyRuleCoverageMetadata(
ImmutableArray<PolicyRuleCoverageEntry> Rules,
int TotalRules,
int TotalConditions,
int TotalActions,
ImmutableDictionary<string, int> ActionTypeCounts,
ImmutableArray<PolicyCoveragePath> CoveragePaths);
/// <summary>
/// Coverage entry for a single rule.
/// </summary>
public sealed record PolicyRuleCoverageEntry(
string RuleName,
int Priority,
string ConditionHash,
int ThenActionCount,
int ElseActionCount,
bool HasElseBranch,
ImmutableArray<string> CoveragePoints);
/// <summary>
/// A coverage path through the policy (for test generation).
/// </summary>
public sealed record PolicyCoveragePath(
string PathId,
ImmutableArray<string> RuleSequence,
ImmutableArray<PolicyBranchDecision> Decisions,
string PathHash);
/// <summary>
/// A branch decision point.
/// </summary>
public sealed record PolicyBranchDecision(
string RuleName,
bool TookThenBranch,
string ConditionHash);

View File

@@ -0,0 +1,988 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using StellaOps.PolicyDsl;
namespace StellaOps.Policy.Engine.Compilation;
/// <summary>
/// Extracts comprehensive metadata from compiled policy IR documents.
/// Generates symbol tables, rule indices, documentation, coverage metadata, and deterministic hashes.
/// </summary>
internal sealed class PolicyMetadataExtractor
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
/// <summary>
/// Extracts all metadata from a compiled policy document.
/// </summary>
public PolicyCompileMetadata Extract(PolicyIrDocument document, ImmutableArray<byte> canonicalRepresentation)
{
ArgumentNullException.ThrowIfNull(document);
var symbolTable = ExtractSymbolTable(document);
var ruleIndex = BuildRuleIndex(document);
var documentation = ExtractDocumentation(document);
var coverageMetadata = BuildCoverageMetadata(document);
var hashes = ComputeHashes(document, canonicalRepresentation);
return new PolicyCompileMetadata(
symbolTable,
ruleIndex,
documentation,
coverageMetadata,
hashes);
}
#region Symbol Table Extraction
private PolicySymbolTable ExtractSymbolTable(PolicyIrDocument document)
{
var symbols = new List<PolicySymbol>();
var variables = new List<PolicyVariableDefinition>();
var referencesByName = new Dictionary<string, List<PolicySymbolReference>>();
// Extract profile symbols
if (!document.Profiles.IsDefaultOrEmpty)
{
foreach (var profile in document.Profiles)
{
symbols.Add(new PolicySymbol(
profile.Name,
PolicySymbolKind.Profile,
"profile",
new PolicySymbolScope(null, profile.Name, true),
ImmutableArray<PolicySymbolReference>.Empty));
if (!profile.Maps.IsDefaultOrEmpty)
{
foreach (var map in profile.Maps)
{
symbols.Add(new PolicySymbol(
map.Name,
PolicySymbolKind.ProfileMap,
"map",
new PolicySymbolScope(null, profile.Name, false),
ImmutableArray<PolicySymbolReference>.Empty));
}
}
if (!profile.Environments.IsDefaultOrEmpty)
{
foreach (var env in profile.Environments)
{
symbols.Add(new PolicySymbol(
env.Name,
PolicySymbolKind.ProfileEnv,
"env",
new PolicySymbolScope(null, profile.Name, false),
ImmutableArray<PolicySymbolReference>.Empty));
// Extract identifiers from environment conditions
if (!env.Entries.IsDefaultOrEmpty)
{
foreach (var entry in env.Entries)
{
ExtractExpressionReferences(entry.Condition, null, profile.Name, referencesByName);
}
}
}
}
if (!profile.Scalars.IsDefaultOrEmpty)
{
foreach (var scalar in profile.Scalars)
{
symbols.Add(new PolicySymbol(
scalar.Name,
PolicySymbolKind.ProfileScalar,
InferLiteralType(scalar.Value),
new PolicySymbolScope(null, profile.Name, false),
ImmutableArray<PolicySymbolReference>.Empty));
}
}
}
}
// Extract rule symbols and variable definitions
if (!document.Rules.IsDefaultOrEmpty)
{
foreach (var rule in document.Rules)
{
symbols.Add(new PolicySymbol(
rule.Name,
PolicySymbolKind.Rule,
"rule",
new PolicySymbolScope(rule.Name, null, true),
ImmutableArray<PolicySymbolReference>.Empty));
// Extract identifiers from rule condition
ExtractExpressionReferences(rule.When, rule.Name, null, referencesByName);
// Extract from then actions
if (!rule.ThenActions.IsDefaultOrEmpty)
{
foreach (var action in rule.ThenActions)
{
ExtractActionReferences(action, rule.Name, referencesByName, variables);
}
}
// Extract from else actions
if (!rule.ElseActions.IsDefaultOrEmpty)
{
foreach (var action in rule.ElseActions)
{
ExtractActionReferences(action, rule.Name, referencesByName, variables);
}
}
}
}
// Extract metadata symbols
foreach (var (key, _) in document.Metadata)
{
symbols.Add(new PolicySymbol(
key,
PolicySymbolKind.Metadata,
"metadata",
new PolicySymbolScope(null, null, true),
ImmutableArray<PolicySymbolReference>.Empty));
}
// Extract settings symbols
foreach (var (key, _) in document.Settings)
{
symbols.Add(new PolicySymbol(
key,
PolicySymbolKind.Setting,
"setting",
new PolicySymbolScope(null, null, true),
ImmutableArray<PolicySymbolReference>.Empty));
}
return new PolicySymbolTable(
symbols.ToImmutableArray(),
GetBuiltInFunctions(),
variables.ToImmutableArray(),
referencesByName.ToImmutableDictionary(
kvp => kvp.Key,
kvp => kvp.Value.ToImmutableArray()));
}
private void ExtractExpressionReferences(
PolicyExpression? expression,
string? ruleName,
string? profileName,
Dictionary<string, List<PolicySymbolReference>> referencesByName)
{
if (expression is null) return;
switch (expression)
{
case PolicyIdentifierExpression identifier:
AddReference(referencesByName, identifier.Name, ruleName, profileName, PolicySymbolUsage.Read);
break;
case PolicyMemberAccessExpression member:
ExtractExpressionReferences(member.Target, ruleName, profileName, referencesByName);
// Member name is not a standalone identifier
break;
case PolicyInvocationExpression invocation:
ExtractExpressionReferences(invocation.Target, ruleName, profileName, referencesByName);
if (!invocation.Arguments.IsDefaultOrEmpty)
{
foreach (var arg in invocation.Arguments)
{
ExtractExpressionReferences(arg, ruleName, profileName, referencesByName);
}
}
break;
case PolicyIndexerExpression indexer:
ExtractExpressionReferences(indexer.Target, ruleName, profileName, referencesByName);
ExtractExpressionReferences(indexer.Index, ruleName, profileName, referencesByName);
break;
case PolicyUnaryExpression unary:
ExtractExpressionReferences(unary.Operand, ruleName, profileName, referencesByName);
break;
case PolicyBinaryExpression binary:
ExtractExpressionReferences(binary.Left, ruleName, profileName, referencesByName);
ExtractExpressionReferences(binary.Right, ruleName, profileName, referencesByName);
break;
case PolicyListExpression list when !list.Items.IsDefaultOrEmpty:
foreach (var item in list.Items)
{
ExtractExpressionReferences(item, ruleName, profileName, referencesByName);
}
break;
}
}
private void ExtractActionReferences(
PolicyIrAction action,
string ruleName,
Dictionary<string, List<PolicySymbolReference>> referencesByName,
List<PolicyVariableDefinition> variables)
{
switch (action)
{
case PolicyIrAssignmentAction assignment:
if (!assignment.Target.IsDefaultOrEmpty)
{
var varName = string.Join(".", assignment.Target);
AddReference(referencesByName, varName, ruleName, null, PolicySymbolUsage.Write);
variables.Add(new PolicyVariableDefinition(
varName,
InferExpressionType(assignment.Value),
SummarizeExpression(assignment.Value),
ruleName,
true));
}
ExtractExpressionReferences(assignment.Value, ruleName, null, referencesByName);
break;
case PolicyIrAnnotateAction annotate:
if (!annotate.Target.IsDefaultOrEmpty)
{
var targetName = string.Join(".", annotate.Target);
AddReference(referencesByName, targetName, ruleName, null, PolicySymbolUsage.Write);
}
ExtractExpressionReferences(annotate.Value, ruleName, null, referencesByName);
break;
case PolicyIrIgnoreAction ignore:
ExtractExpressionReferences(ignore.Until, ruleName, null, referencesByName);
break;
case PolicyIrEscalateAction escalate:
ExtractExpressionReferences(escalate.To, ruleName, null, referencesByName);
ExtractExpressionReferences(escalate.When, ruleName, null, referencesByName);
break;
case PolicyIrRequireVexAction require:
foreach (var condition in require.Conditions.Values)
{
ExtractExpressionReferences(condition, ruleName, null, referencesByName);
}
break;
case PolicyIrWarnAction warn:
ExtractExpressionReferences(warn.Message, ruleName, null, referencesByName);
break;
case PolicyIrDeferAction defer:
ExtractExpressionReferences(defer.Until, ruleName, null, referencesByName);
break;
}
}
private static void AddReference(
Dictionary<string, List<PolicySymbolReference>> referencesByName,
string symbolName,
string? ruleName,
string? profileName,
PolicySymbolUsage usage)
{
if (!referencesByName.TryGetValue(symbolName, out var refs))
{
refs = [];
referencesByName[symbolName] = refs;
}
refs.Add(new PolicySymbolReference(
symbolName,
ruleName ?? profileName ?? "global",
null,
null,
usage));
}
private static string? InferLiteralType(PolicyIrLiteral literal) => literal switch
{
PolicyIrStringLiteral => "string",
PolicyIrNumberLiteral => "number",
PolicyIrBooleanLiteral => "boolean",
PolicyIrListLiteral => "list",
_ => null
};
private static string? InferExpressionType(PolicyExpression? expression) => expression switch
{
PolicyLiteralExpression lit => lit.Value switch
{
string => "string",
decimal or double or float or int or long => "number",
bool => "boolean",
null => "null",
_ => "unknown"
},
PolicyListExpression => "list",
PolicyBinaryExpression bin => bin.Operator switch
{
PolicyBinaryOperator.And or PolicyBinaryOperator.Or or PolicyBinaryOperator.Equal or
PolicyBinaryOperator.NotEqual or PolicyBinaryOperator.LessThan or PolicyBinaryOperator.LessThanOrEqual or
PolicyBinaryOperator.GreaterThan or PolicyBinaryOperator.GreaterThanOrEqual or
PolicyBinaryOperator.In or PolicyBinaryOperator.NotIn => "boolean",
_ => "unknown"
},
PolicyUnaryExpression { Operator: PolicyUnaryOperator.Not } => "boolean",
_ => null
};
private static ImmutableArray<PolicyFunctionSignature> GetBuiltInFunctions()
{
return
[
new PolicyFunctionSignature(
"contains",
"Checks if a string contains a substring or a list contains an element",
[
new PolicyParameterInfo("haystack", "string|list", false, null, "The string or list to search in"),
new PolicyParameterInfo("needle", "any", false, null, "The value to search for")
],
"boolean",
["contains(advisory.id, \"CVE\")", "contains(tags, \"critical\")"]),
new PolicyFunctionSignature(
"startsWith",
"Checks if a string starts with a prefix",
[
new PolicyParameterInfo("value", "string", false, null, "The string to check"),
new PolicyParameterInfo("prefix", "string", false, null, "The prefix to match")
],
"boolean",
["startsWith(component.purl, \"pkg:npm\")"]),
new PolicyFunctionSignature(
"endsWith",
"Checks if a string ends with a suffix",
[
new PolicyParameterInfo("value", "string", false, null, "The string to check"),
new PolicyParameterInfo("suffix", "string", false, null, "The suffix to match")
],
"boolean",
["endsWith(component.name, \"-dev\")"]),
new PolicyFunctionSignature(
"matches",
"Checks if a string matches a regex pattern",
[
new PolicyParameterInfo("value", "string", false, null, "The string to check"),
new PolicyParameterInfo("pattern", "string", false, null, "The regex pattern")
],
"boolean",
["matches(advisory.id, \"^CVE-202[3-9]\")"]),
new PolicyFunctionSignature(
"length",
"Returns the length of a string or list",
[
new PolicyParameterInfo("value", "string|list", false, null, "The value to measure")
],
"number",
["length(component.name)", "length(tags)"]),
new PolicyFunctionSignature(
"lower",
"Converts a string to lowercase",
[
new PolicyParameterInfo("value", "string", false, null, "The string to convert")
],
"string",
["lower(component.ecosystem)"]),
new PolicyFunctionSignature(
"upper",
"Converts a string to uppercase",
[
new PolicyParameterInfo("value", "string", false, null, "The string to convert")
],
"string",
["upper(severity)"]),
new PolicyFunctionSignature(
"now",
"Returns the current evaluation timestamp (deterministic within a run)",
[],
"datetime",
["now()"]),
new PolicyFunctionSignature(
"days",
"Creates a duration in days",
[
new PolicyParameterInfo("count", "number", false, null, "Number of days")
],
"duration",
["days(30)", "days(7)"]),
new PolicyFunctionSignature(
"semver",
"Parses a semantic version string",
[
new PolicyParameterInfo("version", "string", false, null, "The version string to parse")
],
"semver",
["semver(component.version)"]),
new PolicyFunctionSignature(
"semverCompare",
"Compares two semantic versions",
[
new PolicyParameterInfo("left", "string|semver", false, null, "First version"),
new PolicyParameterInfo("right", "string|semver", false, null, "Second version")
],
"number",
["semverCompare(component.version, \"1.0.0\")"])
];
}
#endregion
#region Rule Index Building
private PolicyRuleIndex BuildRuleIndex(PolicyIrDocument document)
{
var rules = new List<PolicyRuleEntry>();
var byName = new Dictionary<string, PolicyRuleEntry>(StringComparer.Ordinal);
var byPriority = new Dictionary<int, List<PolicyRuleEntry>>();
var allActionTypes = new HashSet<string>();
var allIdentifiers = new HashSet<string>();
if (!document.Rules.IsDefaultOrEmpty)
{
for (var i = 0; i < document.Rules.Length; i++)
{
var rule = document.Rules[i];
var thenActionTypes = GetActionTypes(rule.ThenActions, allActionTypes);
var elseActionTypes = GetActionTypes(rule.ElseActions, allActionTypes);
var (identifiers, functions) = ExtractRuleReferences(rule);
foreach (var id in identifiers)
{
allIdentifiers.Add(id);
}
var entry = new PolicyRuleEntry(
rule.Name,
rule.Priority,
i,
SummarizeExpression(rule.When) ?? "true",
thenActionTypes,
elseActionTypes,
rule.Because,
identifiers,
functions);
rules.Add(entry);
byName[rule.Name] = entry;
if (!byPriority.TryGetValue(rule.Priority, out var priorityList))
{
priorityList = [];
byPriority[rule.Priority] = priorityList;
}
priorityList.Add(entry);
}
}
return new PolicyRuleIndex(
rules.ToImmutableArray(),
byName.ToImmutableDictionary(),
byPriority.ToImmutableDictionary(kvp => kvp.Key, kvp => kvp.Value.ToImmutableArray()),
allActionTypes.Order().ToImmutableArray(),
allIdentifiers.Order().ToImmutableArray());
}
private static ImmutableArray<string> GetActionTypes(
ImmutableArray<PolicyIrAction> actions,
HashSet<string> allActionTypes)
{
if (actions.IsDefaultOrEmpty) return [];
var types = new List<string>();
foreach (var action in actions)
{
var typeName = action switch
{
PolicyIrAssignmentAction => "assign",
PolicyIrAnnotateAction => "annotate",
PolicyIrIgnoreAction => "ignore",
PolicyIrEscalateAction => "escalate",
PolicyIrRequireVexAction => "requireVex",
PolicyIrWarnAction => "warn",
PolicyIrDeferAction => "defer",
_ => "unknown"
};
types.Add(typeName);
allActionTypes.Add(typeName);
}
return types.ToImmutableArray();
}
private static (ImmutableArray<string> Identifiers, ImmutableArray<string> Functions) ExtractRuleReferences(PolicyIrRule rule)
{
var identifiers = new HashSet<string>();
var functions = new HashSet<string>();
CollectExpressionReferences(rule.When, identifiers, functions);
if (!rule.ThenActions.IsDefaultOrEmpty)
{
foreach (var action in rule.ThenActions)
{
CollectActionReferences(action, identifiers, functions);
}
}
if (!rule.ElseActions.IsDefaultOrEmpty)
{
foreach (var action in rule.ElseActions)
{
CollectActionReferences(action, identifiers, functions);
}
}
return (identifiers.Order().ToImmutableArray(), functions.Order().ToImmutableArray());
}
private static void CollectExpressionReferences(
PolicyExpression? expression,
HashSet<string> identifiers,
HashSet<string> functions)
{
if (expression is null) return;
switch (expression)
{
case PolicyIdentifierExpression id:
identifiers.Add(id.Name);
break;
case PolicyMemberAccessExpression member:
CollectExpressionReferences(member.Target, identifiers, functions);
break;
case PolicyInvocationExpression invocation:
if (invocation.Target is PolicyIdentifierExpression funcId)
{
functions.Add(funcId.Name);
}
else
{
CollectExpressionReferences(invocation.Target, identifiers, functions);
}
if (!invocation.Arguments.IsDefaultOrEmpty)
{
foreach (var arg in invocation.Arguments)
{
CollectExpressionReferences(arg, identifiers, functions);
}
}
break;
case PolicyIndexerExpression indexer:
CollectExpressionReferences(indexer.Target, identifiers, functions);
CollectExpressionReferences(indexer.Index, identifiers, functions);
break;
case PolicyUnaryExpression unary:
CollectExpressionReferences(unary.Operand, identifiers, functions);
break;
case PolicyBinaryExpression binary:
CollectExpressionReferences(binary.Left, identifiers, functions);
CollectExpressionReferences(binary.Right, identifiers, functions);
break;
case PolicyListExpression list when !list.Items.IsDefaultOrEmpty:
foreach (var item in list.Items)
{
CollectExpressionReferences(item, identifiers, functions);
}
break;
}
}
private static void CollectActionReferences(
PolicyIrAction action,
HashSet<string> identifiers,
HashSet<string> functions)
{
switch (action)
{
case PolicyIrAssignmentAction assign:
CollectExpressionReferences(assign.Value, identifiers, functions);
break;
case PolicyIrAnnotateAction annotate:
CollectExpressionReferences(annotate.Value, identifiers, functions);
break;
case PolicyIrIgnoreAction ignore:
CollectExpressionReferences(ignore.Until, identifiers, functions);
break;
case PolicyIrEscalateAction escalate:
CollectExpressionReferences(escalate.To, identifiers, functions);
CollectExpressionReferences(escalate.When, identifiers, functions);
break;
case PolicyIrRequireVexAction require:
foreach (var condition in require.Conditions.Values)
{
CollectExpressionReferences(condition, identifiers, functions);
}
break;
case PolicyIrWarnAction warn:
CollectExpressionReferences(warn.Message, identifiers, functions);
break;
case PolicyIrDeferAction defer:
CollectExpressionReferences(defer.Until, identifiers, functions);
break;
}
}
#endregion
#region Documentation Extraction
private PolicyDocumentation ExtractDocumentation(PolicyIrDocument document)
{
string? description = null;
var tags = ImmutableArray<string>.Empty;
string? author = null;
var customMetadata = new Dictionary<string, string>();
// Extract from metadata
if (document.Metadata.TryGetValue("description", out var descLit) && descLit is PolicyIrStringLiteral descStr)
{
description = descStr.Value;
}
if (document.Metadata.TryGetValue("author", out var authorLit) && authorLit is PolicyIrStringLiteral authorStr)
{
author = authorStr.Value;
}
if (document.Metadata.TryGetValue("tags", out var tagsLit) && tagsLit is PolicyIrListLiteral tagsList)
{
tags = tagsList.Items
.OfType<PolicyIrStringLiteral>()
.Select(s => s.Value)
.ToImmutableArray();
}
foreach (var (key, value) in document.Metadata)
{
if (key is not ("description" or "author" or "tags") && value is PolicyIrStringLiteral strVal)
{
customMetadata[key] = strVal.Value;
}
}
// Extract rule documentation
var ruleDocs = new List<PolicyRuleDocumentation>();
if (!document.Rules.IsDefaultOrEmpty)
{
foreach (var rule in document.Rules)
{
var actionDescs = new List<string>();
if (!rule.ThenActions.IsDefaultOrEmpty)
{
foreach (var action in rule.ThenActions)
{
actionDescs.Add($"then: {DescribeAction(action)}");
}
}
if (!rule.ElseActions.IsDefaultOrEmpty)
{
foreach (var action in rule.ElseActions)
{
actionDescs.Add($"else: {DescribeAction(action)}");
}
}
ruleDocs.Add(new PolicyRuleDocumentation(
rule.Name,
rule.Priority,
rule.Because,
SummarizeExpression(rule.When) ?? "true",
actionDescs.ToImmutableArray()));
}
}
// Extract profile documentation
var profileDocs = new List<PolicyProfileDocumentation>();
if (!document.Profiles.IsDefaultOrEmpty)
{
foreach (var profile in document.Profiles)
{
profileDocs.Add(new PolicyProfileDocumentation(
profile.Name,
profile.Maps.IsDefaultOrEmpty
? []
: profile.Maps.Select(m => m.Name).ToImmutableArray(),
profile.Environments.IsDefaultOrEmpty
? []
: profile.Environments.Select(e => e.Name).ToImmutableArray(),
profile.Scalars.IsDefaultOrEmpty
? []
: profile.Scalars.Select(s => s.Name).ToImmutableArray()));
}
}
return new PolicyDocumentation(
description,
tags,
author,
customMetadata.ToImmutableDictionary(),
ruleDocs.ToImmutableArray(),
profileDocs.ToImmutableArray());
}
private static string DescribeAction(PolicyIrAction action) => action switch
{
PolicyIrAssignmentAction a => $"assign {string.Join(".", a.Target)} = {SummarizeExpression(a.Value)}",
PolicyIrAnnotateAction a => $"annotate {string.Join(".", a.Target)} = {SummarizeExpression(a.Value)}",
PolicyIrIgnoreAction a => $"ignore{(a.Until is not null ? $" until {SummarizeExpression(a.Until)}" : "")}{(a.Because is not null ? $" because \"{a.Because}\"" : "")}",
PolicyIrEscalateAction a => $"escalate{(a.To is not null ? $" to {SummarizeExpression(a.To)}" : "")}{(a.When is not null ? $" when {SummarizeExpression(a.When)}" : "")}",
PolicyIrRequireVexAction a => $"requireVex({string.Join(", ", a.Conditions.Keys)})",
PolicyIrWarnAction a => $"warn {SummarizeExpression(a.Message)}",
PolicyIrDeferAction a => $"defer{(a.Until is not null ? $" until {SummarizeExpression(a.Until)}" : "")}",
_ => "unknown"
};
#endregion
#region Coverage Metadata Building
private PolicyRuleCoverageMetadata BuildCoverageMetadata(PolicyIrDocument document)
{
var rules = new List<PolicyRuleCoverageEntry>();
var actionTypeCounts = new Dictionary<string, int>();
var totalConditions = 0;
var totalActions = 0;
if (!document.Rules.IsDefaultOrEmpty)
{
foreach (var rule in document.Rules)
{
totalConditions++;
var thenCount = rule.ThenActions.IsDefaultOrEmpty ? 0 : rule.ThenActions.Length;
var elseCount = rule.ElseActions.IsDefaultOrEmpty ? 0 : rule.ElseActions.Length;
totalActions += thenCount + elseCount;
// Count action types
CountActionTypes(rule.ThenActions, actionTypeCounts);
CountActionTypes(rule.ElseActions, actionTypeCounts);
// Generate coverage points
var coveragePoints = new List<string>
{
$"{rule.Name}:condition"
};
if (thenCount > 0)
{
coveragePoints.Add($"{rule.Name}:then");
for (var i = 0; i < thenCount; i++)
{
coveragePoints.Add($"{rule.Name}:then[{i}]");
}
}
if (elseCount > 0)
{
coveragePoints.Add($"{rule.Name}:else");
for (var i = 0; i < elseCount; i++)
{
coveragePoints.Add($"{rule.Name}:else[{i}]");
}
}
rules.Add(new PolicyRuleCoverageEntry(
rule.Name,
rule.Priority,
ComputeExpressionHash(rule.When),
thenCount,
elseCount,
elseCount > 0,
coveragePoints.ToImmutableArray()));
}
}
// Generate coverage paths (simplified - exhaustive paths for small policies)
var coveragePaths = GenerateCoveragePaths(document.Rules);
return new PolicyRuleCoverageMetadata(
rules.ToImmutableArray(),
rules.Count,
totalConditions,
totalActions,
actionTypeCounts.ToImmutableDictionary(),
coveragePaths);
}
private static void CountActionTypes(ImmutableArray<PolicyIrAction> actions, Dictionary<string, int> counts)
{
if (actions.IsDefaultOrEmpty) return;
foreach (var action in actions)
{
var typeName = action switch
{
PolicyIrAssignmentAction => "assign",
PolicyIrAnnotateAction => "annotate",
PolicyIrIgnoreAction => "ignore",
PolicyIrEscalateAction => "escalate",
PolicyIrRequireVexAction => "requireVex",
PolicyIrWarnAction => "warn",
PolicyIrDeferAction => "defer",
_ => "unknown"
};
counts.TryGetValue(typeName, out var count);
counts[typeName] = count + 1;
}
}
private static ImmutableArray<PolicyCoveragePath> GenerateCoveragePaths(ImmutableArray<PolicyIrRule> rules)
{
if (rules.IsDefaultOrEmpty) return [];
var paths = new List<PolicyCoveragePath>();
// For small policies, generate all 2^n paths
// For larger policies, generate key paths only
var ruleCount = rules.Length;
var maxPaths = ruleCount <= 10 ? (1 << ruleCount) : 100;
for (var pathIndex = 0; pathIndex < maxPaths && pathIndex < (1 << ruleCount); pathIndex++)
{
var sequence = new List<string>();
var decisions = new List<PolicyBranchDecision>();
var pathHashBuilder = new StringBuilder();
for (var ruleIndex = 0; ruleIndex < ruleCount; ruleIndex++)
{
var rule = rules[ruleIndex];
var tookThen = (pathIndex & (1 << ruleIndex)) != 0;
sequence.Add(rule.Name);
decisions.Add(new PolicyBranchDecision(
rule.Name,
tookThen,
ComputeExpressionHash(rule.When)));
pathHashBuilder.Append(rule.Name);
pathHashBuilder.Append(tookThen ? ":T" : ":F");
pathHashBuilder.Append('|');
}
var pathId = $"path_{pathIndex:D4}";
var pathHash = ComputeStringHash(pathHashBuilder.ToString());
paths.Add(new PolicyCoveragePath(
pathId,
sequence.ToImmutableArray(),
decisions.ToImmutableArray(),
pathHash));
}
return paths.ToImmutableArray();
}
#endregion
#region Hash Computation
private PolicyDeterministicHashes ComputeHashes(PolicyIrDocument document, ImmutableArray<byte> canonicalRepresentation)
{
// Content hash from canonical representation
var contentHash = ComputeHash(canonicalRepresentation.AsSpan());
// Structure hash (rules only)
var structureBuilder = new StringBuilder();
if (!document.Rules.IsDefaultOrEmpty)
{
foreach (var rule in document.Rules)
{
structureBuilder.Append(rule.Name);
structureBuilder.Append(':');
structureBuilder.Append(rule.Priority);
structureBuilder.Append(':');
structureBuilder.Append(ComputeExpressionHash(rule.When));
structureBuilder.Append('|');
}
}
var structureHash = ComputeStringHash(structureBuilder.ToString());
// Ordering hash (names and priorities only)
var orderingBuilder = new StringBuilder();
if (!document.Rules.IsDefaultOrEmpty)
{
foreach (var rule in document.Rules)
{
orderingBuilder.Append(rule.Name);
orderingBuilder.Append(':');
orderingBuilder.Append(rule.Priority);
orderingBuilder.Append('|');
}
}
var orderingHash = ComputeStringHash(orderingBuilder.ToString());
// Identity hash (combination)
var identityBuilder = new StringBuilder();
identityBuilder.Append(document.Name);
identityBuilder.Append(':');
identityBuilder.Append(document.Syntax);
identityBuilder.Append(':');
identityBuilder.Append(contentHash);
var identityHash = ComputeStringHash(identityBuilder.ToString());
return new PolicyDeterministicHashes(contentHash, structureHash, orderingHash, identityHash);
}
private static string ComputeExpressionHash(PolicyExpression? expression)
{
if (expression is null) return "null";
var summary = SummarizeExpression(expression) ?? "empty";
return ComputeStringHash(summary);
}
private static string ComputeStringHash(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
return ComputeHash(bytes);
}
private static string ComputeHash(ReadOnlySpan<byte> bytes)
{
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(bytes, hash);
return Convert.ToHexStringLower(hash);
}
private static string? SummarizeExpression(PolicyExpression? expression, int maxLength = 100)
{
if (expression is null) return null;
var summary = expression switch
{
PolicyLiteralExpression lit => lit.Value?.ToString() ?? "null",
PolicyIdentifierExpression id => id.Name,
PolicyMemberAccessExpression member => $"{SummarizeExpression(member.Target)}.{member.Member}",
PolicyInvocationExpression inv => $"{SummarizeExpression(inv.Target)}({string.Join(", ", inv.Arguments.IsDefaultOrEmpty ? [] : inv.Arguments.Select(a => SummarizeExpression(a)))})",
PolicyIndexerExpression idx => $"{SummarizeExpression(idx.Target)}[{SummarizeExpression(idx.Index)}]",
PolicyUnaryExpression unary => $"{unary.Operator} {SummarizeExpression(unary.Operand)}",
PolicyBinaryExpression binary => $"{SummarizeExpression(binary.Left)} {binary.Operator} {SummarizeExpression(binary.Right)}",
PolicyListExpression list => $"[{string.Join(", ", list.Items.IsDefaultOrEmpty ? [] : list.Items.Take(3).Select(i => SummarizeExpression(i)))}{(list.Items.Length > 3 ? ", ..." : "")}]",
_ => expression.GetType().Name
};
return summary.Length > maxLength ? summary[..(maxLength - 3)] + "..." : summary;
}
#endregion
}

View File

@@ -0,0 +1,154 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Policy.Engine.Caching;
using StellaOps.Policy.Engine.EffectiveDecisionMap;
using StellaOps.Policy.Engine.Events;
using StellaOps.Policy.Engine.ExceptionCache;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.WhatIfSimulation;
using StellaOps.Policy.Engine.Workers;
using StackExchange.Redis;
namespace StellaOps.Policy.Engine.DependencyInjection;
/// <summary>
/// Extension methods for registering Policy Engine services.
/// </summary>
public static class PolicyEngineServiceCollectionExtensions
{
/// <summary>
/// Adds the core Policy Engine services to the service collection.
/// Includes TimeProvider, cache, and core evaluation services.
/// </summary>
public static IServiceCollection AddPolicyEngineCore(this IServiceCollection services)
{
// Time provider
services.TryAddSingleton(TimeProvider.System);
// Core compilation and evaluation services
services.TryAddSingleton<PolicyCompilationService>();
// Cache
services.TryAddSingleton<IPolicyEvaluationCache, InMemoryPolicyEvaluationCache>();
// Runtime evaluation
services.TryAddSingleton<PolicyRuntimeEvaluationService>();
// Bundle service
services.TryAddSingleton<PolicyBundleService>();
// Decision service
services.TryAddSingleton<PolicyDecisionService>();
return services;
}
/// <summary>
/// Adds the Policy Engine event pipeline services.
/// Includes event processor and job scheduler.
/// </summary>
public static IServiceCollection AddPolicyEngineEventPipeline(this IServiceCollection services)
{
// Event processor (implements both IPolicyEffectiveEventPublisher and IReEvaluationJobScheduler)
services.TryAddSingleton<PolicyEventProcessor>();
services.TryAddSingleton<IPolicyEffectiveEventPublisher>(sp =>
sp.GetRequiredService<PolicyEventProcessor>());
services.TryAddSingleton<IReEvaluationJobScheduler>(sp =>
sp.GetRequiredService<PolicyEventProcessor>());
return services;
}
/// <summary>
/// Adds the Policy Engine evaluation worker services.
/// Includes background host for continuous job processing.
/// </summary>
public static IServiceCollection AddPolicyEngineWorker(this IServiceCollection services)
{
// Worker service
services.TryAddSingleton<PolicyEvaluationWorkerService>();
// Background host
services.AddHostedService<PolicyEvaluationWorkerHost>();
return services;
}
/// <summary>
/// Adds the Policy Engine explainer services.
/// Requires IExplainTraceRepository and IPolicyPackRepository to be registered.
/// </summary>
public static IServiceCollection AddPolicyEngineExplainer(this IServiceCollection services)
{
services.TryAddSingleton<PolicyExplainerService>();
return services;
}
/// <summary>
/// Adds the effective decision map services for Graph overlays.
/// Requires Redis connection to be registered.
/// </summary>
public static IServiceCollection AddEffectiveDecisionMap(this IServiceCollection services)
{
services.TryAddSingleton<IEffectiveDecisionMap, RedisEffectiveDecisionMap>();
return services;
}
/// <summary>
/// Adds the exception effective cache for fast exception lookups during policy evaluation.
/// Requires Redis connection and IExceptionRepository to be registered.
/// </summary>
public static IServiceCollection AddExceptionEffectiveCache(this IServiceCollection services)
{
services.TryAddSingleton<IExceptionEffectiveCache, RedisExceptionEffectiveCache>();
return services;
}
/// <summary>
/// Adds the What-If simulation service for Graph APIs.
/// Supports hypothetical SBOM diffs and draft policies without persisting results.
/// </summary>
public static IServiceCollection AddWhatIfSimulation(this IServiceCollection services)
{
services.TryAddSingleton<WhatIfSimulationService>();
return services;
}
/// <summary>
/// Adds Redis connection for effective decision map and evaluation cache.
/// </summary>
public static IServiceCollection AddPolicyEngineRedis(
this IServiceCollection services,
string connectionString)
{
services.TryAddSingleton<IConnectionMultiplexer>(sp =>
ConnectionMultiplexer.Connect(connectionString));
return services;
}
/// <summary>
/// Adds all Policy Engine services with default configuration.
/// </summary>
public static IServiceCollection AddPolicyEngine(this IServiceCollection services)
{
services.AddPolicyEngineCore();
services.AddPolicyEngineEventPipeline();
services.AddPolicyEngineWorker();
services.AddPolicyEngineExplainer();
return services;
}
/// <summary>
/// Adds all Policy Engine services with configuration binding.
/// </summary>
public static IServiceCollection AddPolicyEngine(
this IServiceCollection services,
Action<PolicyEngineOptions> configure)
{
services.Configure(configure);
return services.AddPolicyEngine();
}
}

View File

@@ -1,5 +1,6 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using StellaOps.PolicyDsl;
namespace StellaOps.Policy.Engine.Domain;
@@ -113,6 +114,7 @@ internal sealed record PolicyBundleRecord(
int Size,
DateTimeOffset CreatedAt,
ImmutableArray<byte> Payload,
PolicyIrDocument? CompiledDocument = null,
PolicyAocMetadata? AocMetadata = null);
/// <summary>

View File

@@ -0,0 +1,221 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.EffectiveDecisionMap;
/// <summary>
/// Represents an effective policy decision for an asset/snapshot.
/// Stored in Redis for Graph overlay lookups.
/// </summary>
public sealed record EffectiveDecisionEntry
{
/// <summary>
/// Tenant identifier.
/// </summary>
[JsonPropertyName("tenant_id")]
public required string TenantId { get; init; }
/// <summary>
/// Asset identifier (PURL or SBOM ID).
/// </summary>
[JsonPropertyName("asset_id")]
public required string AssetId { get; init; }
/// <summary>
/// Snapshot identifier (SBOM version or evaluation run).
/// </summary>
[JsonPropertyName("snapshot_id")]
public required string SnapshotId { get; init; }
/// <summary>
/// Policy pack ID that produced this decision.
/// </summary>
[JsonPropertyName("pack_id")]
public required string PackId { get; init; }
/// <summary>
/// Policy pack version.
/// </summary>
[JsonPropertyName("pack_version")]
public required int PackVersion { get; init; }
/// <summary>
/// Final decision status (allow, warn, deny, blocked).
/// </summary>
[JsonPropertyName("status")]
public required string Status { get; init; }
/// <summary>
/// Severity level if applicable.
/// </summary>
[JsonPropertyName("severity")]
public string? Severity { get; init; }
/// <summary>
/// Rule name that determined the decision.
/// </summary>
[JsonPropertyName("rule_name")]
public string? RuleName { get; init; }
/// <summary>
/// Priority of the applied rule.
/// </summary>
[JsonPropertyName("priority")]
public int? Priority { get; init; }
/// <summary>
/// Exception ID if an exception was applied.
/// </summary>
[JsonPropertyName("exception_id")]
public string? ExceptionId { get; init; }
/// <summary>
/// Count of advisories affecting this asset.
/// </summary>
[JsonPropertyName("advisory_count")]
public int AdvisoryCount { get; init; }
/// <summary>
/// Count of critical/high severity findings.
/// </summary>
[JsonPropertyName("high_severity_count")]
public int HighSeverityCount { get; init; }
/// <summary>
/// Aggregated annotations from the decision.
/// </summary>
[JsonPropertyName("annotations")]
public ImmutableDictionary<string, string> Annotations { get; init; } = ImmutableDictionary<string, string>.Empty;
/// <summary>
/// Version counter for cache coherency.
/// </summary>
[JsonPropertyName("version")]
public required long Version { get; init; }
/// <summary>
/// When this entry was evaluated.
/// </summary>
[JsonPropertyName("evaluated_at")]
public required DateTimeOffset EvaluatedAt { get; init; }
/// <summary>
/// When this entry expires.
/// </summary>
[JsonPropertyName("expires_at")]
public required DateTimeOffset ExpiresAt { get; init; }
/// <summary>
/// Correlation ID for tracing.
/// </summary>
[JsonPropertyName("correlation_id")]
public string? CorrelationId { get; init; }
}
/// <summary>
/// Result of an effective decision map query.
/// </summary>
public sealed record EffectiveDecisionQueryResult
{
/// <summary>
/// Found entries mapped by asset ID.
/// </summary>
public required IReadOnlyDictionary<string, EffectiveDecisionEntry> Entries { get; init; }
/// <summary>
/// Asset IDs that were not found.
/// </summary>
public required IReadOnlyList<string> NotFound { get; init; }
/// <summary>
/// Current version of the decision map.
/// </summary>
public long MapVersion { get; init; }
/// <summary>
/// Whether the result came from cache.
/// </summary>
public bool FromCache { get; init; }
}
/// <summary>
/// Summary statistics for a snapshot's effective decisions.
/// </summary>
public sealed record EffectiveDecisionSummary
{
/// <summary>
/// Snapshot ID.
/// </summary>
public required string SnapshotId { get; init; }
/// <summary>
/// Total assets evaluated.
/// </summary>
public int TotalAssets { get; init; }
/// <summary>
/// Count by status.
/// </summary>
public required IReadOnlyDictionary<string, int> StatusCounts { get; init; }
/// <summary>
/// Count by severity.
/// </summary>
public required IReadOnlyDictionary<string, int> SeverityCounts { get; init; }
/// <summary>
/// Assets with exceptions applied.
/// </summary>
public int ExceptionCount { get; init; }
/// <summary>
/// Map version at time of summary.
/// </summary>
public long MapVersion { get; init; }
/// <summary>
/// When this summary was computed.
/// </summary>
public DateTimeOffset ComputedAt { get; init; }
}
/// <summary>
/// Filter options for querying effective decisions.
/// </summary>
public sealed record EffectiveDecisionFilter
{
/// <summary>
/// Filter by status values.
/// </summary>
public IReadOnlyList<string>? Statuses { get; init; }
/// <summary>
/// Filter by severity values.
/// </summary>
public IReadOnlyList<string>? Severities { get; init; }
/// <summary>
/// Include only assets with exceptions.
/// </summary>
public bool? HasException { get; init; }
/// <summary>
/// Filter by minimum advisory count.
/// </summary>
public int? MinAdvisoryCount { get; init; }
/// <summary>
/// Filter by minimum high severity count.
/// </summary>
public int? MinHighSeverityCount { get; init; }
/// <summary>
/// Maximum results to return.
/// </summary>
public int Limit { get; init; } = 1000;
/// <summary>
/// Offset for pagination.
/// </summary>
public int Offset { get; init; } = 0;
}

View File

@@ -0,0 +1,144 @@
namespace StellaOps.Policy.Engine.EffectiveDecisionMap;
/// <summary>
/// Interface for effective decision map storage.
/// Maintains policy decisions per asset/snapshot for Graph overlays.
/// </summary>
public interface IEffectiveDecisionMap
{
/// <summary>
/// Sets an effective decision entry.
/// </summary>
Task SetAsync(
string tenantId,
string snapshotId,
EffectiveDecisionEntry entry,
CancellationToken cancellationToken = default);
/// <summary>
/// Sets multiple effective decision entries.
/// </summary>
Task SetBatchAsync(
string tenantId,
string snapshotId,
IEnumerable<EffectiveDecisionEntry> entries,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets an effective decision entry.
/// </summary>
Task<EffectiveDecisionEntry?> GetAsync(
string tenantId,
string snapshotId,
string assetId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets multiple effective decision entries.
/// </summary>
Task<EffectiveDecisionQueryResult> GetBatchAsync(
string tenantId,
string snapshotId,
IReadOnlyList<string> assetIds,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all effective decisions for a snapshot.
/// </summary>
Task<IReadOnlyList<EffectiveDecisionEntry>> GetAllForSnapshotAsync(
string tenantId,
string snapshotId,
EffectiveDecisionFilter? filter = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a summary of effective decisions for a snapshot.
/// </summary>
Task<EffectiveDecisionSummary> GetSummaryAsync(
string tenantId,
string snapshotId,
CancellationToken cancellationToken = default);
/// <summary>
/// Invalidates a specific entry.
/// </summary>
Task InvalidateAsync(
string tenantId,
string snapshotId,
string assetId,
CancellationToken cancellationToken = default);
/// <summary>
/// Invalidates all entries for a snapshot.
/// </summary>
Task InvalidateSnapshotAsync(
string tenantId,
string snapshotId,
CancellationToken cancellationToken = default);
/// <summary>
/// Invalidates all entries for a tenant.
/// </summary>
Task InvalidateTenantAsync(
string tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current map version for a snapshot.
/// </summary>
Task<long> GetVersionAsync(
string tenantId,
string snapshotId,
CancellationToken cancellationToken = default);
/// <summary>
/// Increments and returns the new map version for a snapshot.
/// </summary>
Task<long> IncrementVersionAsync(
string tenantId,
string snapshotId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets statistics about the effective decision map.
/// </summary>
Task<EffectiveDecisionMapStats> GetStatsAsync(
string? tenantId = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Statistics about the effective decision map.
/// </summary>
public sealed record EffectiveDecisionMapStats
{
/// <summary>
/// Total entries across all tenants/snapshots.
/// </summary>
public long TotalEntries { get; init; }
/// <summary>
/// Total snapshots tracked.
/// </summary>
public long TotalSnapshots { get; init; }
/// <summary>
/// Memory used in bytes (if available).
/// </summary>
public long? MemoryUsedBytes { get; init; }
/// <summary>
/// Entries expiring in the next hour.
/// </summary>
public long ExpiringWithinHour { get; init; }
/// <summary>
/// Last eviction timestamp.
/// </summary>
public DateTimeOffset? LastEvictionAt { get; init; }
/// <summary>
/// Count of entries evicted in last eviction run.
/// </summary>
public long LastEvictionCount { get; init; }
}

View File

@@ -0,0 +1,501 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Telemetry;
using StackExchange.Redis;
namespace StellaOps.Policy.Engine.EffectiveDecisionMap;
/// <summary>
/// Redis-backed effective decision map with versioning and TTL-based eviction.
/// Key structure:
/// - Entry: stellaops:edm:{tenant}:{snapshot}:e:{asset} -> JSON entry
/// - Version: stellaops:edm:{tenant}:{snapshot}:v -> integer version
/// - Index: stellaops:edm:{tenant}:{snapshot}:idx -> sorted set of assets by evaluated_at
/// </summary>
internal sealed class RedisEffectiveDecisionMap : IEffectiveDecisionMap
{
private readonly IConnectionMultiplexer _redis;
private readonly ILogger<RedisEffectiveDecisionMap> _logger;
private readonly EffectiveDecisionMapOptions _options;
private readonly TimeProvider _timeProvider;
private const string KeyPrefix = "stellaops:edm";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
};
public RedisEffectiveDecisionMap(
IConnectionMultiplexer redis,
ILogger<RedisEffectiveDecisionMap> logger,
IOptions<PolicyEngineOptions> options,
TimeProvider timeProvider)
{
_redis = redis ?? throw new ArgumentNullException(nameof(redis));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options?.Value.EffectiveDecisionMap ?? new EffectiveDecisionMapOptions();
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task SetAsync(
string tenantId,
string snapshotId,
EffectiveDecisionEntry entry,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entry);
var db = _redis.GetDatabase();
var entryKey = GetEntryKey(tenantId, snapshotId, entry.AssetId);
var indexKey = GetIndexKey(tenantId, snapshotId);
var json = JsonSerializer.Serialize(entry, JsonOptions);
var ttl = entry.ExpiresAt - _timeProvider.GetUtcNow();
if (ttl <= TimeSpan.Zero)
{
ttl = TimeSpan.FromMinutes(_options.DefaultTtlMinutes);
}
var tasks = new List<Task>
{
db.StringSetAsync(entryKey, json, ttl),
db.SortedSetAddAsync(indexKey, entry.AssetId, entry.EvaluatedAt.ToUnixTimeMilliseconds()),
db.KeyExpireAsync(indexKey, ttl + TimeSpan.FromMinutes(5)), // Index lives slightly longer
};
await Task.WhenAll(tasks).ConfigureAwait(false);
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(1,
new KeyValuePair<string, object?>("operation", "set"),
new KeyValuePair<string, object?>("tenant_id", tenantId));
}
public async Task SetBatchAsync(
string tenantId,
string snapshotId,
IEnumerable<EffectiveDecisionEntry> entries,
CancellationToken cancellationToken = default)
{
var db = _redis.GetDatabase();
var batch = db.CreateBatch();
var indexKey = GetIndexKey(tenantId, snapshotId);
var now = _timeProvider.GetUtcNow();
var count = 0;
var sortedSetEntries = new List<SortedSetEntry>();
foreach (var entry in entries)
{
var entryKey = GetEntryKey(tenantId, snapshotId, entry.AssetId);
var json = JsonSerializer.Serialize(entry, JsonOptions);
var ttl = entry.ExpiresAt - now;
if (ttl <= TimeSpan.Zero)
{
ttl = TimeSpan.FromMinutes(_options.DefaultTtlMinutes);
}
_ = batch.StringSetAsync(entryKey, json, ttl);
sortedSetEntries.Add(new SortedSetEntry(entry.AssetId, entry.EvaluatedAt.ToUnixTimeMilliseconds()));
count++;
}
if (sortedSetEntries.Count > 0)
{
_ = batch.SortedSetAddAsync(indexKey, sortedSetEntries.ToArray());
_ = batch.KeyExpireAsync(indexKey, TimeSpan.FromMinutes(_options.DefaultTtlMinutes + 5));
}
batch.Execute();
await Task.CompletedTask; // Batch operations are synchronous
// Increment version after batch write
await IncrementVersionAsync(tenantId, snapshotId, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(count,
new KeyValuePair<string, object?>("operation", "set_batch"),
new KeyValuePair<string, object?>("tenant_id", tenantId));
_logger.LogDebug("Set {Count} effective decisions for snapshot {SnapshotId}", count, snapshotId);
}
public async Task<EffectiveDecisionEntry?> GetAsync(
string tenantId,
string snapshotId,
string assetId,
CancellationToken cancellationToken = default)
{
var db = _redis.GetDatabase();
var entryKey = GetEntryKey(tenantId, snapshotId, assetId);
var json = await db.StringGetAsync(entryKey).ConfigureAwait(false);
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(1,
new KeyValuePair<string, object?>("operation", "get"),
new KeyValuePair<string, object?>("tenant_id", tenantId),
new KeyValuePair<string, object?>("cache_hit", json.HasValue));
if (!json.HasValue)
{
return null;
}
return JsonSerializer.Deserialize<EffectiveDecisionEntry>((string)json!, JsonOptions);
}
public async Task<EffectiveDecisionQueryResult> GetBatchAsync(
string tenantId,
string snapshotId,
IReadOnlyList<string> assetIds,
CancellationToken cancellationToken = default)
{
var db = _redis.GetDatabase();
var keys = assetIds.Select(id => (RedisKey)GetEntryKey(tenantId, snapshotId, id)).ToArray();
var values = await db.StringGetAsync(keys).ConfigureAwait(false);
var entries = new Dictionary<string, EffectiveDecisionEntry>();
var notFound = new List<string>();
for (int i = 0; i < assetIds.Count; i++)
{
if (values[i].HasValue)
{
var entry = JsonSerializer.Deserialize<EffectiveDecisionEntry>((string)values[i]!, JsonOptions);
if (entry != null)
{
entries[assetIds[i]] = entry;
}
}
else
{
notFound.Add(assetIds[i]);
}
}
var version = await GetVersionAsync(tenantId, snapshotId, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(assetIds.Count,
new KeyValuePair<string, object?>("operation", "get_batch"),
new KeyValuePair<string, object?>("tenant_id", tenantId));
return new EffectiveDecisionQueryResult
{
Entries = entries,
NotFound = notFound,
MapVersion = version,
FromCache = true,
};
}
public async Task<IReadOnlyList<EffectiveDecisionEntry>> GetAllForSnapshotAsync(
string tenantId,
string snapshotId,
EffectiveDecisionFilter? filter = null,
CancellationToken cancellationToken = default)
{
var db = _redis.GetDatabase();
var indexKey = GetIndexKey(tenantId, snapshotId);
// Get all asset IDs from the index
var assetIds = await db.SortedSetRangeByRankAsync(indexKey, 0, -1, Order.Descending)
.ConfigureAwait(false);
if (assetIds.Length == 0)
{
return Array.Empty<EffectiveDecisionEntry>();
}
// Get all entries
var keys = assetIds.Select(id => (RedisKey)GetEntryKey(tenantId, snapshotId, id!)).ToArray();
var values = await db.StringGetAsync(keys).ConfigureAwait(false);
var entries = new List<EffectiveDecisionEntry>();
foreach (var value in values)
{
if (!value.HasValue) continue;
var entry = JsonSerializer.Deserialize<EffectiveDecisionEntry>((string)value!, JsonOptions);
if (entry is null) continue;
// Apply filters
if (filter != null)
{
if (filter.Statuses?.Count > 0 &&
!filter.Statuses.Contains(entry.Status, StringComparer.OrdinalIgnoreCase))
{
continue;
}
if (filter.Severities?.Count > 0 &&
(entry.Severity is null || !filter.Severities.Contains(entry.Severity, StringComparer.OrdinalIgnoreCase)))
{
continue;
}
if (filter.HasException == true && entry.ExceptionId is null)
{
continue;
}
if (filter.HasException == false && entry.ExceptionId is not null)
{
continue;
}
if (filter.MinAdvisoryCount.HasValue && entry.AdvisoryCount < filter.MinAdvisoryCount)
{
continue;
}
if (filter.MinHighSeverityCount.HasValue && entry.HighSeverityCount < filter.MinHighSeverityCount)
{
continue;
}
}
entries.Add(entry);
// Apply limit
if (filter?.Limit > 0 && entries.Count >= filter.Limit + (filter?.Offset ?? 0))
{
break;
}
}
// Apply offset
if (filter?.Offset > 0)
{
entries = entries.Skip(filter.Offset).ToList();
}
// Apply final limit
if (filter?.Limit > 0)
{
entries = entries.Take(filter.Limit).ToList();
}
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(1,
new KeyValuePair<string, object?>("operation", "get_all"),
new KeyValuePair<string, object?>("tenant_id", tenantId));
return entries;
}
public async Task<EffectiveDecisionSummary> GetSummaryAsync(
string tenantId,
string snapshotId,
CancellationToken cancellationToken = default)
{
var entries = await GetAllForSnapshotAsync(tenantId, snapshotId, null, cancellationToken)
.ConfigureAwait(false);
var statusCounts = entries
.GroupBy(e => e.Status, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
var severityCounts = entries
.Where(e => e.Severity is not null)
.GroupBy(e => e.Severity!, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
var version = await GetVersionAsync(tenantId, snapshotId, cancellationToken).ConfigureAwait(false);
return new EffectiveDecisionSummary
{
SnapshotId = snapshotId,
TotalAssets = entries.Count,
StatusCounts = statusCounts,
SeverityCounts = severityCounts,
ExceptionCount = entries.Count(e => e.ExceptionId is not null),
MapVersion = version,
ComputedAt = _timeProvider.GetUtcNow(),
};
}
public async Task InvalidateAsync(
string tenantId,
string snapshotId,
string assetId,
CancellationToken cancellationToken = default)
{
var db = _redis.GetDatabase();
var entryKey = GetEntryKey(tenantId, snapshotId, assetId);
var indexKey = GetIndexKey(tenantId, snapshotId);
await Task.WhenAll(
db.KeyDeleteAsync(entryKey),
db.SortedSetRemoveAsync(indexKey, assetId)
).ConfigureAwait(false);
await IncrementVersionAsync(tenantId, snapshotId, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(1,
new KeyValuePair<string, object?>("operation", "invalidate"),
new KeyValuePair<string, object?>("tenant_id", tenantId));
}
public async Task InvalidateSnapshotAsync(
string tenantId,
string snapshotId,
CancellationToken cancellationToken = default)
{
var db = _redis.GetDatabase();
var indexKey = GetIndexKey(tenantId, snapshotId);
// Get all asset IDs from the index
var assetIds = await db.SortedSetRangeByRankAsync(indexKey).ConfigureAwait(false);
if (assetIds.Length > 0)
{
var keys = assetIds
.Select(id => (RedisKey)GetEntryKey(tenantId, snapshotId, id!))
.Append(indexKey)
.Append(GetVersionKey(tenantId, snapshotId))
.ToArray();
await db.KeyDeleteAsync(keys).ConfigureAwait(false);
}
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(assetIds.Length,
new KeyValuePair<string, object?>("operation", "invalidate_snapshot"),
new KeyValuePair<string, object?>("tenant_id", tenantId));
_logger.LogInformation("Invalidated {Count} entries for snapshot {SnapshotId}", assetIds.Length, snapshotId);
}
public async Task InvalidateTenantAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
var server = _redis.GetServer(_redis.GetEndPoints().First());
var pattern = $"{KeyPrefix}:{tenantId}:*";
var keys = server.Keys(pattern: pattern).ToArray();
if (keys.Length > 0)
{
var db = _redis.GetDatabase();
await db.KeyDeleteAsync(keys).ConfigureAwait(false);
}
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(keys.Length,
new KeyValuePair<string, object?>("operation", "invalidate_tenant"),
new KeyValuePair<string, object?>("tenant_id", tenantId));
_logger.LogInformation("Invalidated {Count} keys for tenant {TenantId}", keys.Length, tenantId);
}
public async Task<long> GetVersionAsync(
string tenantId,
string snapshotId,
CancellationToken cancellationToken = default)
{
var db = _redis.GetDatabase();
var versionKey = GetVersionKey(tenantId, snapshotId);
var version = await db.StringGetAsync(versionKey).ConfigureAwait(false);
return version.HasValue ? (long)version : 0;
}
public async Task<long> IncrementVersionAsync(
string tenantId,
string snapshotId,
CancellationToken cancellationToken = default)
{
var db = _redis.GetDatabase();
var versionKey = GetVersionKey(tenantId, snapshotId);
var newVersion = await db.StringIncrementAsync(versionKey).ConfigureAwait(false);
// Set TTL on version key if not already set
await db.KeyExpireAsync(versionKey, TimeSpan.FromMinutes(_options.DefaultTtlMinutes + 10), ExpireWhen.HasNoExpiry)
.ConfigureAwait(false);
return newVersion;
}
public async Task<EffectiveDecisionMapStats> GetStatsAsync(
string? tenantId = null,
CancellationToken cancellationToken = default)
{
var server = _redis.GetServer(_redis.GetEndPoints().First());
var pattern = tenantId != null
? $"{KeyPrefix}:{tenantId}:*:e:*"
: $"{KeyPrefix}:*:e:*";
var entryCount = server.Keys(pattern: pattern).Count();
var snapshotPattern = tenantId != null
? $"{KeyPrefix}:{tenantId}:*:idx"
: $"{KeyPrefix}:*:idx";
var snapshotCount = server.Keys(pattern: snapshotPattern).Count();
long? memoryUsed = null;
try
{
var info = server.Info("memory");
var memorySection = info.FirstOrDefault(s => s.Key == "Memory");
if (memorySection is not null)
{
var usedMemory = memorySection.FirstOrDefault(p => p.Key == "used_memory");
if (usedMemory.Key is not null && long.TryParse(usedMemory.Value, out var bytes))
{
memoryUsed = bytes;
}
}
}
catch
{
// Ignore - memory info not available
}
return new EffectiveDecisionMapStats
{
TotalEntries = entryCount,
TotalSnapshots = snapshotCount,
MemoryUsedBytes = memoryUsed,
ExpiringWithinHour = 0, // Would require scanning TTLs
LastEvictionAt = null,
LastEvictionCount = 0,
};
}
private static string GetEntryKey(string tenantId, string snapshotId, string assetId) =>
$"{KeyPrefix}:{tenantId}:{snapshotId}:e:{assetId}";
private static string GetIndexKey(string tenantId, string snapshotId) =>
$"{KeyPrefix}:{tenantId}:{snapshotId}:idx";
private static string GetVersionKey(string tenantId, string snapshotId) =>
$"{KeyPrefix}:{tenantId}:{snapshotId}:v";
}
/// <summary>
/// Configuration options for effective decision map.
/// </summary>
public sealed class EffectiveDecisionMapOptions
{
/// <summary>
/// Default TTL for entries in minutes.
/// </summary>
public int DefaultTtlMinutes { get; set; } = 60;
/// <summary>
/// Maximum entries per snapshot.
/// </summary>
public int MaxEntriesPerSnapshot { get; set; } = 100000;
/// <summary>
/// Whether to enable automatic eviction of expired entries.
/// </summary>
public bool EnableAutoEviction { get; set; } = true;
/// <summary>
/// Eviction check interval in minutes.
/// </summary>
public int EvictionIntervalMinutes { get; set; } = 5;
}

View File

@@ -0,0 +1,184 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.Events;
/// <summary>
/// Type of policy effective event.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<PolicyEffectiveEventType>))]
public enum PolicyEffectiveEventType
{
/// <summary>Policy decision changed for a subject.</summary>
[JsonPropertyName("policy.effective.updated")]
EffectiveUpdated,
/// <summary>Policy decision added for new subject.</summary>
[JsonPropertyName("policy.effective.added")]
EffectiveAdded,
/// <summary>Policy decision removed (subject no longer affected).</summary>
[JsonPropertyName("policy.effective.removed")]
EffectiveRemoved,
/// <summary>Batch re-evaluation completed.</summary>
[JsonPropertyName("policy.effective.batch_completed")]
BatchCompleted
}
/// <summary>
/// Base class for policy effective events.
/// </summary>
public abstract record PolicyEffectiveEvent(
[property: JsonPropertyName("event_id")] string EventId,
[property: JsonPropertyName("event_type")] PolicyEffectiveEventType EventType,
[property: JsonPropertyName("tenant_id")] string TenantId,
[property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp,
[property: JsonPropertyName("correlation_id")] string? CorrelationId);
/// <summary>
/// Event emitted when a policy decision is updated for a subject.
/// </summary>
public sealed record PolicyEffectiveUpdatedEvent(
string EventId,
string TenantId,
DateTimeOffset Timestamp,
string? CorrelationId,
[property: JsonPropertyName("pack_id")] string PackId,
[property: JsonPropertyName("pack_version")] int PackVersion,
[property: JsonPropertyName("subject_purl")] string SubjectPurl,
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
[property: JsonPropertyName("trigger_type")] string TriggerType,
[property: JsonPropertyName("diff")] PolicyDecisionDiff Diff)
: PolicyEffectiveEvent(EventId, PolicyEffectiveEventType.EffectiveUpdated, TenantId, Timestamp, CorrelationId);
/// <summary>
/// Diff metadata for policy decision changes.
/// </summary>
public sealed record PolicyDecisionDiff(
[property: JsonPropertyName("old_status")] string? OldStatus,
[property: JsonPropertyName("new_status")] string NewStatus,
[property: JsonPropertyName("old_severity")] string? OldSeverity,
[property: JsonPropertyName("new_severity")] string? NewSeverity,
[property: JsonPropertyName("old_rule")] string? OldRule,
[property: JsonPropertyName("new_rule")] string? NewRule,
[property: JsonPropertyName("old_priority")] int? OldPriority,
[property: JsonPropertyName("new_priority")] int? NewPriority,
[property: JsonPropertyName("status_changed")] bool StatusChanged,
[property: JsonPropertyName("severity_changed")] bool SeverityChanged,
[property: JsonPropertyName("rule_changed")] bool RuleChanged,
[property: JsonPropertyName("annotations_added")] ImmutableArray<string> AnnotationsAdded,
[property: JsonPropertyName("annotations_removed")] ImmutableArray<string> AnnotationsRemoved)
{
/// <summary>
/// Creates a diff between two policy decisions.
/// </summary>
public static PolicyDecisionDiff Create(
string? oldStatus, string newStatus,
string? oldSeverity, string? newSeverity,
string? oldRule, string? newRule,
int? oldPriority, int? newPriority,
ImmutableDictionary<string, string>? oldAnnotations,
ImmutableDictionary<string, string>? newAnnotations)
{
var oldKeys = oldAnnotations?.Keys ?? Enumerable.Empty<string>();
var newKeys = newAnnotations?.Keys ?? Enumerable.Empty<string>();
var annotationsAdded = newKeys
.Where(k => oldAnnotations?.ContainsKey(k) != true)
.OrderBy(k => k)
.ToImmutableArray();
var annotationsRemoved = oldKeys
.Where(k => newAnnotations?.ContainsKey(k) != true)
.OrderBy(k => k)
.ToImmutableArray();
return new PolicyDecisionDiff(
OldStatus: oldStatus,
NewStatus: newStatus,
OldSeverity: oldSeverity,
NewSeverity: newSeverity,
OldRule: oldRule,
NewRule: newRule,
OldPriority: oldPriority,
NewPriority: newPriority,
StatusChanged: !string.Equals(oldStatus, newStatus, StringComparison.Ordinal),
SeverityChanged: !string.Equals(oldSeverity, newSeverity, StringComparison.Ordinal),
RuleChanged: !string.Equals(oldRule, newRule, StringComparison.Ordinal),
AnnotationsAdded: annotationsAdded,
AnnotationsRemoved: annotationsRemoved);
}
}
/// <summary>
/// Event emitted when batch re-evaluation completes.
/// </summary>
public sealed record PolicyBatchCompletedEvent(
string EventId,
string TenantId,
DateTimeOffset Timestamp,
string? CorrelationId,
[property: JsonPropertyName("batch_id")] string BatchId,
[property: JsonPropertyName("trigger_type")] string TriggerType,
[property: JsonPropertyName("subjects_evaluated")] int SubjectsEvaluated,
[property: JsonPropertyName("decisions_changed")] int DecisionsChanged,
[property: JsonPropertyName("duration_ms")] long DurationMs,
[property: JsonPropertyName("summary")] PolicyBatchSummary Summary)
: PolicyEffectiveEvent(EventId, PolicyEffectiveEventType.BatchCompleted, TenantId, Timestamp, CorrelationId);
/// <summary>
/// Summary of changes in a batch re-evaluation.
/// </summary>
public sealed record PolicyBatchSummary(
[property: JsonPropertyName("status_upgrades")] int StatusUpgrades,
[property: JsonPropertyName("status_downgrades")] int StatusDowngrades,
[property: JsonPropertyName("new_blocks")] int NewBlocks,
[property: JsonPropertyName("blocks_removed")] int BlocksRemoved,
[property: JsonPropertyName("affected_advisories")] ImmutableArray<string> AffectedAdvisories,
[property: JsonPropertyName("affected_purls")] ImmutableArray<string> AffectedPurls);
/// <summary>
/// Request to schedule a re-evaluation job.
/// </summary>
public sealed record ReEvaluationJobRequest(
string JobId,
string TenantId,
string PackId,
int PackVersion,
string TriggerType,
string? CorrelationId,
DateTimeOffset CreatedAt,
PolicyChangePriority Priority,
ImmutableArray<string> AdvisoryIds,
ImmutableArray<string> SubjectPurls,
ImmutableArray<string> SbomIds,
ImmutableDictionary<string, string> Metadata)
{
/// <summary>
/// Creates a deterministic job ID.
/// </summary>
public static string CreateJobId(
string tenantId,
string packId,
int packVersion,
string triggerType,
DateTimeOffset createdAt)
{
var seed = $"{tenantId}|{packId}|{packVersion}|{triggerType}|{createdAt:O}";
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
return $"rej-{Convert.ToHexStringLower(bytes)[..16]}";
}
}
/// <summary>
/// Policy change priority from IncrementalOrchestrator namespace.
/// </summary>
public enum PolicyChangePriority
{
Normal = 0,
High = 1,
Emergency = 2
}

View File

@@ -0,0 +1,454 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.IncrementalOrchestrator;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Telemetry;
namespace StellaOps.Policy.Engine.Events;
/// <summary>
/// Interface for publishing policy effective events.
/// </summary>
public interface IPolicyEffectiveEventPublisher
{
/// <summary>
/// Publishes a policy effective updated event.
/// </summary>
Task PublishEffectiveUpdatedAsync(PolicyEffectiveUpdatedEvent evt, CancellationToken cancellationToken = default);
/// <summary>
/// Publishes a batch completed event.
/// </summary>
Task PublishBatchCompletedAsync(PolicyBatchCompletedEvent evt, CancellationToken cancellationToken = default);
/// <summary>
/// Registers a handler for effective events.
/// </summary>
void RegisterHandler(Func<PolicyEffectiveEvent, Task> handler);
}
/// <summary>
/// Interface for scheduling re-evaluation jobs.
/// </summary>
public interface IReEvaluationJobScheduler
{
/// <summary>
/// Schedules a re-evaluation job.
/// </summary>
Task<string> ScheduleAsync(ReEvaluationJobRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// Gets pending job count.
/// </summary>
int GetPendingJobCount();
/// <summary>
/// Gets job by ID.
/// </summary>
ReEvaluationJobRequest? GetJob(string jobId);
}
/// <summary>
/// Processes policy change events, schedules re-evaluations, and emits effective events.
/// </summary>
public sealed class PolicyEventProcessor : IPolicyEffectiveEventPublisher, IReEvaluationJobScheduler
{
private readonly ILogger<PolicyEventProcessor> _logger;
private readonly TimeProvider _timeProvider;
private readonly ConcurrentQueue<ReEvaluationJobRequest> _jobQueue;
private readonly ConcurrentDictionary<string, ReEvaluationJobRequest> _jobIndex;
private readonly ConcurrentQueue<PolicyEffectiveEvent> _eventStream;
private readonly List<Func<PolicyEffectiveEvent, Task>> _eventHandlers;
private readonly object _handlersLock = new();
private const int MaxQueueSize = 10000;
private const int MaxEventStreamSize = 50000;
public PolicyEventProcessor(
ILogger<PolicyEventProcessor> logger,
TimeProvider timeProvider)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_jobQueue = new ConcurrentQueue<ReEvaluationJobRequest>();
_jobIndex = new ConcurrentDictionary<string, ReEvaluationJobRequest>(StringComparer.OrdinalIgnoreCase);
_eventStream = new ConcurrentQueue<PolicyEffectiveEvent>();
_eventHandlers = new List<Func<PolicyEffectiveEvent, Task>>();
}
/// <summary>
/// Processes a policy change event and schedules re-evaluation if needed.
/// </summary>
public async Task<string?> ProcessChangeEventAsync(
PolicyChangeEvent changeEvent,
string packId,
int packVersion,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(changeEvent);
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity("policy_event.process", ActivityKind.Internal);
activity?.SetTag("event.id", changeEvent.EventId);
activity?.SetTag("event.type", changeEvent.ChangeType.ToString());
activity?.SetTag("tenant.id", changeEvent.TenantId);
_logger.LogDebug(
"Processing policy change event {EventId}: {ChangeType} for tenant {TenantId}",
changeEvent.EventId, changeEvent.ChangeType, changeEvent.TenantId);
// Skip if event targets no subjects
if (changeEvent.AffectedPurls.IsDefaultOrEmpty &&
changeEvent.AffectedSbomIds.IsDefaultOrEmpty &&
changeEvent.AffectedProductKeys.IsDefaultOrEmpty)
{
_logger.LogDebug("Skipping event {EventId}: no affected subjects", changeEvent.EventId);
return null;
}
// Create re-evaluation job request
var jobId = ReEvaluationJobRequest.CreateJobId(
changeEvent.TenantId,
packId,
packVersion,
changeEvent.ChangeType.ToString(),
_timeProvider.GetUtcNow());
var jobRequest = new ReEvaluationJobRequest(
JobId: jobId,
TenantId: changeEvent.TenantId,
PackId: packId,
PackVersion: packVersion,
TriggerType: changeEvent.ChangeType.ToString(),
CorrelationId: changeEvent.CorrelationId,
CreatedAt: _timeProvider.GetUtcNow(),
Priority: MapPriority(changeEvent.Priority),
AdvisoryIds: changeEvent.AdvisoryId is not null
? ImmutableArray.Create(changeEvent.AdvisoryId)
: ImmutableArray<string>.Empty,
SubjectPurls: changeEvent.AffectedPurls,
SbomIds: changeEvent.AffectedSbomIds,
Metadata: changeEvent.Metadata);
// Schedule the job
var scheduledId = await ScheduleAsync(jobRequest, cancellationToken).ConfigureAwait(false);
activity?.SetTag("job.id", scheduledId);
PolicyEngineTelemetry.PolicyEventsProcessed.Add(1);
return scheduledId;
}
/// <summary>
/// Processes results from a re-evaluation and emits effective events.
/// </summary>
public async Task ProcessReEvaluationResultsAsync(
string jobId,
string tenantId,
string packId,
int packVersion,
string triggerType,
string? correlationId,
IReadOnlyList<PolicyDecisionChange> changes,
long durationMs,
CancellationToken cancellationToken = default)
{
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity("policy_event.emit_results", ActivityKind.Internal);
activity?.SetTag("job.id", jobId);
activity?.SetTag("changes.count", changes.Count);
var now = _timeProvider.GetUtcNow();
var changedCount = 0;
// Emit individual effective events for each changed decision
foreach (var change in changes)
{
if (!change.HasChanged)
{
continue;
}
changedCount++;
var diff = PolicyDecisionDiff.Create(
change.OldStatus, change.NewStatus,
change.OldSeverity, change.NewSeverity,
change.OldRule, change.NewRule,
change.OldPriority, change.NewPriority,
change.OldAnnotations, change.NewAnnotations);
var evt = new PolicyEffectiveUpdatedEvent(
EventId: GenerateEventId(),
TenantId: tenantId,
Timestamp: now,
CorrelationId: correlationId,
PackId: packId,
PackVersion: packVersion,
SubjectPurl: change.SubjectPurl,
AdvisoryId: change.AdvisoryId,
TriggerType: triggerType,
Diff: diff);
await PublishEffectiveUpdatedAsync(evt, cancellationToken).ConfigureAwait(false);
}
// Emit batch completed event
var summary = ComputeBatchSummary(changes);
var batchEvent = new PolicyBatchCompletedEvent(
EventId: GenerateEventId(),
TenantId: tenantId,
Timestamp: now,
CorrelationId: correlationId,
BatchId: jobId,
TriggerType: triggerType,
SubjectsEvaluated: changes.Count,
DecisionsChanged: changedCount,
DurationMs: durationMs,
Summary: summary);
await PublishBatchCompletedAsync(batchEvent, cancellationToken).ConfigureAwait(false);
activity?.SetTag("decisions.changed", changedCount);
_logger.LogInformation(
"Re-evaluation {JobId} completed: {Evaluated} subjects, {Changed} decisions changed in {Duration}ms",
jobId, changes.Count, changedCount, durationMs);
}
/// <inheritdoc/>
public async Task PublishEffectiveUpdatedAsync(
PolicyEffectiveUpdatedEvent evt,
CancellationToken cancellationToken = default)
{
await PublishEventAsync(evt).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task PublishBatchCompletedAsync(
PolicyBatchCompletedEvent evt,
CancellationToken cancellationToken = default)
{
await PublishEventAsync(evt).ConfigureAwait(false);
}
/// <inheritdoc/>
public void RegisterHandler(Func<PolicyEffectiveEvent, Task> handler)
{
ArgumentNullException.ThrowIfNull(handler);
lock (_handlersLock)
{
_eventHandlers.Add(handler);
}
}
/// <inheritdoc/>
public Task<string> ScheduleAsync(ReEvaluationJobRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
// Check for duplicate
if (_jobIndex.ContainsKey(request.JobId))
{
_logger.LogDebug("Duplicate job {JobId} ignored", request.JobId);
return Task.FromResult(request.JobId);
}
// Enforce queue limit
if (_jobQueue.Count >= MaxQueueSize)
{
_logger.LogWarning("Job queue full, rejecting job {JobId}", request.JobId);
throw new InvalidOperationException("Re-evaluation job queue is full");
}
_jobIndex[request.JobId] = request;
_jobQueue.Enqueue(request);
PolicyEngineTelemetry.ReEvaluationJobsScheduled.Add(1);
_logger.LogDebug(
"Scheduled re-evaluation job {JobId}: {TriggerType} for {TenantId}/{PackId}@{Version}",
request.JobId, request.TriggerType, request.TenantId, request.PackId, request.PackVersion);
return Task.FromResult(request.JobId);
}
/// <inheritdoc/>
public int GetPendingJobCount() => _jobQueue.Count;
/// <inheritdoc/>
public ReEvaluationJobRequest? GetJob(string jobId)
{
_jobIndex.TryGetValue(jobId, out var job);
return job;
}
/// <summary>
/// Dequeues the next job for processing.
/// </summary>
public ReEvaluationJobRequest? DequeueJob()
{
if (_jobQueue.TryDequeue(out var job))
{
_jobIndex.TryRemove(job.JobId, out _);
return job;
}
return null;
}
/// <summary>
/// Gets recent effective events.
/// </summary>
public IReadOnlyList<PolicyEffectiveEvent> GetRecentEvents(int limit = 100)
{
return _eventStream
.ToArray()
.OrderByDescending(e => e.Timestamp)
.Take(limit)
.ToList()
.AsReadOnly();
}
private async Task PublishEventAsync(PolicyEffectiveEvent evt)
{
// Add to stream
_eventStream.Enqueue(evt);
// Trim if too large
while (_eventStream.Count > MaxEventStreamSize)
{
_eventStream.TryDequeue(out _);
}
// Invoke handlers
List<Func<PolicyEffectiveEvent, Task>> handlers;
lock (_handlersLock)
{
handlers = _eventHandlers.ToList();
}
foreach (var handler in handlers)
{
try
{
await handler(evt).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error invoking event handler for {EventType}", evt.EventType);
}
}
PolicyEngineTelemetry.PolicyEffectiveEventsPublished.Add(1);
}
private static PolicyBatchSummary ComputeBatchSummary(IReadOnlyList<PolicyDecisionChange> changes)
{
var statusUpgrades = 0;
var statusDowngrades = 0;
var newBlocks = 0;
var blocksRemoved = 0;
var advisories = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var purls = new HashSet<string>(StringComparer.Ordinal);
foreach (var change in changes)
{
advisories.Add(change.AdvisoryId);
purls.Add(change.SubjectPurl);
if (!change.HasChanged)
{
continue;
}
var severityChange = CompareSeverity(change.OldStatus, change.NewStatus);
if (severityChange > 0)
{
statusUpgrades++;
}
else if (severityChange < 0)
{
statusDowngrades++;
}
if (IsBlockStatus(change.NewStatus) && !IsBlockStatus(change.OldStatus))
{
newBlocks++;
}
else if (IsBlockStatus(change.OldStatus) && !IsBlockStatus(change.NewStatus))
{
blocksRemoved++;
}
}
return new PolicyBatchSummary(
StatusUpgrades: statusUpgrades,
StatusDowngrades: statusDowngrades,
NewBlocks: newBlocks,
BlocksRemoved: blocksRemoved,
AffectedAdvisories: advisories.OrderBy(a => a).ToImmutableArray(),
AffectedPurls: purls.OrderBy(p => p).Take(100).ToImmutableArray());
}
private static int CompareSeverity(string? oldStatus, string? newStatus)
{
var oldSeverity = GetStatusSeverityLevel(oldStatus);
var newSeverity = GetStatusSeverityLevel(newStatus);
return newSeverity.CompareTo(oldSeverity);
}
private static int GetStatusSeverityLevel(string? status) => status?.ToLowerInvariant() switch
{
"blocked" => 4,
"deny" => 4,
"warn" => 3,
"affected" => 2,
"allow" => 1,
"ignored" => 0,
_ => 1
};
private static bool IsBlockStatus(string? status) =>
string.Equals(status, "blocked", StringComparison.OrdinalIgnoreCase) ||
string.Equals(status, "deny", StringComparison.OrdinalIgnoreCase);
private static Events.PolicyChangePriority MapPriority(IncrementalOrchestrator.PolicyChangePriority priority) =>
priority switch
{
IncrementalOrchestrator.PolicyChangePriority.Emergency => Events.PolicyChangePriority.Emergency,
IncrementalOrchestrator.PolicyChangePriority.High => Events.PolicyChangePriority.High,
_ => Events.PolicyChangePriority.Normal
};
private static string GenerateEventId()
{
var guid = Guid.NewGuid().ToByteArray();
return $"pee-{Convert.ToHexStringLower(guid)[..16]}";
}
}
/// <summary>
/// Represents a change in policy decision for a subject.
/// </summary>
public sealed record PolicyDecisionChange(
string SubjectPurl,
string AdvisoryId,
string? OldStatus,
string NewStatus,
string? OldSeverity,
string? NewSeverity,
string? OldRule,
string? NewRule,
int? OldPriority,
int? NewPriority,
ImmutableDictionary<string, string>? OldAnnotations,
ImmutableDictionary<string, string>? NewAnnotations)
{
/// <summary>
/// Whether the decision has changed.
/// </summary>
public bool HasChanged =>
!string.Equals(OldStatus, NewStatus, StringComparison.Ordinal) ||
!string.Equals(OldSeverity, NewSeverity, StringComparison.Ordinal) ||
!string.Equals(OldRule, NewRule, StringComparison.Ordinal);
}

View File

@@ -0,0 +1,225 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.ExceptionCache;
/// <summary>
/// Cached exception entry for fast lookup during policy evaluation.
/// </summary>
public sealed record ExceptionCacheEntry
{
/// <summary>
/// Exception identifier.
/// </summary>
[JsonPropertyName("exception_id")]
public required string ExceptionId { get; init; }
/// <summary>
/// Asset identifier this exception applies to.
/// </summary>
[JsonPropertyName("asset_id")]
public required string AssetId { get; init; }
/// <summary>
/// Advisory ID covered (null if applies to all advisories for asset).
/// </summary>
[JsonPropertyName("advisory_id")]
public string? AdvisoryId { get; init; }
/// <summary>
/// CVE ID covered (null if applies to all CVEs for asset).
/// </summary>
[JsonPropertyName("cve_id")]
public string? CveId { get; init; }
/// <summary>
/// Decision override applied by this exception.
/// </summary>
[JsonPropertyName("decision_override")]
public required string DecisionOverride { get; init; }
/// <summary>
/// Exception type: waiver, override, temporary, permanent.
/// </summary>
[JsonPropertyName("exception_type")]
public required string ExceptionType { get; init; }
/// <summary>
/// Priority for conflict resolution (higher = more precedence).
/// </summary>
[JsonPropertyName("priority")]
public int Priority { get; init; }
/// <summary>
/// When the exception becomes effective.
/// </summary>
[JsonPropertyName("effective_from")]
public DateTimeOffset EffectiveFrom { get; init; }
/// <summary>
/// When the exception expires (null = no expiration).
/// </summary>
[JsonPropertyName("expires_at")]
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// When this cache entry was created.
/// </summary>
[JsonPropertyName("cached_at")]
public DateTimeOffset CachedAt { get; init; }
/// <summary>
/// Original exception name for display.
/// </summary>
[JsonPropertyName("exception_name")]
public string? ExceptionName { get; init; }
}
/// <summary>
/// Result of querying exceptions for an asset.
/// </summary>
public sealed record ExceptionCacheQueryResult
{
/// <summary>
/// Applicable exceptions for the asset, ordered by priority (highest first).
/// </summary>
public required ImmutableArray<ExceptionCacheEntry> Entries { get; init; }
/// <summary>
/// Whether the result came from cache.
/// </summary>
public bool FromCache { get; init; }
/// <summary>
/// Cache version at time of query.
/// </summary>
public long CacheVersion { get; init; }
/// <summary>
/// Time taken to query in milliseconds.
/// </summary>
public long QueryDurationMs { get; init; }
}
/// <summary>
/// Summary of cached exceptions for a tenant.
/// </summary>
public sealed record ExceptionCacheSummary
{
/// <summary>
/// Tenant identifier.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Total cached exception entries.
/// </summary>
public int TotalEntries { get; init; }
/// <summary>
/// Unique exceptions in cache.
/// </summary>
public int UniqueExceptions { get; init; }
/// <summary>
/// Unique assets with exceptions.
/// </summary>
public int UniqueAssets { get; init; }
/// <summary>
/// Counts by exception type.
/// </summary>
public required IReadOnlyDictionary<string, int> ByType { get; init; }
/// <summary>
/// Counts by decision override.
/// </summary>
public required IReadOnlyDictionary<string, int> ByDecision { get; init; }
/// <summary>
/// Entries expiring within the next hour.
/// </summary>
public int ExpiringWithinHour { get; init; }
/// <summary>
/// Cache version.
/// </summary>
public long CacheVersion { get; init; }
/// <summary>
/// When summary was computed.
/// </summary>
public DateTimeOffset ComputedAt { get; init; }
}
/// <summary>
/// Options for exception cache operations.
/// </summary>
public sealed record ExceptionCacheOptions
{
/// <summary>
/// Default TTL for cache entries in minutes.
/// </summary>
public int DefaultTtlMinutes { get; set; } = 60;
/// <summary>
/// Whether to enable automatic cache warming.
/// </summary>
public bool EnableAutoWarm { get; set; } = true;
/// <summary>
/// Warm interval in minutes.
/// </summary>
public int WarmIntervalMinutes { get; set; } = 15;
/// <summary>
/// Maximum entries per tenant.
/// </summary>
public int MaxEntriesPerTenant { get; set; } = 50000;
/// <summary>
/// Whether to invalidate cache on exception events.
/// </summary>
public bool InvalidateOnEvents { get; set; } = true;
}
/// <summary>
/// Statistics for the exception cache.
/// </summary>
public sealed record ExceptionCacheStats
{
/// <summary>
/// Total entries in cache.
/// </summary>
public int TotalEntries { get; init; }
/// <summary>
/// Total tenants with cached data.
/// </summary>
public int TotalTenants { get; init; }
/// <summary>
/// Memory used by cache in bytes (if available).
/// </summary>
public long? MemoryUsedBytes { get; init; }
/// <summary>
/// Cache hit count since last reset.
/// </summary>
public long HitCount { get; init; }
/// <summary>
/// Cache miss count since last reset.
/// </summary>
public long MissCount { get; init; }
/// <summary>
/// Last warm operation timestamp.
/// </summary>
public DateTimeOffset? LastWarmAt { get; init; }
/// <summary>
/// Last invalidation timestamp.
/// </summary>
public DateTimeOffset? LastInvalidationAt { get; init; }
}

View File

@@ -0,0 +1,156 @@
using System.Collections.Immutable;
namespace StellaOps.Policy.Engine.ExceptionCache;
/// <summary>
/// Interface for caching effective exception decisions per asset.
/// Supports warm/invalidation logic reacting to exception events.
/// </summary>
internal interface IExceptionEffectiveCache
{
/// <summary>
/// Gets applicable exceptions for an asset at a given time.
/// </summary>
Task<ExceptionCacheQueryResult> GetForAssetAsync(
string tenantId,
string assetId,
string? advisoryId,
DateTimeOffset asOf,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets applicable exceptions for multiple assets.
/// </summary>
Task<IReadOnlyDictionary<string, ExceptionCacheQueryResult>> GetBatchAsync(
string tenantId,
IReadOnlyList<string> assetIds,
DateTimeOffset asOf,
CancellationToken cancellationToken = default);
/// <summary>
/// Sets a cache entry.
/// </summary>
Task SetAsync(
string tenantId,
ExceptionCacheEntry entry,
CancellationToken cancellationToken = default);
/// <summary>
/// Sets multiple cache entries in batch.
/// </summary>
Task SetBatchAsync(
string tenantId,
IEnumerable<ExceptionCacheEntry> entries,
CancellationToken cancellationToken = default);
/// <summary>
/// Invalidates cache entries for an exception.
/// Called when an exception is modified/revoked/expired.
/// </summary>
Task InvalidateExceptionAsync(
string tenantId,
string exceptionId,
CancellationToken cancellationToken = default);
/// <summary>
/// Invalidates cache entries for an asset.
/// Called when asset exceptions need re-evaluation.
/// </summary>
Task InvalidateAssetAsync(
string tenantId,
string assetId,
CancellationToken cancellationToken = default);
/// <summary>
/// Invalidates all cache entries for a tenant.
/// </summary>
Task InvalidateTenantAsync(
string tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// Warms the cache for a tenant by loading active exceptions from the repository.
/// </summary>
Task WarmAsync(
string tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets cache summary for a tenant.
/// </summary>
Task<ExceptionCacheSummary> GetSummaryAsync(
string tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets cache statistics.
/// </summary>
Task<ExceptionCacheStats> GetStatsAsync(
string? tenantId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current cache version for a tenant.
/// </summary>
Task<long> GetVersionAsync(
string tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// Processes an exception event and updates cache accordingly.
/// </summary>
Task HandleExceptionEventAsync(
ExceptionEvent exceptionEvent,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Event representing a change to an exception.
/// </summary>
public sealed record ExceptionEvent
{
/// <summary>
/// Event type: activated, expired, revoked, updated, created, deleted.
/// </summary>
public required string EventType { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Exception identifier.
/// </summary>
public required string ExceptionId { get; init; }
/// <summary>
/// Exception name.
/// </summary>
public string? ExceptionName { get; init; }
/// <summary>
/// Exception type.
/// </summary>
public string? ExceptionType { get; init; }
/// <summary>
/// Affected asset IDs (if known).
/// </summary>
public ImmutableArray<string> AffectedAssetIds { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Affected advisory IDs (if known).
/// </summary>
public ImmutableArray<string> AffectedAdvisoryIds { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// When the event occurred.
/// </summary>
public DateTimeOffset OccurredAt { get; init; }
/// <summary>
/// Correlation ID for tracing.
/// </summary>
public string? CorrelationId { get; init; }
}

View File

@@ -0,0 +1,725 @@
using System.Collections.Immutable;
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Storage.Mongo.Repositories;
using StellaOps.Policy.Engine.Telemetry;
using StackExchange.Redis;
namespace StellaOps.Policy.Engine.ExceptionCache;
/// <summary>
/// Redis-backed exception effective cache with warm/invalidation support.
/// Key structure:
/// - Entry by asset: stellaops:exc:{tenant}:a:{asset}:{advisory|all} -> JSON array of entries
/// - Entry by exception: stellaops:exc:{tenant}:e:{exceptionId} -> JSON entry
/// - Index by exception: stellaops:exc:{tenant}:idx:e:{exceptionId} -> set of asset keys
/// - Version: stellaops:exc:{tenant}:v -> integer version
/// - Stats: stellaops:exc:{tenant}:stats -> JSON stats
/// </summary>
internal sealed class RedisExceptionEffectiveCache : IExceptionEffectiveCache
{
private readonly IConnectionMultiplexer _redis;
private readonly IExceptionRepository _repository;
private readonly ILogger<RedisExceptionEffectiveCache> _logger;
private readonly ExceptionCacheOptions _options;
private readonly TimeProvider _timeProvider;
private const string KeyPrefix = "stellaops:exc";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
};
public RedisExceptionEffectiveCache(
IConnectionMultiplexer redis,
IExceptionRepository repository,
ILogger<RedisExceptionEffectiveCache> logger,
IOptions<PolicyEngineOptions> options,
TimeProvider timeProvider)
{
_redis = redis ?? throw new ArgumentNullException(nameof(redis));
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options?.Value.ExceptionCache ?? new ExceptionCacheOptions();
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task<ExceptionCacheQueryResult> GetForAssetAsync(
string tenantId,
string assetId,
string? advisoryId,
DateTimeOffset asOf,
CancellationToken cancellationToken = default)
{
var sw = Stopwatch.StartNew();
var db = _redis.GetDatabase();
// Try specific advisory key first, then fall back to "all"
var entries = new List<ExceptionCacheEntry>();
var fromCache = false;
if (advisoryId is not null)
{
var specificKey = GetAssetKey(tenantId, assetId, advisoryId);
var specificJson = await db.StringGetAsync(specificKey).ConfigureAwait(false);
if (specificJson.HasValue)
{
var specificEntries = JsonSerializer.Deserialize<List<ExceptionCacheEntry>>((string)specificJson!, JsonOptions);
if (specificEntries is not null)
{
entries.AddRange(specificEntries);
fromCache = true;
}
}
}
// Also get "all" entries (exceptions without specific advisory)
var allKey = GetAssetKey(tenantId, assetId, null);
var allJson = await db.StringGetAsync(allKey).ConfigureAwait(false);
if (allJson.HasValue)
{
var allEntries = JsonSerializer.Deserialize<List<ExceptionCacheEntry>>((string)allJson!, JsonOptions);
if (allEntries is not null)
{
entries.AddRange(allEntries);
fromCache = true;
}
}
// Filter by time and sort by priority
var validEntries = entries
.Where(e => e.EffectiveFrom <= asOf && (e.ExpiresAt is null || e.ExpiresAt > asOf))
.OrderByDescending(e => e.Priority)
.ToImmutableArray();
var version = await GetVersionAsync(tenantId, cancellationToken).ConfigureAwait(false);
sw.Stop();
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, fromCache ? "hit" : "miss");
return new ExceptionCacheQueryResult
{
Entries = validEntries,
FromCache = fromCache,
CacheVersion = version,
QueryDurationMs = sw.ElapsedMilliseconds,
};
}
public async Task<IReadOnlyDictionary<string, ExceptionCacheQueryResult>> GetBatchAsync(
string tenantId,
IReadOnlyList<string> assetIds,
DateTimeOffset asOf,
CancellationToken cancellationToken = default)
{
var results = new Dictionary<string, ExceptionCacheQueryResult>(StringComparer.OrdinalIgnoreCase);
var db = _redis.GetDatabase();
// Get all "all" keys for assets
var keys = assetIds.Select(id => (RedisKey)GetAssetKey(tenantId, id, null)).ToArray();
var values = await db.StringGetAsync(keys).ConfigureAwait(false);
var version = await GetVersionAsync(tenantId, cancellationToken).ConfigureAwait(false);
for (int i = 0; i < assetIds.Count; i++)
{
var entries = ImmutableArray<ExceptionCacheEntry>.Empty;
var fromCache = false;
if (values[i].HasValue)
{
var cachedEntries = JsonSerializer.Deserialize<List<ExceptionCacheEntry>>((string)values[i]!, JsonOptions);
if (cachedEntries is not null)
{
entries = cachedEntries
.Where(e => e.EffectiveFrom <= asOf && (e.ExpiresAt is null || e.ExpiresAt > asOf))
.OrderByDescending(e => e.Priority)
.ToImmutableArray();
fromCache = true;
}
}
results[assetIds[i]] = new ExceptionCacheQueryResult
{
Entries = entries,
FromCache = fromCache,
CacheVersion = version,
QueryDurationMs = 0,
};
}
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, "batch_get");
return results;
}
public async Task SetAsync(
string tenantId,
ExceptionCacheEntry entry,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entry);
var db = _redis.GetDatabase();
var assetKey = GetAssetKey(tenantId, entry.AssetId, entry.AdvisoryId);
var exceptionIndexKey = GetExceptionIndexKey(tenantId, entry.ExceptionId);
// Get existing entries for this asset
var existingJson = await db.StringGetAsync(assetKey).ConfigureAwait(false);
var entries = existingJson.HasValue
? JsonSerializer.Deserialize<List<ExceptionCacheEntry>>((string)existingJson!, JsonOptions) ?? new List<ExceptionCacheEntry>()
: new List<ExceptionCacheEntry>();
// Remove existing entry for same exception if any
entries.RemoveAll(e => e.ExceptionId == entry.ExceptionId);
// Add new entry
entries.Add(entry);
var ttl = ComputeTtl(entry);
var json = JsonSerializer.Serialize(entries, JsonOptions);
var tasks = new List<Task>
{
db.StringSetAsync(assetKey, json, ttl),
db.SetAddAsync(exceptionIndexKey, assetKey),
db.KeyExpireAsync(exceptionIndexKey, ttl + TimeSpan.FromMinutes(5)),
};
await Task.WhenAll(tasks).ConfigureAwait(false);
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, "set");
}
public async Task SetBatchAsync(
string tenantId,
IEnumerable<ExceptionCacheEntry> entries,
CancellationToken cancellationToken = default)
{
var db = _redis.GetDatabase();
var batch = db.CreateBatch();
var count = 0;
// Group entries by asset+advisory
var groupedEntries = entries
.GroupBy(e => GetAssetKey(tenantId, e.AssetId, e.AdvisoryId))
.ToDictionary(g => g.Key, g => g.ToList());
foreach (var (assetKey, assetEntries) in groupedEntries)
{
var ttl = assetEntries.Max(e => ComputeTtl(e));
var json = JsonSerializer.Serialize(assetEntries, JsonOptions);
_ = batch.StringSetAsync(assetKey, json, ttl);
// Update exception indexes
foreach (var entry in assetEntries)
{
var exceptionIndexKey = GetExceptionIndexKey(tenantId, entry.ExceptionId);
_ = batch.SetAddAsync(exceptionIndexKey, assetKey);
_ = batch.KeyExpireAsync(exceptionIndexKey, ttl + TimeSpan.FromMinutes(5));
}
count += assetEntries.Count;
}
batch.Execute();
// Increment version
await IncrementVersionAsync(tenantId, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, "set_batch");
_logger.LogDebug("Set {Count} exception cache entries for tenant {TenantId}", count, tenantId);
}
public async Task InvalidateExceptionAsync(
string tenantId,
string exceptionId,
CancellationToken cancellationToken = default)
{
var db = _redis.GetDatabase();
var exceptionIndexKey = GetExceptionIndexKey(tenantId, exceptionId);
// Get all asset keys affected by this exception
var assetKeys = await db.SetMembersAsync(exceptionIndexKey).ConfigureAwait(false);
if (assetKeys.Length > 0)
{
// For each asset key, remove entries for this exception
foreach (var assetKey in assetKeys)
{
var json = await db.StringGetAsync((string)assetKey!).ConfigureAwait(false);
if (json.HasValue)
{
var entries = JsonSerializer.Deserialize<List<ExceptionCacheEntry>>((string)json!, JsonOptions);
if (entries is not null)
{
entries.RemoveAll(e => e.ExceptionId == exceptionId);
if (entries.Count > 0)
{
await db.StringSetAsync((string)assetKey!, JsonSerializer.Serialize(entries, JsonOptions))
.ConfigureAwait(false);
}
else
{
await db.KeyDeleteAsync((string)assetKey!).ConfigureAwait(false);
}
}
}
}
}
// Delete the exception index
await db.KeyDeleteAsync(exceptionIndexKey).ConfigureAwait(false);
// Increment version
await IncrementVersionAsync(tenantId, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, "invalidate_exception");
_logger.LogInformation(
"Invalidated exception {ExceptionId} affecting {Count} assets for tenant {TenantId}",
exceptionId, assetKeys.Length, tenantId);
}
public async Task InvalidateAssetAsync(
string tenantId,
string assetId,
CancellationToken cancellationToken = default)
{
var db = _redis.GetDatabase();
var server = _redis.GetServer(_redis.GetEndPoints().First());
// Find all keys for this asset (all advisory variants)
var pattern = $"{KeyPrefix}:{tenantId}:a:{assetId}:*";
var keys = server.Keys(pattern: pattern).ToArray();
if (keys.Length > 0)
{
await db.KeyDeleteAsync(keys).ConfigureAwait(false);
}
// Increment version
await IncrementVersionAsync(tenantId, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, "invalidate_asset");
_logger.LogDebug("Invalidated {Count} cache keys for asset {AssetId}", keys.Length, assetId);
}
public async Task InvalidateTenantAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
var server = _redis.GetServer(_redis.GetEndPoints().First());
var pattern = $"{KeyPrefix}:{tenantId}:*";
var keys = server.Keys(pattern: pattern).ToArray();
if (keys.Length > 0)
{
var db = _redis.GetDatabase();
await db.KeyDeleteAsync(keys).ConfigureAwait(false);
}
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, "invalidate_tenant");
_logger.LogInformation("Invalidated {Count} cache keys for tenant {TenantId}", keys.Length, tenantId);
}
public async Task WarmAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity(
"exception.cache.warm", ActivityKind.Internal);
activity?.SetTag("tenant_id", tenantId);
var sw = Stopwatch.StartNew();
var now = _timeProvider.GetUtcNow();
_logger.LogInformation("Starting cache warm for tenant {TenantId}", tenantId);
try
{
// Get all active exceptions from repository
var exceptions = await _repository.ListExceptionsAsync(
tenantId,
new ExceptionQueryOptions
{
Statuses = ImmutableArray.Create("active"),
IncludeExpired = false,
Limit = _options.MaxEntriesPerTenant,
},
cancellationToken).ConfigureAwait(false);
if (exceptions.Length == 0)
{
_logger.LogDebug("No active exceptions to warm for tenant {TenantId}", tenantId);
return;
}
// Get bindings for all exceptions
var entries = new List<ExceptionCacheEntry>();
foreach (var exception in exceptions)
{
var bindings = await _repository.GetBindingsForExceptionAsync(
tenantId, exception.Id, cancellationToken).ConfigureAwait(false);
foreach (var binding in bindings.Where(b => b.Status == "active"))
{
entries.Add(new ExceptionCacheEntry
{
ExceptionId = exception.Id,
AssetId = binding.AssetId,
AdvisoryId = binding.AdvisoryId,
CveId = binding.CveId,
DecisionOverride = binding.DecisionOverride,
ExceptionType = exception.ExceptionType,
Priority = exception.Priority,
EffectiveFrom = binding.EffectiveFrom,
ExpiresAt = binding.ExpiresAt ?? exception.ExpiresAt,
CachedAt = now,
ExceptionName = exception.Name,
});
}
// Also add entries for scope-based exceptions without explicit bindings
if (exception.Scope.ApplyToAll || exception.Scope.AssetIds.Count > 0)
{
foreach (var assetId in exception.Scope.AssetIds)
{
foreach (var advisoryId in exception.Scope.AdvisoryIds.DefaultIfEmpty(null!))
{
entries.Add(new ExceptionCacheEntry
{
ExceptionId = exception.Id,
AssetId = assetId,
AdvisoryId = advisoryId,
CveId = null,
DecisionOverride = "allow",
ExceptionType = exception.ExceptionType,
Priority = exception.Priority,
EffectiveFrom = exception.EffectiveFrom ?? exception.CreatedAt,
ExpiresAt = exception.ExpiresAt,
CachedAt = now,
ExceptionName = exception.Name,
});
}
}
}
}
if (entries.Count > 0)
{
await SetBatchAsync(tenantId, entries, cancellationToken).ConfigureAwait(false);
}
sw.Stop();
// Update warm stats
await UpdateWarmStatsAsync(tenantId, now, entries.Count).ConfigureAwait(false);
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, "warm");
_logger.LogInformation(
"Warmed cache with {Count} entries from {ExceptionCount} exceptions for tenant {TenantId} in {Duration}ms",
entries.Count, exceptions.Length, tenantId, sw.ElapsedMilliseconds);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to warm cache for tenant {TenantId}", tenantId);
PolicyEngineTelemetry.RecordError("exception_cache_warm", tenantId);
throw;
}
}
public async Task<ExceptionCacheSummary> GetSummaryAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
var server = _redis.GetServer(_redis.GetEndPoints().First());
var db = _redis.GetDatabase();
var now = _timeProvider.GetUtcNow();
// Count asset keys
var assetPattern = $"{KeyPrefix}:{tenantId}:a:*";
var assetKeys = server.Keys(pattern: assetPattern).ToArray();
// Count exception index keys
var exceptionPattern = $"{KeyPrefix}:{tenantId}:idx:e:*";
var exceptionKeys = server.Keys(pattern: exceptionPattern).ToArray();
// Aggregate stats
var byType = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var byDecision = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var totalEntries = 0;
var expiringWithinHour = 0;
var uniqueAssets = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var key in assetKeys.Take(1000)) // Limit scan for performance
{
var json = await db.StringGetAsync(key).ConfigureAwait(false);
if (!json.HasValue) continue;
var entries = JsonSerializer.Deserialize<List<ExceptionCacheEntry>>((string)json!, JsonOptions);
if (entries is null) continue;
foreach (var entry in entries)
{
totalEntries++;
uniqueAssets.Add(entry.AssetId);
byType.TryGetValue(entry.ExceptionType, out var typeCount);
byType[entry.ExceptionType] = typeCount + 1;
byDecision.TryGetValue(entry.DecisionOverride, out var decisionCount);
byDecision[entry.DecisionOverride] = decisionCount + 1;
if (entry.ExpiresAt.HasValue && entry.ExpiresAt.Value - now <= TimeSpan.FromHours(1))
{
expiringWithinHour++;
}
}
}
var version = await GetVersionAsync(tenantId, cancellationToken).ConfigureAwait(false);
return new ExceptionCacheSummary
{
TenantId = tenantId,
TotalEntries = totalEntries,
UniqueExceptions = exceptionKeys.Length,
UniqueAssets = uniqueAssets.Count,
ByType = byType,
ByDecision = byDecision,
ExpiringWithinHour = expiringWithinHour,
CacheVersion = version,
ComputedAt = now,
};
}
public async Task<ExceptionCacheStats> GetStatsAsync(
string? tenantId = null,
CancellationToken cancellationToken = default)
{
var server = _redis.GetServer(_redis.GetEndPoints().First());
var pattern = tenantId != null
? $"{KeyPrefix}:{tenantId}:a:*"
: $"{KeyPrefix}:*:a:*";
var entryCount = server.Keys(pattern: pattern).Count();
var tenantPattern = tenantId != null
? $"{KeyPrefix}:{tenantId}:v"
: $"{KeyPrefix}:*:v";
var tenantCount = server.Keys(pattern: tenantPattern).Count();
long? memoryUsed = null;
try
{
var info = server.Info("memory");
var memorySection = info.FirstOrDefault(s => s.Key == "Memory");
if (memorySection is not null)
{
var usedMemory = memorySection.FirstOrDefault(p => p.Key == "used_memory");
if (usedMemory.Key is not null && long.TryParse(usedMemory.Value, out var bytes))
{
memoryUsed = bytes;
}
}
}
catch
{
// Ignore - memory info not available
}
return new ExceptionCacheStats
{
TotalEntries = entryCount,
TotalTenants = tenantCount,
MemoryUsedBytes = memoryUsed,
HitCount = 0, // Would need to track separately
MissCount = 0,
LastWarmAt = null,
LastInvalidationAt = null,
};
}
public async Task<long> GetVersionAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
var db = _redis.GetDatabase();
var versionKey = GetVersionKey(tenantId);
var version = await db.StringGetAsync(versionKey).ConfigureAwait(false);
return version.HasValue ? (long)version : 0;
}
public async Task HandleExceptionEventAsync(
ExceptionEvent exceptionEvent,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(exceptionEvent);
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity(
"exception.cache.handle_event", ActivityKind.Internal);
activity?.SetTag("tenant_id", exceptionEvent.TenantId);
activity?.SetTag("event_type", exceptionEvent.EventType);
activity?.SetTag("exception_id", exceptionEvent.ExceptionId);
_logger.LogDebug(
"Handling exception event {EventType} for exception {ExceptionId} tenant {TenantId}",
exceptionEvent.EventType, exceptionEvent.ExceptionId, exceptionEvent.TenantId);
switch (exceptionEvent.EventType.ToLowerInvariant())
{
case "activated":
// Warm the cache with the new exception
await WarmExceptionAsync(exceptionEvent.TenantId, exceptionEvent.ExceptionId, cancellationToken)
.ConfigureAwait(false);
break;
case "expired":
case "revoked":
case "deleted":
// Invalidate cache entries for this exception
await InvalidateExceptionAsync(exceptionEvent.TenantId, exceptionEvent.ExceptionId, cancellationToken)
.ConfigureAwait(false);
break;
case "updated":
// Invalidate and re-warm
await InvalidateExceptionAsync(exceptionEvent.TenantId, exceptionEvent.ExceptionId, cancellationToken)
.ConfigureAwait(false);
await WarmExceptionAsync(exceptionEvent.TenantId, exceptionEvent.ExceptionId, cancellationToken)
.ConfigureAwait(false);
break;
case "created":
// Only warm if already active
var exception = await _repository.GetExceptionAsync(
exceptionEvent.TenantId, exceptionEvent.ExceptionId, cancellationToken).ConfigureAwait(false);
if (exception?.Status == "active")
{
await WarmExceptionAsync(exceptionEvent.TenantId, exceptionEvent.ExceptionId, cancellationToken)
.ConfigureAwait(false);
}
break;
default:
_logger.LogWarning("Unknown exception event type: {EventType}", exceptionEvent.EventType);
break;
}
PolicyEngineTelemetry.RecordExceptionCacheOperation(exceptionEvent.TenantId, $"event_{exceptionEvent.EventType}");
}
private async Task WarmExceptionAsync(string tenantId, string exceptionId, CancellationToken cancellationToken)
{
var exception = await _repository.GetExceptionAsync(tenantId, exceptionId, cancellationToken)
.ConfigureAwait(false);
if (exception is null || exception.Status != "active")
{
return;
}
var now = _timeProvider.GetUtcNow();
var entries = new List<ExceptionCacheEntry>();
var bindings = await _repository.GetBindingsForExceptionAsync(tenantId, exceptionId, cancellationToken)
.ConfigureAwait(false);
foreach (var binding in bindings.Where(b => b.Status == "active"))
{
entries.Add(new ExceptionCacheEntry
{
ExceptionId = exception.Id,
AssetId = binding.AssetId,
AdvisoryId = binding.AdvisoryId,
CveId = binding.CveId,
DecisionOverride = binding.DecisionOverride,
ExceptionType = exception.ExceptionType,
Priority = exception.Priority,
EffectiveFrom = binding.EffectiveFrom,
ExpiresAt = binding.ExpiresAt ?? exception.ExpiresAt,
CachedAt = now,
ExceptionName = exception.Name,
});
}
if (entries.Count > 0)
{
await SetBatchAsync(tenantId, entries, cancellationToken).ConfigureAwait(false);
}
_logger.LogDebug(
"Warmed cache with {Count} entries for exception {ExceptionId}",
entries.Count, exceptionId);
}
private async Task<long> IncrementVersionAsync(string tenantId, CancellationToken cancellationToken)
{
var db = _redis.GetDatabase();
var versionKey = GetVersionKey(tenantId);
var newVersion = await db.StringIncrementAsync(versionKey).ConfigureAwait(false);
// Set TTL on version key if not already set
await db.KeyExpireAsync(versionKey, TimeSpan.FromMinutes(_options.DefaultTtlMinutes + 10), ExpireWhen.HasNoExpiry)
.ConfigureAwait(false);
return newVersion;
}
private async Task UpdateWarmStatsAsync(string tenantId, DateTimeOffset warmAt, int count)
{
var db = _redis.GetDatabase();
var statsKey = GetStatsKey(tenantId);
var stats = new Dictionary<string, string>
{
["lastWarmAt"] = warmAt.ToString("O"),
["lastWarmCount"] = count.ToString(),
};
await db.HashSetAsync(statsKey, stats.Select(kv => new HashEntry(kv.Key, kv.Value)).ToArray())
.ConfigureAwait(false);
}
private TimeSpan ComputeTtl(ExceptionCacheEntry entry)
{
if (entry.ExpiresAt.HasValue)
{
var ttl = entry.ExpiresAt.Value - _timeProvider.GetUtcNow();
if (ttl > TimeSpan.Zero)
{
return ttl;
}
}
return TimeSpan.FromMinutes(_options.DefaultTtlMinutes);
}
private static string GetAssetKey(string tenantId, string assetId, string? advisoryId) =>
$"{KeyPrefix}:{tenantId}:a:{assetId}:{advisoryId ?? "all"}";
private static string GetExceptionIndexKey(string tenantId, string exceptionId) =>
$"{KeyPrefix}:{tenantId}:idx:e:{exceptionId}";
private static string GetVersionKey(string tenantId) =>
$"{KeyPrefix}:{tenantId}:v";
private static string GetStatsKey(string tenantId) =>
$"{KeyPrefix}:{tenantId}:stats";
}

View File

@@ -1,6 +1,8 @@
using System.Collections.ObjectModel;
using StellaOps.Auth.Abstractions;
using StellaOps.Policy.Engine.Caching;
using StellaOps.Policy.Engine.EffectiveDecisionMap;
using StellaOps.Policy.Engine.ExceptionCache;
using StellaOps.Policy.Engine.ReachabilityFacts;
using StellaOps.Policy.Engine.Telemetry;
@@ -33,6 +35,10 @@ public sealed class PolicyEngineOptions
public PolicyEvaluationCacheOptions EvaluationCache { get; } = new();
public EffectiveDecisionMapOptions EffectiveDecisionMap { get; } = new();
public ExceptionCacheOptions ExceptionCache { get; } = new();
public void Validate()
{
Authority.Validate();

View File

@@ -79,6 +79,7 @@ internal sealed class PolicyBundleService
Size: payload.Length,
CreatedAt: createdAt,
Payload: payload.ToImmutableArray(),
CompiledDocument: compileResult.Document,
AocMetadata: aocMetadata);
await _repository.StoreBundleAsync(packId, version, record, cancellationToken).ConfigureAwait(false);

View File

@@ -1,9 +1,12 @@
using System;
using System.Collections.Immutable;
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Telemetry;
using StellaOps.PolicyDsl;
using DslCompiler = StellaOps.PolicyDsl.PolicyCompiler;
using DslCompilationResult = StellaOps.PolicyDsl.PolicyCompilationResult;
@@ -27,19 +30,25 @@ internal sealed class PolicyCompilationService
{
private readonly DslCompiler compiler;
private readonly PolicyComplexityAnalyzer complexityAnalyzer;
private readonly PolicyMetadataExtractor metadataExtractor;
private readonly IOptionsMonitor<PolicyEngineOptions> optionsMonitor;
private readonly TimeProvider timeProvider;
private readonly ILogger<PolicyCompilationService> _logger;
public PolicyCompilationService(
DslCompiler compiler,
PolicyComplexityAnalyzer complexityAnalyzer,
PolicyMetadataExtractor metadataExtractor,
IOptionsMonitor<PolicyEngineOptions> optionsMonitor,
TimeProvider timeProvider)
TimeProvider timeProvider,
ILogger<PolicyCompilationService>? logger = null)
{
this.compiler = compiler ?? throw new ArgumentNullException(nameof(compiler));
this.complexityAnalyzer = complexityAnalyzer ?? throw new ArgumentNullException(nameof(complexityAnalyzer));
this.metadataExtractor = metadataExtractor ?? throw new ArgumentNullException(nameof(metadataExtractor));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<PolicyCompilationService>.Instance;
}
public PolicyCompilationResultDto Compile(PolicyCompileRequest request)
@@ -56,6 +65,9 @@ internal sealed class PolicyCompilationService
if (!string.Equals(request.Dsl.Syntax, "stella-dsl@1", StringComparison.Ordinal))
{
PolicyEngineTelemetry.RecordCompilation("unsupported_syntax", 0);
PolicyEngineTelemetry.RecordError("compilation");
_logger.LogWarning("Compilation rejected: unsupported syntax {Syntax}", request.Dsl.Syntax ?? "null");
return PolicyCompilationResultDto.FromFailure(
ImmutableArray.Create(PolicyIssue.Error(
DiagnosticCodes.UnsupportedSyntaxVersion,
@@ -65,13 +77,23 @@ internal sealed class PolicyCompilationService
durationMilliseconds: 0);
}
using var activity = PolicyEngineTelemetry.StartCompileActivity(policyId: null, version: request.Dsl.Syntax);
var start = timeProvider.GetTimestamp();
var result = compiler.Compile(request.Dsl.Source);
var elapsed = timeProvider.GetElapsedTime(start, timeProvider.GetTimestamp());
var durationMilliseconds = (long)Math.Ceiling(elapsed.TotalMilliseconds);
var durationSeconds = elapsed.TotalSeconds;
if (!result.Success || result.Document is null)
{
PolicyEngineTelemetry.RecordCompilation("failure", durationSeconds);
PolicyEngineTelemetry.RecordError("compilation");
activity?.SetStatus(ActivityStatusCode.Error, "Compilation failed");
_logger.LogWarning(
"Policy compilation failed in {DurationMs}ms with {DiagnosticCount} diagnostics",
durationMilliseconds,
result.Diagnostics.IsDefault ? 0 : result.Diagnostics.Length);
return PolicyCompilationResultDto.FromFailure(result.Diagnostics, null, durationMilliseconds);
}
@@ -79,6 +101,9 @@ internal sealed class PolicyCompilationService
var diagnostics = result.Diagnostics.IsDefault ? ImmutableArray<PolicyIssue>.Empty : result.Diagnostics;
var limits = optionsMonitor.CurrentValue?.Compilation ?? new PolicyEngineCompilationOptions();
activity?.SetTag("policy.rule_count", result.Document.Rules.Length);
activity?.SetTag("policy.complexity_score", complexity.Score);
if (limits.EnforceComplexity && complexity.Score > limits.MaxComplexityScore)
{
var diagnostic = PolicyIssue.Error(
@@ -86,6 +111,12 @@ internal sealed class PolicyCompilationService
$"Policy complexity score {complexity.Score:F2} exceeds configured maximum {limits.MaxComplexityScore:F2}. Reduce rule count or expression depth.",
"$.rules");
diagnostics = AppendDiagnostic(diagnostics, diagnostic);
PolicyEngineTelemetry.RecordCompilation("complexity_exceeded", durationSeconds);
PolicyEngineTelemetry.RecordError("compilation");
activity?.SetStatus(ActivityStatusCode.Error, "Complexity exceeded");
_logger.LogWarning(
"Policy compilation rejected: complexity {Score:F2} exceeds limit {MaxScore:F2}",
complexity.Score, limits.MaxComplexityScore);
return PolicyCompilationResultDto.FromFailure(diagnostics, complexity, durationMilliseconds);
}
@@ -96,10 +127,27 @@ internal sealed class PolicyCompilationService
$"Policy compilation time {durationMilliseconds} ms exceeded limit {limits.MaxDurationMilliseconds} ms.",
"$.dsl");
diagnostics = AppendDiagnostic(diagnostics, diagnostic);
PolicyEngineTelemetry.RecordCompilation("duration_exceeded", durationSeconds);
PolicyEngineTelemetry.RecordError("compilation");
activity?.SetStatus(ActivityStatusCode.Error, "Duration exceeded");
_logger.LogWarning(
"Policy compilation rejected: duration {DurationMs}ms exceeds limit {MaxDurationMs}ms",
durationMilliseconds, limits.MaxDurationMilliseconds);
return PolicyCompilationResultDto.FromFailure(diagnostics, complexity, durationMilliseconds);
}
return PolicyCompilationResultDto.FromSuccess(result, complexity, durationMilliseconds);
// Extract extended metadata (symbol table, rule index, documentation, coverage, hashes)
var metadata = metadataExtractor.Extract(result.Document, result.CanonicalRepresentation);
PolicyEngineTelemetry.RecordCompilation("success", durationSeconds);
activity?.SetStatus(ActivityStatusCode.Ok);
activity?.SetTag("policy.symbol_count", metadata.SymbolTable.Symbols.Length);
activity?.SetTag("policy.coverage_paths", metadata.CoverageMetadata.CoveragePaths.Length);
_logger.LogDebug(
"Policy compiled successfully in {DurationMs}ms: {RuleCount} rules, complexity {Score:F2}, {SymbolCount} symbols",
durationMilliseconds, result.Document.Rules.Length, complexity.Score, metadata.SymbolTable.Symbols.Length);
return PolicyCompilationResultDto.FromSuccess(result, complexity, metadata, durationMilliseconds);
}
private static ImmutableArray<PolicyIssue> AppendDiagnostic(ImmutableArray<PolicyIssue> diagnostics, PolicyIssue diagnostic)
@@ -119,17 +167,20 @@ internal sealed record PolicyCompilationResultDto(
ImmutableArray<byte> CanonicalRepresentation,
ImmutableArray<PolicyIssue> Diagnostics,
PolicyComplexityReport? Complexity,
long DurationMilliseconds)
long DurationMilliseconds,
IrDocument? Document = null,
PolicyCompileMetadata? Metadata = null)
{
public static PolicyCompilationResultDto FromFailure(
ImmutableArray<PolicyIssue> diagnostics,
PolicyComplexityReport? complexity,
long durationMilliseconds) =>
new(false, null, null, ImmutableArray<byte>.Empty, diagnostics, complexity, durationMilliseconds);
new(false, null, null, ImmutableArray<byte>.Empty, diagnostics, complexity, durationMilliseconds, null, null);
public static PolicyCompilationResultDto FromSuccess(
DslCompilationResult compilationResult,
PolicyComplexityReport complexity,
PolicyCompileMetadata metadata,
long durationMilliseconds)
{
if (compilationResult.Document is null)
@@ -145,7 +196,9 @@ internal sealed record PolicyCompilationResultDto(
compilationResult.CanonicalRepresentation,
compilationResult.Diagnostics,
complexity,
durationMilliseconds);
durationMilliseconds,
compilationResult.Document,
metadata);
}
}

View File

@@ -0,0 +1,497 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.Domain;
using StellaOps.Policy.Engine.Telemetry;
namespace StellaOps.Policy.Engine.Services;
/// <summary>
/// Query options for retrieving explain traces.
/// </summary>
public sealed record ExplainQueryOptions
{
/// <summary>
/// Filter by policy ID.
/// </summary>
public string? PolicyId { get; init; }
/// <summary>
/// Filter by policy version.
/// </summary>
public int? PolicyVersion { get; init; }
/// <summary>
/// Filter by run ID.
/// </summary>
public string? RunId { get; init; }
/// <summary>
/// Filter by component PURL.
/// </summary>
public string? ComponentPurl { get; init; }
/// <summary>
/// Filter by vulnerability ID.
/// </summary>
public string? VulnerabilityId { get; init; }
/// <summary>
/// Filter by final outcome.
/// </summary>
public string? FinalOutcome { get; init; }
/// <summary>
/// Filter by evaluation time range start.
/// </summary>
public DateTimeOffset? FromTime { get; init; }
/// <summary>
/// Filter by evaluation time range end.
/// </summary>
public DateTimeOffset? ToTime { get; init; }
/// <summary>
/// Maximum number of results to return.
/// </summary>
public int Limit { get; init; } = 100;
/// <summary>
/// Number of results to skip for pagination.
/// </summary>
public int Skip { get; init; } = 0;
/// <summary>
/// Include rule steps in results (can be large).
/// </summary>
public bool IncludeRuleSteps { get; init; } = true;
/// <summary>
/// Include VEX evidence in results.
/// </summary>
public bool IncludeVexEvidence { get; init; } = true;
}
/// <summary>
/// Stored explain trace with AOC chain reference.
/// </summary>
public sealed record StoredExplainTrace
{
/// <summary>
/// Unique identifier.
/// </summary>
public required string Id { get; init; }
/// <summary>
/// The explain trace data.
/// </summary>
public required ExplainTrace Trace { get; init; }
/// <summary>
/// Reference to the AOC chain for this decision.
/// </summary>
public ExplainAocChain? AocChain { get; init; }
/// <summary>
/// When this trace was stored.
/// </summary>
public required DateTimeOffset StoredAt { get; init; }
}
/// <summary>
/// AOC chain linking a decision to its attestation chain.
/// </summary>
public sealed record ExplainAocChain
{
/// <summary>
/// Compilation ID that produced the policy bundle.
/// </summary>
public required string CompilationId { get; init; }
/// <summary>
/// Compiler version used.
/// </summary>
public required string CompilerVersion { get; init; }
/// <summary>
/// Source digest of the policy document.
/// </summary>
public required string SourceDigest { get; init; }
/// <summary>
/// Artifact digest of the compiled bundle.
/// </summary>
public required string ArtifactDigest { get; init; }
/// <summary>
/// Reference to the signed attestation.
/// </summary>
public ExplainAttestationRef? AttestationRef { get; init; }
/// <summary>
/// Provenance information.
/// </summary>
public ExplainProvenance? Provenance { get; init; }
}
/// <summary>
/// Attestation reference for AOC chain.
/// </summary>
public sealed record ExplainAttestationRef(
string AttestationId,
string EnvelopeDigest,
string? Uri,
string? SigningKeyId);
/// <summary>
/// Provenance for AOC chain.
/// </summary>
public sealed record ExplainProvenance(
string SourceType,
string? SourceUrl,
string? Submitter,
string? CommitSha,
string? Branch);
/// <summary>
/// Repository interface for explain trace persistence.
/// </summary>
public interface IExplainTraceRepository
{
/// <summary>
/// Stores an explain trace.
/// </summary>
Task<StoredExplainTrace> StoreAsync(
string tenantId,
ExplainTrace trace,
ExplainAocChain? aocChain,
TimeSpan? retention,
CancellationToken cancellationToken);
/// <summary>
/// Retrieves an explain trace by ID.
/// </summary>
Task<StoredExplainTrace?> GetByIdAsync(
string tenantId,
string id,
CancellationToken cancellationToken);
/// <summary>
/// Retrieves an explain trace by run ID and subject hash.
/// </summary>
Task<StoredExplainTrace?> GetByRunAndSubjectAsync(
string tenantId,
string runId,
string subjectHash,
CancellationToken cancellationToken);
/// <summary>
/// Queries explain traces with filtering and pagination.
/// </summary>
Task<IReadOnlyList<StoredExplainTrace>> QueryAsync(
string tenantId,
ExplainQueryOptions options,
CancellationToken cancellationToken);
/// <summary>
/// Gets all explain traces for a policy run.
/// </summary>
Task<IReadOnlyList<StoredExplainTrace>> GetByRunIdAsync(
string tenantId,
string runId,
CancellationToken cancellationToken);
/// <summary>
/// Deletes explain traces older than the specified retention period.
/// </summary>
Task<int> PruneExpiredAsync(
string tenantId,
CancellationToken cancellationToken);
}
/// <summary>
/// Service for persisting and retrieving policy explain traces with AOC chain linkage.
/// </summary>
internal sealed class PolicyExplainerService
{
private readonly IExplainTraceRepository _repository;
private readonly IPolicyPackRepository _policyRepository;
private readonly ILogger<PolicyExplainerService> _logger;
private readonly TimeProvider _timeProvider;
private readonly TimeSpan _defaultRetention;
public PolicyExplainerService(
IExplainTraceRepository repository,
IPolicyPackRepository policyRepository,
ILogger<PolicyExplainerService> logger,
TimeProvider timeProvider,
TimeSpan? defaultRetention = null)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_policyRepository = policyRepository ?? throw new ArgumentNullException(nameof(policyRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_defaultRetention = defaultRetention ?? TimeSpan.FromDays(30);
}
/// <summary>
/// Stores an explain trace and links it to the AOC chain from the policy bundle.
/// </summary>
public async Task<StoredExplainTrace> StoreExplainTraceAsync(
string tenantId,
ExplainTrace trace,
TimeSpan? retention = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenantId);
ArgumentNullException.ThrowIfNull(trace);
_logger.LogDebug(
"Storing explain trace for run {RunId}, policy {PolicyId}:{Version}, tenant {TenantId}",
trace.RunId, trace.PolicyId, trace.PolicyVersion, tenantId);
// Try to get AOC chain from the policy bundle
ExplainAocChain? aocChain = null;
if (trace.PolicyVersion.HasValue)
{
var revision = await _policyRepository.GetRevisionAsync(
trace.PolicyId,
trace.PolicyVersion.Value,
cancellationToken).ConfigureAwait(false);
if (revision?.Bundle?.AocMetadata is not null)
{
var aoc = revision.Bundle.AocMetadata;
aocChain = new ExplainAocChain
{
CompilationId = aoc.CompilationId,
CompilerVersion = aoc.CompilerVersion,
SourceDigest = aoc.SourceDigest,
ArtifactDigest = aoc.ArtifactDigest,
AttestationRef = aoc.AttestationRef is not null
? new ExplainAttestationRef(
aoc.AttestationRef.AttestationId,
aoc.AttestationRef.EnvelopeDigest,
aoc.AttestationRef.Uri,
aoc.AttestationRef.SigningKeyId)
: null,
Provenance = aoc.Provenance is not null
? new ExplainProvenance(
aoc.Provenance.SourceType,
aoc.Provenance.SourceUrl,
aoc.Provenance.Submitter,
aoc.Provenance.CommitSha,
aoc.Provenance.Branch)
: null
};
_logger.LogDebug(
"Linked explain trace to AOC chain: compilation {CompilationId}, attestation {AttestationId}",
aocChain.CompilationId,
aocChain.AttestationRef?.AttestationId ?? "(none)");
}
}
var stored = await _repository.StoreAsync(
tenantId,
trace,
aocChain,
retention ?? _defaultRetention,
cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.ExplainTracesStored.Add(1,
new KeyValuePair<string, object?>("tenant_id", tenantId),
new KeyValuePair<string, object?>("policy_id", trace.PolicyId));
return stored;
}
/// <summary>
/// Retrieves an explain trace by its ID.
/// </summary>
public Task<StoredExplainTrace?> GetExplainTraceAsync(
string tenantId,
string traceId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenantId);
ArgumentNullException.ThrowIfNull(traceId);
return _repository.GetByIdAsync(tenantId, traceId, cancellationToken);
}
/// <summary>
/// Retrieves an explain trace for a specific decision.
/// </summary>
public Task<StoredExplainTrace?> GetExplainTraceForDecisionAsync(
string tenantId,
string runId,
string subjectHash,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenantId);
ArgumentNullException.ThrowIfNull(runId);
ArgumentNullException.ThrowIfNull(subjectHash);
return _repository.GetByRunAndSubjectAsync(tenantId, runId, subjectHash, cancellationToken);
}
/// <summary>
/// Gets all explain traces for a policy run.
/// </summary>
public Task<IReadOnlyList<StoredExplainTrace>> GetExplainTracesForRunAsync(
string tenantId,
string runId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenantId);
ArgumentNullException.ThrowIfNull(runId);
return _repository.GetByRunIdAsync(tenantId, runId, cancellationToken);
}
/// <summary>
/// Queries explain traces with filtering and pagination.
/// </summary>
public Task<IReadOnlyList<StoredExplainTrace>> QueryExplainTracesAsync(
string tenantId,
ExplainQueryOptions options,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenantId);
options ??= new ExplainQueryOptions();
return _repository.QueryAsync(tenantId, options, cancellationToken);
}
/// <summary>
/// Gets the AOC chain for a stored explain trace.
/// </summary>
public async Task<ExplainAocChain?> GetAocChainForTraceAsync(
string tenantId,
string traceId,
CancellationToken cancellationToken = default)
{
var trace = await GetExplainTraceAsync(tenantId, traceId, cancellationToken).ConfigureAwait(false);
return trace?.AocChain;
}
/// <summary>
/// Validates that an explain trace's AOC chain is intact.
/// </summary>
public async Task<AocChainValidationResult> ValidateAocChainAsync(
string tenantId,
string traceId,
CancellationToken cancellationToken = default)
{
var trace = await GetExplainTraceAsync(tenantId, traceId, cancellationToken).ConfigureAwait(false);
if (trace is null)
{
return new AocChainValidationResult(
IsValid: false,
ValidationMessage: "Explain trace not found",
PolicyFound: false,
BundleIntact: false,
AttestationAvailable: false);
}
if (trace.AocChain is null)
{
return new AocChainValidationResult(
IsValid: false,
ValidationMessage: "No AOC chain linked to this trace",
PolicyFound: true,
BundleIntact: false,
AttestationAvailable: false);
}
// Verify the policy revision still exists
if (!trace.Trace.PolicyVersion.HasValue)
{
return new AocChainValidationResult(
IsValid: false,
ValidationMessage: "Trace has no policy version",
PolicyFound: false,
BundleIntact: false,
AttestationAvailable: false);
}
var revision = await _policyRepository.GetRevisionAsync(
trace.Trace.PolicyId,
trace.Trace.PolicyVersion.Value,
cancellationToken).ConfigureAwait(false);
if (revision is null)
{
return new AocChainValidationResult(
IsValid: false,
ValidationMessage: $"Policy revision {trace.Trace.PolicyId}:{trace.Trace.PolicyVersion} no longer exists",
PolicyFound: false,
BundleIntact: false,
AttestationAvailable: false);
}
// Verify bundle digest matches
var bundleIntact = revision.Bundle?.Digest == trace.AocChain.ArtifactDigest;
if (!bundleIntact)
{
return new AocChainValidationResult(
IsValid: false,
ValidationMessage: "Bundle digest mismatch - policy bundle has been modified",
PolicyFound: true,
BundleIntact: false,
AttestationAvailable: trace.AocChain.AttestationRef is not null);
}
// Verify AOC metadata matches
var aocMatches = revision.Bundle?.AocMetadata?.CompilationId == trace.AocChain.CompilationId &&
revision.Bundle?.AocMetadata?.SourceDigest == trace.AocChain.SourceDigest;
if (!aocMatches)
{
return new AocChainValidationResult(
IsValid: false,
ValidationMessage: "AOC metadata mismatch - compilation chain has been modified",
PolicyFound: true,
BundleIntact: true,
AttestationAvailable: trace.AocChain.AttestationRef is not null);
}
return new AocChainValidationResult(
IsValid: true,
ValidationMessage: "AOC chain is intact and verifiable",
PolicyFound: true,
BundleIntact: true,
AttestationAvailable: trace.AocChain.AttestationRef is not null);
}
/// <summary>
/// Prunes expired explain traces for a tenant.
/// </summary>
public async Task<int> PruneExpiredTracesAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenantId);
var pruned = await _repository.PruneExpiredAsync(tenantId, cancellationToken).ConfigureAwait(false);
if (pruned > 0)
{
_logger.LogInformation(
"Pruned {Count} expired explain traces for tenant {TenantId}",
pruned, tenantId);
}
return pruned;
}
}
/// <summary>
/// Result of AOC chain validation.
/// </summary>
public sealed record AocChainValidationResult(
bool IsValid,
string ValidationMessage,
bool PolicyFound,
bool BundleIntact,
bool AttestationAvailable);

View File

@@ -1,4 +1,5 @@
using System.Collections.Immutable;
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -6,6 +7,7 @@ using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.Caching;
using StellaOps.Policy.Engine.Domain;
using StellaOps.Policy.Engine.Evaluation;
using StellaOps.Policy.Engine.Telemetry;
using StellaOps.PolicyDsl;
namespace StellaOps.Policy.Engine.Services;
@@ -88,6 +90,12 @@ internal sealed class PolicyRuntimeEvaluationService
{
ArgumentNullException.ThrowIfNull(request);
using var activity = PolicyEngineTelemetry.StartEvaluateActivity(
request.TenantId, request.PackId, runId: null);
activity?.SetTag("policy.version", request.Version);
activity?.SetTag("subject.purl", request.SubjectPurl);
activity?.SetTag("advisory.id", request.AdvisoryId);
var startTimestamp = _timeProvider.GetTimestamp();
var evaluationTimestamp = request.EvaluationTimestamp ?? _timeProvider.GetUtcNow();
@@ -97,6 +105,9 @@ internal sealed class PolicyRuntimeEvaluationService
if (bundle is null)
{
PolicyEngineTelemetry.RecordError("evaluation", request.TenantId);
PolicyEngineTelemetry.RecordEvaluationFailure(request.TenantId, request.PackId, "bundle_not_found");
activity?.SetStatus(ActivityStatusCode.Error, "Bundle not found");
throw new InvalidOperationException(
$"Policy bundle not found for pack '{request.PackId}' version {request.Version}.");
}
@@ -113,6 +124,12 @@ internal sealed class PolicyRuntimeEvaluationService
if (cacheResult.CacheHit && cacheResult.Entry is not null)
{
var duration = GetElapsedMilliseconds(startTimestamp);
var durationSeconds = duration / 1000.0;
PolicyEngineTelemetry.RecordEvaluationLatency(durationSeconds, request.TenantId, request.PackId);
PolicyEngineTelemetry.RecordEvaluation(request.TenantId, request.PackId, "cached");
activity?.SetTag("cache.hit", true);
activity?.SetTag("cache.source", cacheResult.Source.ToString());
activity?.SetStatus(ActivityStatusCode.Ok);
_logger.LogDebug(
"Cache hit for evaluation {PackId}@{Version} subject {Subject} from {Source}",
request.PackId, request.Version, request.SubjectPurl, cacheResult.Source);
@@ -122,12 +139,17 @@ internal sealed class PolicyRuntimeEvaluationService
}
}
activity?.SetTag("cache.hit", false);
// Cache miss - perform evaluation
var document = DeserializeCompiledPolicy(bundle.Payload);
var document = bundle.CompiledDocument;
if (document is null)
{
PolicyEngineTelemetry.RecordError("evaluation", request.TenantId);
PolicyEngineTelemetry.RecordEvaluationFailure(request.TenantId, request.PackId, "document_not_found");
activity?.SetStatus(ActivityStatusCode.Error, "Document not found");
throw new InvalidOperationException(
$"Failed to deserialize compiled policy for pack '{request.PackId}' version {request.Version}.");
$"Compiled policy document not found for pack '{request.PackId}' version {request.Version}.");
}
var context = new PolicyEvaluationContext(
@@ -162,6 +184,21 @@ internal sealed class PolicyRuntimeEvaluationService
await _cache.SetAsync(cacheKey, cacheEntry, cancellationToken).ConfigureAwait(false);
var evalDuration = GetElapsedMilliseconds(startTimestamp);
var evalDurationSeconds = evalDuration / 1000.0;
// Record metrics
PolicyEngineTelemetry.RecordEvaluationLatency(evalDurationSeconds, request.TenantId, request.PackId);
PolicyEngineTelemetry.RecordEvaluation(request.TenantId, request.PackId, "full");
if (!string.IsNullOrEmpty(result.RuleName))
{
PolicyEngineTelemetry.RecordRuleFired(request.PackId, result.RuleName);
}
activity?.SetTag("evaluation.status", result.Status);
activity?.SetTag("evaluation.rule", result.RuleName ?? "none");
activity?.SetTag("evaluation.duration_ms", evalDuration);
activity?.SetStatus(ActivityStatusCode.Ok);
_logger.LogDebug(
"Evaluated {PackId}@{Version} subject {Subject} in {Duration}ms - {Status}",
request.PackId, request.Version, request.SubjectPurl, evalDuration, result.Status);
@@ -195,7 +232,13 @@ internal sealed class PolicyRuntimeEvaluationService
return Array.Empty<RuntimeEvaluationResponse>();
}
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity("policy.evaluate_batch", ActivityKind.Internal);
activity?.SetTag("batch.size", requests.Count);
var batchStartTimestamp = _timeProvider.GetTimestamp();
var results = new List<RuntimeEvaluationResponse>(requests.Count);
var cacheHits = 0;
var cacheMisses = 0;
// Group by pack/version for bundle loading efficiency
var groups = requests.GroupBy(r => (r.PackId, r.Version));
@@ -210,6 +253,7 @@ internal sealed class PolicyRuntimeEvaluationService
{
foreach (var request in group)
{
PolicyEngineTelemetry.RecordEvaluationFailure(request.TenantId, packId, "bundle_not_found");
_logger.LogWarning(
"Policy bundle not found for pack '{PackId}' version {Version}, skipping evaluation",
packId, version);
@@ -217,11 +261,12 @@ internal sealed class PolicyRuntimeEvaluationService
continue;
}
var document = DeserializeCompiledPolicy(bundle.Payload);
var document = bundle.CompiledDocument;
if (document is null)
{
PolicyEngineTelemetry.RecordEvaluationFailure("default", packId, "document_not_found");
_logger.LogWarning(
"Failed to deserialize policy bundle for pack '{PackId}' version {Version}",
"Compiled policy document not found for pack '{PackId}' version {Version}",
packId, version);
continue;
}
@@ -249,6 +294,8 @@ internal sealed class PolicyRuntimeEvaluationService
{
var response = CreateResponseFromCache(request, bundle.Digest, entry, CacheSource.InMemory, 0);
results.Add(response);
cacheHits++;
PolicyEngineTelemetry.RecordEvaluation(request.TenantId, packId, "cached");
}
else
{
@@ -294,6 +341,15 @@ internal sealed class PolicyRuntimeEvaluationService
expiresAt);
entriesToCache[key] = cacheEntry;
cacheMisses++;
// Record metrics for each evaluation
PolicyEngineTelemetry.RecordEvaluationLatency(duration / 1000.0, request.TenantId, packId);
PolicyEngineTelemetry.RecordEvaluation(request.TenantId, packId, "full");
if (!string.IsNullOrEmpty(result.RuleName))
{
PolicyEngineTelemetry.RecordRuleFired(packId, result.RuleName);
}
results.Add(new RuntimeEvaluationResponse(
request.PackId,
@@ -319,6 +375,17 @@ internal sealed class PolicyRuntimeEvaluationService
}
}
// Record batch-level metrics
var batchDuration = GetElapsedMilliseconds(batchStartTimestamp);
activity?.SetTag("batch.cache_hits", cacheHits);
activity?.SetTag("batch.cache_misses", cacheMisses);
activity?.SetTag("batch.duration_ms", batchDuration);
activity?.SetStatus(ActivityStatusCode.Ok);
_logger.LogDebug(
"Batch evaluation completed: {Total} subjects, {CacheHits} cache hits, {CacheMisses} evaluated in {Duration}ms",
requests.Count, cacheHits, cacheMisses, batchDuration);
return results;
}
@@ -398,24 +465,6 @@ internal sealed class PolicyRuntimeEvaluationService
return Convert.ToHexString(hash);
}
private static PolicyIrDocument? DeserializeCompiledPolicy(ImmutableArray<byte> payload)
{
if (payload.IsDefaultOrEmpty)
{
return null;
}
try
{
var json = Encoding.UTF8.GetString(payload.AsSpan());
return JsonSerializer.Deserialize<PolicyIrDocument>(json);
}
catch
{
return null;
}
}
private long GetElapsedMilliseconds(long startTimestamp)
{
var elapsed = _timeProvider.GetElapsedTime(startTimestamp);

View File

@@ -61,7 +61,8 @@ public sealed record RiskSimulationResult(
[property: JsonPropertyName("distribution")] RiskDistribution? Distribution,
[property: JsonPropertyName("top_movers")] IReadOnlyList<TopMover>? TopMovers,
[property: JsonPropertyName("aggregate_metrics")] AggregateRiskMetrics AggregateMetrics,
[property: JsonPropertyName("execution_time_ms")] double ExecutionTimeMs);
[property: JsonPropertyName("execution_time_ms")] double ExecutionTimeMs,
[property: JsonPropertyName("analytics")] SimulationAnalytics? Analytics = null);
/// <summary>
/// Computed risk score for a finding.

View File

@@ -0,0 +1,236 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.Simulation;
/// <summary>
/// Extended simulation analytics including rule firing counts, heatmaps, traces, and delta summaries.
/// </summary>
public sealed record SimulationAnalytics(
[property: JsonPropertyName("rule_firing_counts")] RuleFiringCounts RuleFiringCounts,
[property: JsonPropertyName("heatmap")] SimulationHeatmap Heatmap,
[property: JsonPropertyName("sampled_traces")] SampledExplainTraces SampledTraces,
[property: JsonPropertyName("delta_summary")] SimulationDeltaSummary? DeltaSummary);
/// <summary>
/// Rule firing counts aggregated across simulation runs.
/// </summary>
public sealed record RuleFiringCounts(
[property: JsonPropertyName("total_evaluations")] int TotalEvaluations,
[property: JsonPropertyName("total_rules_fired")] int TotalRulesFired,
[property: JsonPropertyName("rules_by_name")] ImmutableDictionary<string, RuleFireCount> RulesByName,
[property: JsonPropertyName("rules_by_priority")] ImmutableDictionary<int, int> RulesByPriority,
[property: JsonPropertyName("rules_by_outcome")] ImmutableDictionary<string, int> RulesByOutcome,
[property: JsonPropertyName("rules_by_category")] ImmutableDictionary<string, int> RulesByCategory,
[property: JsonPropertyName("top_rules")] ImmutableArray<RuleFireCount> TopRules,
[property: JsonPropertyName("vex_override_counts")] VexOverrideCounts VexOverrides);
/// <summary>
/// Fire count for a single rule.
/// </summary>
public sealed record RuleFireCount(
[property: JsonPropertyName("rule_name")] string RuleName,
[property: JsonPropertyName("priority")] int Priority,
[property: JsonPropertyName("category")] string? Category,
[property: JsonPropertyName("fire_count")] int FireCount,
[property: JsonPropertyName("fire_percentage")] double FirePercentage,
[property: JsonPropertyName("outcomes")] ImmutableDictionary<string, int> OutcomeBreakdown,
[property: JsonPropertyName("avg_evaluation_us")] double AverageEvaluationMicroseconds);
/// <summary>
/// VEX override aggregation.
/// </summary>
public sealed record VexOverrideCounts(
[property: JsonPropertyName("total_overrides")] int TotalOverrides,
[property: JsonPropertyName("by_vendor")] ImmutableDictionary<string, int> ByVendor,
[property: JsonPropertyName("by_status")] ImmutableDictionary<string, int> ByStatus,
[property: JsonPropertyName("by_justification")] ImmutableDictionary<string, int> ByJustification);
/// <summary>
/// Heatmap aggregates for visualization.
/// </summary>
public sealed record SimulationHeatmap(
[property: JsonPropertyName("rule_severity_matrix")] ImmutableArray<HeatmapCell> RuleSeverityMatrix,
[property: JsonPropertyName("rule_outcome_matrix")] ImmutableArray<HeatmapCell> RuleOutcomeMatrix,
[property: JsonPropertyName("finding_rule_coverage")] FindingRuleCoverage FindingRuleCoverage,
[property: JsonPropertyName("temporal_distribution")] ImmutableArray<TemporalBucket> TemporalDistribution);
/// <summary>
/// A cell in the heatmap matrix.
/// </summary>
public sealed record HeatmapCell(
[property: JsonPropertyName("x")] string X,
[property: JsonPropertyName("y")] string Y,
[property: JsonPropertyName("value")] int Value,
[property: JsonPropertyName("normalized")] double Normalized);
/// <summary>
/// Coverage of findings by rules.
/// </summary>
public sealed record FindingRuleCoverage(
[property: JsonPropertyName("total_findings")] int TotalFindings,
[property: JsonPropertyName("findings_matched")] int FindingsMatched,
[property: JsonPropertyName("findings_unmatched")] int FindingsUnmatched,
[property: JsonPropertyName("coverage_percentage")] double CoveragePercentage,
[property: JsonPropertyName("rules_never_fired")] ImmutableArray<string> RulesNeverFired,
[property: JsonPropertyName("findings_by_match_count")] ImmutableDictionary<int, int> FindingsByMatchCount);
/// <summary>
/// Temporal distribution bucket.
/// </summary>
public sealed record TemporalBucket(
[property: JsonPropertyName("bucket_start_ms")] long BucketStartMs,
[property: JsonPropertyName("bucket_end_ms")] long BucketEndMs,
[property: JsonPropertyName("evaluation_count")] int EvaluationCount,
[property: JsonPropertyName("rules_fired")] int RulesFired);
/// <summary>
/// Sampled explain traces with deterministic ordering.
/// </summary>
public sealed record SampledExplainTraces(
[property: JsonPropertyName("sample_rate")] double SampleRate,
[property: JsonPropertyName("total_traces")] int TotalTraces,
[property: JsonPropertyName("sampled_count")] int SampledCount,
[property: JsonPropertyName("ordering")] TraceOrdering Ordering,
[property: JsonPropertyName("traces")] ImmutableArray<SampledTrace> Traces,
[property: JsonPropertyName("determinism_hash")] string DeterminismHash);
/// <summary>
/// Deterministic ordering specification.
/// </summary>
public sealed record TraceOrdering(
[property: JsonPropertyName("primary_key")] string PrimaryKey,
[property: JsonPropertyName("secondary_key")] string? SecondaryKey,
[property: JsonPropertyName("direction")] string Direction);
/// <summary>
/// A sampled trace with key metadata.
/// </summary>
public sealed record SampledTrace(
[property: JsonPropertyName("trace_id")] string TraceId,
[property: JsonPropertyName("finding_id")] string FindingId,
[property: JsonPropertyName("component_purl")] string? ComponentPurl,
[property: JsonPropertyName("advisory_id")] string? AdvisoryId,
[property: JsonPropertyName("final_outcome")] string FinalOutcome,
[property: JsonPropertyName("assigned_severity")] string? AssignedSeverity,
[property: JsonPropertyName("rules_evaluated")] int RulesEvaluated,
[property: JsonPropertyName("rules_fired")] int RulesFired,
[property: JsonPropertyName("vex_applied")] bool VexApplied,
[property: JsonPropertyName("evaluation_ms")] double EvaluationMs,
[property: JsonPropertyName("rule_sequence")] ImmutableArray<string> RuleSequence,
[property: JsonPropertyName("sample_reason")] string SampleReason);
/// <summary>
/// Delta summary comparing simulation results.
/// </summary>
public sealed record SimulationDeltaSummary(
[property: JsonPropertyName("comparison_type")] SimulationComparisonType ComparisonType,
[property: JsonPropertyName("base_policy_ref")] string BasePolicyRef,
[property: JsonPropertyName("candidate_policy_ref")] string? CandidatePolicyRef,
[property: JsonPropertyName("total_findings")] int TotalFindings,
[property: JsonPropertyName("outcome_changes")] OutcomeChangeSummary OutcomeChanges,
[property: JsonPropertyName("severity_changes")] SeverityChangeSummary SeverityChanges,
[property: JsonPropertyName("rule_changes")] RuleChangeSummary RuleChanges,
[property: JsonPropertyName("high_impact_findings")] ImmutableArray<HighImpactFinding> HighImpactFindings,
[property: JsonPropertyName("determinism_hash")] string DeterminismHash);
/// <summary>
/// Type of simulation comparison.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<SimulationComparisonType>))]
public enum SimulationComparisonType
{
/// <summary>Single policy snapshot.</summary>
[JsonPropertyName("snapshot")]
Snapshot,
/// <summary>Comparing two policy versions.</summary>
[JsonPropertyName("version_compare")]
VersionCompare,
/// <summary>What-if analysis with hypothetical changes.</summary>
[JsonPropertyName("whatif")]
WhatIf,
/// <summary>Batch comparison across multiple inputs.</summary>
[JsonPropertyName("batch")]
Batch
}
/// <summary>
/// Summary of outcome changes.
/// </summary>
public sealed record OutcomeChangeSummary(
[property: JsonPropertyName("unchanged")] int Unchanged,
[property: JsonPropertyName("improved")] int Improved,
[property: JsonPropertyName("regressed")] int Regressed,
[property: JsonPropertyName("transitions")] ImmutableArray<OutcomeTransition> Transitions);
/// <summary>
/// A specific outcome transition.
/// </summary>
public sealed record OutcomeTransition(
[property: JsonPropertyName("from_outcome")] string FromOutcome,
[property: JsonPropertyName("to_outcome")] string ToOutcome,
[property: JsonPropertyName("count")] int Count,
[property: JsonPropertyName("percentage")] double Percentage,
[property: JsonPropertyName("is_improvement")] bool IsImprovement);
/// <summary>
/// Summary of severity changes.
/// </summary>
public sealed record SeverityChangeSummary(
[property: JsonPropertyName("unchanged")] int Unchanged,
[property: JsonPropertyName("escalated")] int Escalated,
[property: JsonPropertyName("deescalated")] int Deescalated,
[property: JsonPropertyName("transitions")] ImmutableArray<SeverityTransition> Transitions);
/// <summary>
/// A specific severity transition.
/// </summary>
public sealed record SeverityTransition(
[property: JsonPropertyName("from_severity")] string FromSeverity,
[property: JsonPropertyName("to_severity")] string ToSeverity,
[property: JsonPropertyName("count")] int Count,
[property: JsonPropertyName("percentage")] double Percentage);
/// <summary>
/// Summary of rule behavior changes.
/// </summary>
public sealed record RuleChangeSummary(
[property: JsonPropertyName("rules_added")] ImmutableArray<string> RulesAdded,
[property: JsonPropertyName("rules_removed")] ImmutableArray<string> RulesRemoved,
[property: JsonPropertyName("rules_modified")] ImmutableArray<RuleModification> RulesModified,
[property: JsonPropertyName("fire_rate_changes")] ImmutableArray<RuleFireRateChange> FireRateChanges);
/// <summary>
/// A rule modification between versions.
/// </summary>
public sealed record RuleModification(
[property: JsonPropertyName("rule_name")] string RuleName,
[property: JsonPropertyName("modification_type")] string ModificationType,
[property: JsonPropertyName("description")] string Description);
/// <summary>
/// Change in rule fire rate.
/// </summary>
public sealed record RuleFireRateChange(
[property: JsonPropertyName("rule_name")] string RuleName,
[property: JsonPropertyName("base_fire_rate")] double BaseFireRate,
[property: JsonPropertyName("candidate_fire_rate")] double CandidateFireRate,
[property: JsonPropertyName("change_percentage")] double ChangePercentage,
[property: JsonPropertyName("is_significant")] bool IsSignificant);
/// <summary>
/// A finding with high impact from policy changes.
/// </summary>
public sealed record HighImpactFinding(
[property: JsonPropertyName("finding_id")] string FindingId,
[property: JsonPropertyName("component_purl")] string? ComponentPurl,
[property: JsonPropertyName("advisory_id")] string? AdvisoryId,
[property: JsonPropertyName("base_outcome")] string BaseOutcome,
[property: JsonPropertyName("candidate_outcome")] string? CandidateOutcome,
[property: JsonPropertyName("base_severity")] string? BaseSeverity,
[property: JsonPropertyName("candidate_severity")] string? CandidateSeverity,
[property: JsonPropertyName("impact_score")] double ImpactScore,
[property: JsonPropertyName("impact_reason")] string ImpactReason);

View File

@@ -0,0 +1,811 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Policy.Engine.Telemetry;
namespace StellaOps.Policy.Engine.Simulation;
/// <summary>
/// Service for computing simulation analytics including rule firing counts, heatmaps,
/// sampled traces, and delta summaries.
/// </summary>
public sealed class SimulationAnalyticsService
{
private static readonly ImmutableArray<string> OutcomeSeverityOrder = ImmutableArray.Create(
"allow", "info", "warn", "review", "block", "deny", "critical");
private static readonly ImmutableArray<string> SeverityOrder = ImmutableArray.Create(
"informational", "low", "medium", "high", "critical");
/// <summary>
/// Computes full simulation analytics from rule hit traces.
/// </summary>
public SimulationAnalytics ComputeAnalytics(
string policyRef,
IReadOnlyList<RuleHitTrace> traces,
IReadOnlyList<SimulationFinding> findings,
SimulationAnalyticsOptions? options = null)
{
options ??= SimulationAnalyticsOptions.Default;
var firingCounts = ComputeRuleFiringCounts(traces, findings.Count);
var heatmap = ComputeHeatmap(traces, findings, options);
var sampledTraces = ComputeSampledTraces(traces, findings, options);
return new SimulationAnalytics(
firingCounts,
heatmap,
sampledTraces,
DeltaSummary: null);
}
/// <summary>
/// Computes delta summary comparing base and candidate simulation results.
/// </summary>
public SimulationDeltaSummary ComputeDeltaSummary(
string basePolicyRef,
string candidatePolicyRef,
IReadOnlyList<SimulationFindingResult> baseResults,
IReadOnlyList<SimulationFindingResult> candidateResults,
SimulationComparisonType comparisonType = SimulationComparisonType.VersionCompare)
{
var baseByFinding = baseResults.ToDictionary(r => r.FindingId);
var candidateByFinding = candidateResults.ToDictionary(r => r.FindingId);
var outcomeChanges = ComputeOutcomeChanges(baseByFinding, candidateByFinding);
var severityChanges = ComputeSeverityChanges(baseByFinding, candidateByFinding);
var ruleChanges = ComputeRuleChanges(baseResults, candidateResults);
var highImpact = ComputeHighImpactFindings(baseByFinding, candidateByFinding);
var hashInput = $"{basePolicyRef}:{candidatePolicyRef}:{baseResults.Count}:{candidateResults.Count}";
var determinismHash = ComputeHash(hashInput);
return new SimulationDeltaSummary(
comparisonType,
basePolicyRef,
candidatePolicyRef,
TotalFindings: baseResults.Count,
outcomeChanges,
severityChanges,
ruleChanges,
highImpact,
determinismHash);
}
/// <summary>
/// Computes rule firing counts from traces.
/// </summary>
public RuleFiringCounts ComputeRuleFiringCounts(
IReadOnlyList<RuleHitTrace> traces,
int totalEvaluations)
{
var ruleStats = new Dictionary<string, RuleStats>();
var byPriority = new Dictionary<int, int>();
var byOutcome = new Dictionary<string, int>();
var byCategory = new Dictionary<string, int>();
var vexByVendor = new Dictionary<string, int>();
var vexByStatus = new Dictionary<string, int>();
var vexByJustification = new Dictionary<string, int>();
var totalFired = 0;
var totalVexOverrides = 0;
foreach (var trace in traces)
{
if (!trace.ExpressionResult)
{
continue;
}
totalFired++;
// Rule stats
if (!ruleStats.TryGetValue(trace.RuleName, out var stats))
{
stats = new RuleStats(trace.RuleName, trace.RulePriority, trace.RuleCategory);
ruleStats[trace.RuleName] = stats;
}
stats.FireCount++;
stats.TotalEvaluationUs += trace.EvaluationMicroseconds;
stats.IncrementOutcome(trace.Outcome);
// Priority aggregation
byPriority.TryGetValue(trace.RulePriority, out var priorityCount);
byPriority[trace.RulePriority] = priorityCount + 1;
// Outcome aggregation
byOutcome.TryGetValue(trace.Outcome, out var outcomeCount);
byOutcome[trace.Outcome] = outcomeCount + 1;
// Category aggregation
if (!string.IsNullOrWhiteSpace(trace.RuleCategory))
{
byCategory.TryGetValue(trace.RuleCategory, out var categoryCount);
byCategory[trace.RuleCategory] = categoryCount + 1;
}
// VEX overrides
if (trace.IsVexOverride)
{
totalVexOverrides++;
if (!string.IsNullOrWhiteSpace(trace.VexVendor))
{
vexByVendor.TryGetValue(trace.VexVendor, out var vendorCount);
vexByVendor[trace.VexVendor] = vendorCount + 1;
}
if (!string.IsNullOrWhiteSpace(trace.VexStatus))
{
vexByStatus.TryGetValue(trace.VexStatus, out var statusCount);
vexByStatus[trace.VexStatus] = statusCount + 1;
}
if (!string.IsNullOrWhiteSpace(trace.VexJustification))
{
vexByJustification.TryGetValue(trace.VexJustification, out var justCount);
vexByJustification[trace.VexJustification] = justCount + 1;
}
}
}
// Build rule fire counts
var ruleFireCounts = ruleStats.Values
.Select(s => new RuleFireCount(
s.RuleName,
s.Priority,
s.Category,
s.FireCount,
totalEvaluations > 0 ? (double)s.FireCount / totalEvaluations * 100 : 0,
s.OutcomeCounts.ToImmutableDictionary(),
s.FireCount > 0 ? (double)s.TotalEvaluationUs / s.FireCount : 0))
.ToImmutableDictionary(r => r.RuleName);
var topRules = ruleFireCounts.Values
.OrderByDescending(r => r.FireCount)
.Take(10)
.ToImmutableArray();
var vexOverrides = new VexOverrideCounts(
totalVexOverrides,
vexByVendor.ToImmutableDictionary(),
vexByStatus.ToImmutableDictionary(),
vexByJustification.ToImmutableDictionary());
return new RuleFiringCounts(
totalEvaluations,
totalFired,
ruleFireCounts,
byPriority.ToImmutableDictionary(),
byOutcome.ToImmutableDictionary(),
byCategory.ToImmutableDictionary(),
topRules,
vexOverrides);
}
/// <summary>
/// Computes heatmap aggregates for visualization.
/// </summary>
public SimulationHeatmap ComputeHeatmap(
IReadOnlyList<RuleHitTrace> traces,
IReadOnlyList<SimulationFinding> findings,
SimulationAnalyticsOptions options)
{
var ruleSeverityMatrix = ComputeRuleSeverityMatrix(traces);
var ruleOutcomeMatrix = ComputeRuleOutcomeMatrix(traces);
var findingCoverage = ComputeFindingRuleCoverage(traces, findings);
var temporalDist = ComputeTemporalDistribution(traces, options.TemporalBucketMs);
return new SimulationHeatmap(
ruleSeverityMatrix,
ruleOutcomeMatrix,
findingCoverage,
temporalDist);
}
/// <summary>
/// Computes sampled explain traces with deterministic ordering.
/// </summary>
public SampledExplainTraces ComputeSampledTraces(
IReadOnlyList<RuleHitTrace> traces,
IReadOnlyList<SimulationFinding> findings,
SimulationAnalyticsOptions options)
{
// Group traces by finding
var tracesByFinding = traces
.GroupBy(t => t.ComponentPurl ?? t.AdvisoryId ?? "unknown")
.ToDictionary(g => g.Key, g => g.ToList());
var findingsById = findings.ToDictionary(f => f.FindingId);
// Deterministic ordering by finding_id, then rule_priority
var ordering = new TraceOrdering("finding_id", "rule_priority", "ascending");
// Sample traces deterministically
var sampledList = new List<SampledTrace>();
var totalTraceCount = 0;
foreach (var finding in findings.OrderBy(f => f.FindingId, StringComparer.Ordinal))
{
var key = finding.ComponentPurl ?? finding.AdvisoryId ?? finding.FindingId;
if (!tracesByFinding.TryGetValue(key, out var findingTraces))
{
continue;
}
totalTraceCount += findingTraces.Count;
// Deterministic sampling based on finding_id hash
var sampleHash = ComputeHash(finding.FindingId);
var sampleValue = Math.Abs(sampleHash.GetHashCode()) % 100;
var shouldSample = sampleValue < (int)(options.TraceSampleRate * 100);
if (!shouldSample && sampledList.Count >= options.MaxSampledTraces)
{
continue;
}
// Always sample high-impact findings
var hasFiredRule = findingTraces.Any(t => t.ExpressionResult);
var isHighSeverity = findingTraces.Any(t =>
t.AssignedSeverity?.Equals("critical", StringComparison.OrdinalIgnoreCase) == true ||
t.AssignedSeverity?.Equals("high", StringComparison.OrdinalIgnoreCase) == true);
var hasVexOverride = findingTraces.Any(t => t.IsVexOverride);
var sampleReason = DetermineSampleReason(shouldSample, isHighSeverity, hasVexOverride);
if (!shouldSample && !isHighSeverity && !hasVexOverride)
{
continue;
}
var orderedTraces = findingTraces.OrderBy(t => t.RulePriority).ToList();
var finalTrace = orderedTraces.LastOrDefault(t => t.ExpressionResult) ?? orderedTraces.LastOrDefault();
if (finalTrace == null)
{
continue;
}
var ruleSequence = orderedTraces
.Where(t => t.ExpressionResult)
.Select(t => t.RuleName)
.ToImmutableArray();
sampledList.Add(new SampledTrace(
TraceId: $"{finding.FindingId}:{finalTrace.SpanId}",
FindingId: finding.FindingId,
ComponentPurl: finding.ComponentPurl,
AdvisoryId: finding.AdvisoryId,
FinalOutcome: finalTrace.Outcome,
AssignedSeverity: finalTrace.AssignedSeverity,
RulesEvaluated: findingTraces.Count,
RulesFired: findingTraces.Count(t => t.ExpressionResult),
VexApplied: hasVexOverride,
EvaluationMs: findingTraces.Sum(t => t.EvaluationMicroseconds) / 1000.0,
RuleSequence: ruleSequence,
SampleReason: sampleReason));
if (sampledList.Count >= options.MaxSampledTraces)
{
break;
}
}
// Compute determinism hash from ordered sample
var hashBuilder = new StringBuilder();
foreach (var sample in sampledList.OrderBy(s => s.FindingId, StringComparer.Ordinal))
{
hashBuilder.Append(sample.FindingId);
hashBuilder.Append(':');
hashBuilder.Append(sample.FinalOutcome);
hashBuilder.Append(';');
}
var determinismHash = ComputeHash(hashBuilder.ToString());
return new SampledExplainTraces(
options.TraceSampleRate,
totalTraceCount,
sampledList.Count,
ordering,
sampledList.ToImmutableArray(),
determinismHash);
}
private ImmutableArray<HeatmapCell> ComputeRuleSeverityMatrix(IReadOnlyList<RuleHitTrace> traces)
{
var matrix = new Dictionary<(string rule, string severity), int>();
foreach (var trace in traces.Where(t => t.ExpressionResult && !string.IsNullOrWhiteSpace(t.AssignedSeverity)))
{
var key = (trace.RuleName, trace.AssignedSeverity!);
matrix.TryGetValue(key, out var count);
matrix[key] = count + 1;
}
var maxValue = matrix.Values.DefaultIfEmpty(1).Max();
return matrix
.Select(kvp => new HeatmapCell(
kvp.Key.rule,
kvp.Key.severity,
kvp.Value,
maxValue > 0 ? (double)kvp.Value / maxValue : 0))
.OrderBy(c => c.X, StringComparer.Ordinal)
.ThenBy(c => SeverityOrder.IndexOf(c.Y.ToLowerInvariant()))
.ToImmutableArray();
}
private ImmutableArray<HeatmapCell> ComputeRuleOutcomeMatrix(IReadOnlyList<RuleHitTrace> traces)
{
var matrix = new Dictionary<(string rule, string outcome), int>();
foreach (var trace in traces.Where(t => t.ExpressionResult))
{
var key = (trace.RuleName, trace.Outcome);
matrix.TryGetValue(key, out var count);
matrix[key] = count + 1;
}
var maxValue = matrix.Values.DefaultIfEmpty(1).Max();
return matrix
.Select(kvp => new HeatmapCell(
kvp.Key.rule,
kvp.Key.outcome,
kvp.Value,
maxValue > 0 ? (double)kvp.Value / maxValue : 0))
.OrderBy(c => c.X, StringComparer.Ordinal)
.ThenBy(c => OutcomeSeverityOrder.IndexOf(c.Y.ToLowerInvariant()))
.ToImmutableArray();
}
private FindingRuleCoverage ComputeFindingRuleCoverage(
IReadOnlyList<RuleHitTrace> traces,
IReadOnlyList<SimulationFinding> findings)
{
var rulesThatFired = traces
.Where(t => t.ExpressionResult)
.Select(t => t.RuleName)
.ToHashSet();
var allRules = traces
.Select(t => t.RuleName)
.Distinct()
.ToHashSet();
var rulesNeverFired = allRules.Except(rulesThatFired).ToImmutableArray();
// Group by finding to count matches per finding
var findingMatchCounts = traces
.Where(t => t.ExpressionResult)
.GroupBy(t => t.ComponentPurl ?? t.AdvisoryId ?? "unknown")
.ToDictionary(g => g.Key, g => g.Select(t => t.RuleName).Distinct().Count());
var matchCountDistribution = findingMatchCounts.Values
.GroupBy(c => c)
.ToDictionary(g => g.Key, g => g.Count())
.ToImmutableDictionary();
var findingsMatched = findingMatchCounts.Count;
var findingsUnmatched = findings.Count - findingsMatched;
return new FindingRuleCoverage(
findings.Count,
findingsMatched,
findingsUnmatched,
findings.Count > 0 ? (double)findingsMatched / findings.Count * 100 : 0,
rulesNeverFired,
matchCountDistribution);
}
private ImmutableArray<TemporalBucket> ComputeTemporalDistribution(
IReadOnlyList<RuleHitTrace> traces,
long bucketMs)
{
if (traces.Count == 0)
{
return ImmutableArray<TemporalBucket>.Empty;
}
var minTime = traces.Min(t => t.EvaluationTimestamp);
var maxTime = traces.Max(t => t.EvaluationTimestamp);
var totalMs = (long)(maxTime - minTime).TotalMilliseconds;
if (totalMs <= 0)
{
return ImmutableArray.Create(new TemporalBucket(0, bucketMs, traces.Count, traces.Count(t => t.ExpressionResult)));
}
var buckets = new Dictionary<long, (int evalCount, int fireCount)>();
foreach (var trace in traces)
{
var offsetMs = (long)(trace.EvaluationTimestamp - minTime).TotalMilliseconds;
var bucketStart = (offsetMs / bucketMs) * bucketMs;
buckets.TryGetValue(bucketStart, out var counts);
buckets[bucketStart] = (counts.evalCount + 1, counts.fireCount + (trace.ExpressionResult ? 1 : 0));
}
return buckets
.OrderBy(kvp => kvp.Key)
.Select(kvp => new TemporalBucket(kvp.Key, kvp.Key + bucketMs, kvp.Value.evalCount, kvp.Value.fireCount))
.ToImmutableArray();
}
private OutcomeChangeSummary ComputeOutcomeChanges(
Dictionary<string, SimulationFindingResult> baseResults,
Dictionary<string, SimulationFindingResult> candidateResults)
{
var unchanged = 0;
var improved = 0;
var regressed = 0;
var transitionCounts = new Dictionary<(string from, string to), int>();
foreach (var (findingId, baseResult) in baseResults)
{
if (!candidateResults.TryGetValue(findingId, out var candidateResult))
{
continue;
}
if (baseResult.Outcome == candidateResult.Outcome)
{
unchanged++;
}
else
{
var key = (baseResult.Outcome, candidateResult.Outcome);
transitionCounts.TryGetValue(key, out var count);
transitionCounts[key] = count + 1;
var isImprovement = IsOutcomeImprovement(baseResult.Outcome, candidateResult.Outcome);
if (isImprovement)
{
improved++;
}
else
{
regressed++;
}
}
}
var total = baseResults.Count;
var transitions = transitionCounts
.Select(kvp => new OutcomeTransition(
kvp.Key.from,
kvp.Key.to,
kvp.Value,
total > 0 ? (double)kvp.Value / total * 100 : 0,
IsOutcomeImprovement(kvp.Key.from, kvp.Key.to)))
.OrderByDescending(t => t.Count)
.ToImmutableArray();
return new OutcomeChangeSummary(unchanged, improved, regressed, transitions);
}
private SeverityChangeSummary ComputeSeverityChanges(
Dictionary<string, SimulationFindingResult> baseResults,
Dictionary<string, SimulationFindingResult> candidateResults)
{
var unchanged = 0;
var escalated = 0;
var deescalated = 0;
var transitionCounts = new Dictionary<(string from, string to), int>();
foreach (var (findingId, baseResult) in baseResults)
{
if (!candidateResults.TryGetValue(findingId, out var candidateResult))
{
continue;
}
var baseSeverity = baseResult.Severity ?? "unknown";
var candidateSeverity = candidateResult.Severity ?? "unknown";
if (baseSeverity == candidateSeverity)
{
unchanged++;
}
else
{
var key = (baseSeverity, candidateSeverity);
transitionCounts.TryGetValue(key, out var count);
transitionCounts[key] = count + 1;
var baseIdx = SeverityOrder.IndexOf(baseSeverity.ToLowerInvariant());
var candidateIdx = SeverityOrder.IndexOf(candidateSeverity.ToLowerInvariant());
if (candidateIdx > baseIdx)
{
escalated++;
}
else
{
deescalated++;
}
}
}
var total = baseResults.Count;
var transitions = transitionCounts
.Select(kvp => new SeverityTransition(
kvp.Key.from,
kvp.Key.to,
kvp.Value,
total > 0 ? (double)kvp.Value / total * 100 : 0))
.OrderByDescending(t => t.Count)
.ToImmutableArray();
return new SeverityChangeSummary(unchanged, escalated, deescalated, transitions);
}
private RuleChangeSummary ComputeRuleChanges(
IReadOnlyList<SimulationFindingResult> baseResults,
IReadOnlyList<SimulationFindingResult> candidateResults)
{
var baseRules = baseResults
.SelectMany(r => r.FiredRules ?? Array.Empty<string>())
.Distinct()
.ToHashSet();
var candidateRules = candidateResults
.SelectMany(r => r.FiredRules ?? Array.Empty<string>())
.Distinct()
.ToHashSet();
var rulesAdded = candidateRules.Except(baseRules).ToImmutableArray();
var rulesRemoved = baseRules.Except(candidateRules).ToImmutableArray();
// Compute fire rate changes for common rules
var baseFireRates = ComputeFireRates(baseResults);
var candidateFireRates = ComputeFireRates(candidateResults);
var fireRateChanges = baseRules.Intersect(candidateRules)
.Select(rule =>
{
var baseRate = baseFireRates.GetValueOrDefault(rule, 0);
var candidateRate = candidateFireRates.GetValueOrDefault(rule, 0);
var change = candidateRate - baseRate;
return new RuleFireRateChange(
rule,
baseRate,
candidateRate,
change,
Math.Abs(change) > 5.0); // >5% change is significant
})
.Where(c => Math.Abs(c.ChangePercentage) > 1.0) // Only show changes > 1%
.OrderByDescending(c => Math.Abs(c.ChangePercentage))
.Take(20)
.ToImmutableArray();
return new RuleChangeSummary(
rulesAdded,
rulesRemoved,
ImmutableArray<RuleModification>.Empty, // Would require policy diff analysis
fireRateChanges);
}
private Dictionary<string, double> ComputeFireRates(IReadOnlyList<SimulationFindingResult> results)
{
var ruleCounts = new Dictionary<string, int>();
foreach (var result in results)
{
foreach (var rule in result.FiredRules ?? Array.Empty<string>())
{
ruleCounts.TryGetValue(rule, out var count);
ruleCounts[rule] = count + 1;
}
}
var total = results.Count;
return ruleCounts.ToDictionary(
kvp => kvp.Key,
kvp => total > 0 ? (double)kvp.Value / total * 100 : 0);
}
private ImmutableArray<HighImpactFinding> ComputeHighImpactFindings(
Dictionary<string, SimulationFindingResult> baseResults,
Dictionary<string, SimulationFindingResult> candidateResults)
{
var highImpact = new List<HighImpactFinding>();
foreach (var (findingId, baseResult) in baseResults)
{
if (!candidateResults.TryGetValue(findingId, out var candidateResult))
{
continue;
}
var impactScore = ComputeImpactScore(baseResult, candidateResult);
if (impactScore < 0.3) // Threshold for high impact
{
continue;
}
var impactReason = DetermineImpactReason(baseResult, candidateResult);
highImpact.Add(new HighImpactFinding(
findingId,
baseResult.ComponentPurl,
baseResult.AdvisoryId,
baseResult.Outcome,
candidateResult.Outcome,
baseResult.Severity,
candidateResult.Severity,
impactScore,
impactReason));
}
return highImpact
.OrderByDescending(f => f.ImpactScore)
.Take(50)
.ToImmutableArray();
}
private double ComputeImpactScore(SimulationFindingResult baseResult, SimulationFindingResult candidateResult)
{
var score = 0.0;
// Outcome change weight
if (baseResult.Outcome != candidateResult.Outcome)
{
var baseIdx = OutcomeSeverityOrder.IndexOf(baseResult.Outcome.ToLowerInvariant());
var candidateIdx = OutcomeSeverityOrder.IndexOf(candidateResult.Outcome.ToLowerInvariant());
score += Math.Abs(candidateIdx - baseIdx) * 0.2;
}
// Severity change weight
var baseSeverity = baseResult.Severity ?? "unknown";
var candidateSeverity = candidateResult.Severity ?? "unknown";
if (baseSeverity != candidateSeverity)
{
var baseIdx = SeverityOrder.IndexOf(baseSeverity.ToLowerInvariant());
var candidateIdx = SeverityOrder.IndexOf(candidateSeverity.ToLowerInvariant());
score += Math.Abs(candidateIdx - baseIdx) * 0.15;
}
return Math.Min(1.0, score);
}
private string DetermineImpactReason(SimulationFindingResult baseResult, SimulationFindingResult candidateResult)
{
var reasons = new List<string>();
if (baseResult.Outcome != candidateResult.Outcome)
{
reasons.Add($"Outcome changed from '{baseResult.Outcome}' to '{candidateResult.Outcome}'");
}
if (baseResult.Severity != candidateResult.Severity)
{
reasons.Add($"Severity changed from '{baseResult.Severity}' to '{candidateResult.Severity}'");
}
return string.Join("; ", reasons);
}
private bool IsOutcomeImprovement(string from, string to)
{
var fromIdx = OutcomeSeverityOrder.IndexOf(from.ToLowerInvariant());
var toIdx = OutcomeSeverityOrder.IndexOf(to.ToLowerInvariant());
// Lower index = less severe = improvement
return toIdx < fromIdx;
}
private static string DetermineSampleReason(bool randomSample, bool highSeverity, bool vexOverride)
{
if (vexOverride)
{
return "vex_override";
}
if (highSeverity)
{
return "high_severity";
}
return randomSample ? "random_sample" : "coverage";
}
private static string ComputeHash(string input)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(bytes)[..16].ToLowerInvariant();
}
private sealed class RuleStats
{
public string RuleName { get; }
public int Priority { get; }
public string? Category { get; }
public int FireCount { get; set; }
public long TotalEvaluationUs { get; set; }
public Dictionary<string, int> OutcomeCounts { get; } = new();
public RuleStats(string ruleName, int priority, string? category)
{
RuleName = ruleName;
Priority = priority;
Category = category;
}
public void IncrementOutcome(string outcome)
{
OutcomeCounts.TryGetValue(outcome, out var count);
OutcomeCounts[outcome] = count + 1;
}
}
}
/// <summary>
/// Options for simulation analytics computation.
/// </summary>
public sealed record SimulationAnalyticsOptions
{
/// <summary>
/// Sample rate for traces (0.0 to 1.0).
/// </summary>
public double TraceSampleRate { get; init; } = 0.1;
/// <summary>
/// Maximum number of sampled traces to include.
/// </summary>
public int MaxSampledTraces { get; init; } = 100;
/// <summary>
/// Temporal bucket size in milliseconds.
/// </summary>
public long TemporalBucketMs { get; init; } = 100;
/// <summary>
/// Maximum number of top rules to include.
/// </summary>
public int MaxTopRules { get; init; } = 10;
/// <summary>
/// Significance threshold for fire rate changes (percentage).
/// </summary>
public double FireRateSignificanceThreshold { get; init; } = 5.0;
/// <summary>
/// Default options.
/// </summary>
public static SimulationAnalyticsOptions Default { get; } = new();
/// <summary>
/// Options for quick simulations (lower sampling, faster).
/// </summary>
public static SimulationAnalyticsOptions Quick { get; } = new()
{
TraceSampleRate = 0.01,
MaxSampledTraces = 20,
TemporalBucketMs = 500
};
/// <summary>
/// Options for batch simulations (balanced).
/// </summary>
public static SimulationAnalyticsOptions Batch { get; } = new()
{
TraceSampleRate = 0.05,
MaxSampledTraces = 50,
TemporalBucketMs = 200
};
}
/// <summary>
/// Result of a single finding simulation (for delta comparison).
/// </summary>
public sealed record SimulationFindingResult(
string FindingId,
string? ComponentPurl,
string? AdvisoryId,
string Outcome,
string? Severity,
IReadOnlyList<string>? FiredRules);

View File

@@ -10,6 +10,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<PackageReference Include="StackExchange.Redis" Version="2.8.37" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />

View File

@@ -0,0 +1,325 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Policy.Engine.Storage.Mongo.Documents;
/// <summary>
/// MongoDB document representing an effective finding after policy evaluation.
/// Collection: effective_finding_{policyId}
/// Tenant-scoped with unique constraint on (tenantId, componentPurl, advisoryId).
/// </summary>
[BsonIgnoreExtraElements]
public sealed class EffectiveFindingDocument
{
/// <summary>
/// Unique identifier: sha256:{hash of tenantId|policyId|componentPurl|advisoryId}
/// </summary>
[BsonId]
[BsonElement("_id")]
public string Id { get; set; } = string.Empty;
/// <summary>
/// Tenant identifier (normalized to lowercase).
/// </summary>
[BsonElement("tenantId")]
public string TenantId { get; set; } = string.Empty;
/// <summary>
/// Policy identifier.
/// </summary>
[BsonElement("policyId")]
public string PolicyId { get; set; } = string.Empty;
/// <summary>
/// Policy version at time of evaluation.
/// </summary>
[BsonElement("policyVersion")]
public int PolicyVersion { get; set; }
/// <summary>
/// Component PURL from the SBOM.
/// </summary>
[BsonElement("componentPurl")]
public string ComponentPurl { get; set; } = string.Empty;
/// <summary>
/// Component name.
/// </summary>
[BsonElement("componentName")]
public string ComponentName { get; set; } = string.Empty;
/// <summary>
/// Component version.
/// </summary>
[BsonElement("componentVersion")]
public string ComponentVersion { get; set; } = string.Empty;
/// <summary>
/// Package ecosystem (npm, maven, pypi, etc.).
/// </summary>
[BsonElement("ecosystem")]
[BsonIgnoreIfNull]
public string? Ecosystem { get; set; }
/// <summary>
/// Advisory identifier (CVE, GHSA, etc.).
/// </summary>
[BsonElement("advisoryId")]
public string AdvisoryId { get; set; } = string.Empty;
/// <summary>
/// Advisory source.
/// </summary>
[BsonElement("advisorySource")]
public string AdvisorySource { get; set; } = string.Empty;
/// <summary>
/// Vulnerability ID (may differ from advisory ID).
/// </summary>
[BsonElement("vulnerabilityId")]
[BsonIgnoreIfNull]
public string? VulnerabilityId { get; set; }
/// <summary>
/// Policy evaluation status (affected, blocked, suppressed, etc.).
/// </summary>
[BsonElement("status")]
public string Status { get; set; } = string.Empty;
/// <summary>
/// Normalized severity (Critical, High, Medium, Low, None).
/// </summary>
[BsonElement("severity")]
[BsonIgnoreIfNull]
public string? Severity { get; set; }
/// <summary>
/// CVSS score (if available).
/// </summary>
[BsonElement("cvssScore")]
[BsonIgnoreIfNull]
public double? CvssScore { get; set; }
/// <summary>
/// Rule name that matched.
/// </summary>
[BsonElement("ruleName")]
[BsonIgnoreIfNull]
public string? RuleName { get; set; }
/// <summary>
/// Rule priority.
/// </summary>
[BsonElement("rulePriority")]
[BsonIgnoreIfNull]
public int? RulePriority { get; set; }
/// <summary>
/// VEX status overlay (if VEX was applied).
/// </summary>
[BsonElement("vexStatus")]
[BsonIgnoreIfNull]
public string? VexStatus { get; set; }
/// <summary>
/// VEX justification (if VEX was applied).
/// </summary>
[BsonElement("vexJustification")]
[BsonIgnoreIfNull]
public string? VexJustification { get; set; }
/// <summary>
/// VEX provider/vendor.
/// </summary>
[BsonElement("vexVendor")]
[BsonIgnoreIfNull]
public string? VexVendor { get; set; }
/// <summary>
/// Whether a VEX override was applied.
/// </summary>
[BsonElement("isVexOverride")]
public bool IsVexOverride { get; set; }
/// <summary>
/// SBOM ID where component was found.
/// </summary>
[BsonElement("sbomId")]
[BsonIgnoreIfNull]
public string? SbomId { get; set; }
/// <summary>
/// Product key associated with the SBOM.
/// </summary>
[BsonElement("productKey")]
[BsonIgnoreIfNull]
public string? ProductKey { get; set; }
/// <summary>
/// Policy evaluation annotations.
/// </summary>
[BsonElement("annotations")]
public Dictionary<string, string> Annotations { get; set; } = new();
/// <summary>
/// Current history version (incremented on each update).
/// </summary>
[BsonElement("historyVersion")]
public long HistoryVersion { get; set; }
/// <summary>
/// Reference to the policy run that produced this finding.
/// </summary>
[BsonElement("policyRunId")]
[BsonIgnoreIfNull]
public string? PolicyRunId { get; set; }
/// <summary>
/// Trace ID for distributed tracing.
/// </summary>
[BsonElement("traceId")]
[BsonIgnoreIfNull]
public string? TraceId { get; set; }
/// <summary>
/// Span ID for distributed tracing.
/// </summary>
[BsonElement("spanId")]
[BsonIgnoreIfNull]
public string? SpanId { get; set; }
/// <summary>
/// When this finding was first created.
/// </summary>
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; }
/// <summary>
/// When this finding was last updated.
/// </summary>
[BsonElement("updatedAt")]
public DateTimeOffset UpdatedAt { get; set; }
/// <summary>
/// Content hash for deduplication and change detection.
/// </summary>
[BsonElement("contentHash")]
public string ContentHash { get; set; } = string.Empty;
}
/// <summary>
/// MongoDB document for effective finding history (append-only).
/// Collection: effective_finding_history_{policyId}
/// </summary>
[BsonIgnoreExtraElements]
public sealed class EffectiveFindingHistoryDocument
{
/// <summary>
/// Unique identifier: {findingId}:v{version}
/// </summary>
[BsonId]
[BsonElement("_id")]
public string Id { get; set; } = string.Empty;
/// <summary>
/// Tenant identifier.
/// </summary>
[BsonElement("tenantId")]
public string TenantId { get; set; } = string.Empty;
/// <summary>
/// Reference to the effective finding.
/// </summary>
[BsonElement("findingId")]
public string FindingId { get; set; } = string.Empty;
/// <summary>
/// Policy identifier.
/// </summary>
[BsonElement("policyId")]
public string PolicyId { get; set; } = string.Empty;
/// <summary>
/// History version number (monotonically increasing).
/// </summary>
[BsonElement("version")]
public long Version { get; set; }
/// <summary>
/// Type of change (Created, StatusChanged, SeverityChanged, VexApplied, etc.).
/// </summary>
[BsonElement("changeType")]
public string ChangeType { get; set; } = string.Empty;
/// <summary>
/// Previous status (for status changes).
/// </summary>
[BsonElement("previousStatus")]
[BsonIgnoreIfNull]
public string? PreviousStatus { get; set; }
/// <summary>
/// New status.
/// </summary>
[BsonElement("newStatus")]
public string NewStatus { get; set; } = string.Empty;
/// <summary>
/// Previous severity (for severity changes).
/// </summary>
[BsonElement("previousSeverity")]
[BsonIgnoreIfNull]
public string? PreviousSeverity { get; set; }
/// <summary>
/// New severity.
/// </summary>
[BsonElement("newSeverity")]
[BsonIgnoreIfNull]
public string? NewSeverity { get; set; }
/// <summary>
/// Previous content hash.
/// </summary>
[BsonElement("previousContentHash")]
[BsonIgnoreIfNull]
public string? PreviousContentHash { get; set; }
/// <summary>
/// New content hash.
/// </summary>
[BsonElement("newContentHash")]
public string NewContentHash { get; set; } = string.Empty;
/// <summary>
/// Policy run that triggered this change.
/// </summary>
[BsonElement("policyRunId")]
[BsonIgnoreIfNull]
public string? PolicyRunId { get; set; }
/// <summary>
/// Trace ID for distributed tracing.
/// </summary>
[BsonElement("traceId")]
[BsonIgnoreIfNull]
public string? TraceId { get; set; }
/// <summary>
/// When this change occurred.
/// </summary>
[BsonElement("occurredAt")]
public DateTimeOffset OccurredAt { get; set; }
/// <summary>
/// TTL expiration timestamp for automatic cleanup.
/// </summary>
[BsonElement("expiresAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? ExpiresAt { get; set; }
/// <summary>
/// Creates the composite ID for a history entry.
/// </summary>
public static string CreateId(string findingId, long version) => $"{findingId}:v{version}";
}

View File

@@ -0,0 +1,157 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Policy.Engine.Storage.Mongo.Documents;
/// <summary>
/// MongoDB document for policy audit log entries.
/// Collection: policy_audit
/// Tracks all policy-related actions for compliance and debugging.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PolicyAuditDocument
{
/// <summary>
/// Unique audit entry identifier.
/// </summary>
[BsonId]
[BsonElement("_id")]
public ObjectId Id { get; set; }
/// <summary>
/// Tenant identifier.
/// </summary>
[BsonElement("tenantId")]
public string TenantId { get; set; } = string.Empty;
/// <summary>
/// Action type (PolicyCreated, PolicyUpdated, RevisionApproved, RunStarted, etc.).
/// </summary>
[BsonElement("action")]
public string Action { get; set; } = string.Empty;
/// <summary>
/// Resource type (Policy, Revision, Bundle, Run, Finding).
/// </summary>
[BsonElement("resourceType")]
public string ResourceType { get; set; } = string.Empty;
/// <summary>
/// Resource identifier.
/// </summary>
[BsonElement("resourceId")]
public string ResourceId { get; set; } = string.Empty;
/// <summary>
/// Actor identifier (user ID or service account).
/// </summary>
[BsonElement("actorId")]
[BsonIgnoreIfNull]
public string? ActorId { get; set; }
/// <summary>
/// Actor type (User, ServiceAccount, System).
/// </summary>
[BsonElement("actorType")]
public string ActorType { get; set; } = "System";
/// <summary>
/// Previous state snapshot (for update actions).
/// </summary>
[BsonElement("previousState")]
[BsonIgnoreIfNull]
public BsonDocument? PreviousState { get; set; }
/// <summary>
/// New state snapshot (for create/update actions).
/// </summary>
[BsonElement("newState")]
[BsonIgnoreIfNull]
public BsonDocument? NewState { get; set; }
/// <summary>
/// Additional context/metadata.
/// </summary>
[BsonElement("metadata")]
public Dictionary<string, string> Metadata { get; set; } = new();
/// <summary>
/// Correlation ID for distributed tracing.
/// </summary>
[BsonElement("correlationId")]
[BsonIgnoreIfNull]
public string? CorrelationId { get; set; }
/// <summary>
/// Trace ID for OpenTelemetry.
/// </summary>
[BsonElement("traceId")]
[BsonIgnoreIfNull]
public string? TraceId { get; set; }
/// <summary>
/// Client IP address.
/// </summary>
[BsonElement("clientIp")]
[BsonIgnoreIfNull]
public string? ClientIp { get; set; }
/// <summary>
/// User agent string.
/// </summary>
[BsonElement("userAgent")]
[BsonIgnoreIfNull]
public string? UserAgent { get; set; }
/// <summary>
/// When the action occurred.
/// </summary>
[BsonElement("occurredAt")]
public DateTimeOffset OccurredAt { get; set; }
}
/// <summary>
/// Audit action types for policy operations.
/// </summary>
public static class PolicyAuditActions
{
public const string PolicyCreated = "PolicyCreated";
public const string PolicyUpdated = "PolicyUpdated";
public const string PolicyDeleted = "PolicyDeleted";
public const string RevisionCreated = "RevisionCreated";
public const string RevisionApproved = "RevisionApproved";
public const string RevisionActivated = "RevisionActivated";
public const string RevisionArchived = "RevisionArchived";
public const string BundleCompiled = "BundleCompiled";
public const string RunStarted = "RunStarted";
public const string RunCompleted = "RunCompleted";
public const string RunFailed = "RunFailed";
public const string RunCancelled = "RunCancelled";
public const string FindingCreated = "FindingCreated";
public const string FindingUpdated = "FindingUpdated";
public const string SimulationStarted = "SimulationStarted";
public const string SimulationCompleted = "SimulationCompleted";
}
/// <summary>
/// Resource types for policy audit entries.
/// </summary>
public static class PolicyAuditResourceTypes
{
public const string Policy = "Policy";
public const string Revision = "Revision";
public const string Bundle = "Bundle";
public const string Run = "Run";
public const string Finding = "Finding";
public const string Simulation = "Simulation";
}
/// <summary>
/// Actor types for policy audit entries.
/// </summary>
public static class PolicyAuditActorTypes
{
public const string User = "User";
public const string ServiceAccount = "ServiceAccount";
public const string System = "System";
}

View File

@@ -0,0 +1,343 @@
using System.Collections.Immutable;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Policy.Engine.Storage.Mongo.Documents;
/// <summary>
/// MongoDB document representing a policy pack.
/// Collection: policies
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PolicyDocument
{
/// <summary>
/// Unique identifier (packId).
/// </summary>
[BsonId]
[BsonElement("_id")]
public string Id { get; set; } = string.Empty;
/// <summary>
/// Tenant identifier (normalized to lowercase).
/// </summary>
[BsonElement("tenantId")]
public string TenantId { get; set; } = string.Empty;
/// <summary>
/// Display name for the policy pack.
/// </summary>
[BsonElement("displayName")]
[BsonIgnoreIfNull]
public string? DisplayName { get; set; }
/// <summary>
/// Description of the policy pack.
/// </summary>
[BsonElement("description")]
[BsonIgnoreIfNull]
public string? Description { get; set; }
/// <summary>
/// Current active revision version (null if none active).
/// </summary>
[BsonElement("activeVersion")]
[BsonIgnoreIfNull]
public int? ActiveVersion { get; set; }
/// <summary>
/// Latest revision version.
/// </summary>
[BsonElement("latestVersion")]
public int LatestVersion { get; set; }
/// <summary>
/// Tags for categorization and filtering.
/// </summary>
[BsonElement("tags")]
public List<string> Tags { get; set; } = [];
/// <summary>
/// Creation timestamp.
/// </summary>
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; }
/// <summary>
/// Last update timestamp.
/// </summary>
[BsonElement("updatedAt")]
public DateTimeOffset UpdatedAt { get; set; }
/// <summary>
/// User who created the policy pack.
/// </summary>
[BsonElement("createdBy")]
[BsonIgnoreIfNull]
public string? CreatedBy { get; set; }
}
/// <summary>
/// MongoDB document representing a policy revision.
/// Collection: policy_revisions
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PolicyRevisionDocument
{
/// <summary>
/// Unique identifier: {packId}:{version}
/// </summary>
[BsonId]
[BsonElement("_id")]
public string Id { get; set; } = string.Empty;
/// <summary>
/// Tenant identifier.
/// </summary>
[BsonElement("tenantId")]
public string TenantId { get; set; } = string.Empty;
/// <summary>
/// Reference to policy pack.
/// </summary>
[BsonElement("packId")]
public string PackId { get; set; } = string.Empty;
/// <summary>
/// Revision version number.
/// </summary>
[BsonElement("version")]
public int Version { get; set; }
/// <summary>
/// Revision status (Draft, Approved, Active, Archived).
/// </summary>
[BsonElement("status")]
public string Status { get; set; } = "Draft";
/// <summary>
/// Whether two-person approval is required.
/// </summary>
[BsonElement("requiresTwoPersonApproval")]
public bool RequiresTwoPersonApproval { get; set; }
/// <summary>
/// Approval records.
/// </summary>
[BsonElement("approvals")]
public List<PolicyApprovalRecord> Approvals { get; set; } = [];
/// <summary>
/// Reference to the compiled bundle.
/// </summary>
[BsonElement("bundleId")]
[BsonIgnoreIfNull]
public string? BundleId { get; set; }
/// <summary>
/// SHA256 digest of the bundle.
/// </summary>
[BsonElement("bundleDigest")]
[BsonIgnoreIfNull]
public string? BundleDigest { get; set; }
/// <summary>
/// Creation timestamp.
/// </summary>
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; }
/// <summary>
/// Activation timestamp (when status became Active).
/// </summary>
[BsonElement("activatedAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? ActivatedAt { get; set; }
/// <summary>
/// Creates the composite ID for a revision.
/// </summary>
public static string CreateId(string packId, int version) => $"{packId}:{version}";
}
/// <summary>
/// Embedded approval record for policy revisions.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PolicyApprovalRecord
{
/// <summary>
/// User who approved.
/// </summary>
[BsonElement("actorId")]
public string ActorId { get; set; } = string.Empty;
/// <summary>
/// Approval timestamp.
/// </summary>
[BsonElement("approvedAt")]
public DateTimeOffset ApprovedAt { get; set; }
/// <summary>
/// Optional comment.
/// </summary>
[BsonElement("comment")]
[BsonIgnoreIfNull]
public string? Comment { get; set; }
}
/// <summary>
/// MongoDB document for compiled policy bundles.
/// Collection: policy_bundles
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PolicyBundleDocument
{
/// <summary>
/// Unique identifier (SHA256 digest).
/// </summary>
[BsonId]
[BsonElement("_id")]
public string Id { get; set; } = string.Empty;
/// <summary>
/// Tenant identifier.
/// </summary>
[BsonElement("tenantId")]
public string TenantId { get; set; } = string.Empty;
/// <summary>
/// Reference to policy pack.
/// </summary>
[BsonElement("packId")]
public string PackId { get; set; } = string.Empty;
/// <summary>
/// Revision version.
/// </summary>
[BsonElement("version")]
public int Version { get; set; }
/// <summary>
/// Cryptographic signature.
/// </summary>
[BsonElement("signature")]
public string Signature { get; set; } = string.Empty;
/// <summary>
/// Bundle size in bytes.
/// </summary>
[BsonElement("sizeBytes")]
public int SizeBytes { get; set; }
/// <summary>
/// Compiled bundle payload (binary).
/// </summary>
[BsonElement("payload")]
public byte[] Payload { get; set; } = [];
/// <summary>
/// AOC metadata for compliance tracking.
/// </summary>
[BsonElement("aocMetadata")]
[BsonIgnoreIfNull]
public PolicyAocMetadataDocument? AocMetadata { get; set; }
/// <summary>
/// Creation timestamp.
/// </summary>
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; }
}
/// <summary>
/// Embedded AOC metadata document.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PolicyAocMetadataDocument
{
[BsonElement("compilationId")]
public string CompilationId { get; set; } = string.Empty;
[BsonElement("compilerVersion")]
public string CompilerVersion { get; set; } = string.Empty;
[BsonElement("compiledAt")]
public DateTimeOffset CompiledAt { get; set; }
[BsonElement("sourceDigest")]
public string SourceDigest { get; set; } = string.Empty;
[BsonElement("artifactDigest")]
public string ArtifactDigest { get; set; } = string.Empty;
[BsonElement("complexityScore")]
public double ComplexityScore { get; set; }
[BsonElement("ruleCount")]
public int RuleCount { get; set; }
[BsonElement("durationMilliseconds")]
public long DurationMilliseconds { get; set; }
[BsonElement("provenance")]
[BsonIgnoreIfNull]
public PolicyProvenanceDocument? Provenance { get; set; }
[BsonElement("attestationRef")]
[BsonIgnoreIfNull]
public PolicyAttestationRefDocument? AttestationRef { get; set; }
}
/// <summary>
/// Embedded provenance document.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PolicyProvenanceDocument
{
[BsonElement("sourceType")]
public string SourceType { get; set; } = string.Empty;
[BsonElement("sourceUrl")]
[BsonIgnoreIfNull]
public string? SourceUrl { get; set; }
[BsonElement("submitter")]
[BsonIgnoreIfNull]
public string? Submitter { get; set; }
[BsonElement("commitSha")]
[BsonIgnoreIfNull]
public string? CommitSha { get; set; }
[BsonElement("branch")]
[BsonIgnoreIfNull]
public string? Branch { get; set; }
[BsonElement("ingestedAt")]
public DateTimeOffset IngestedAt { get; set; }
}
/// <summary>
/// Embedded attestation reference document.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PolicyAttestationRefDocument
{
[BsonElement("attestationId")]
public string AttestationId { get; set; } = string.Empty;
[BsonElement("envelopeDigest")]
public string EnvelopeDigest { get; set; } = string.Empty;
[BsonElement("uri")]
[BsonIgnoreIfNull]
public string? Uri { get; set; }
[BsonElement("signingKeyId")]
[BsonIgnoreIfNull]
public string? SigningKeyId { get; set; }
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; }
}

View File

@@ -0,0 +1,482 @@
using System.Collections.Immutable;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Policy.Engine.Storage.Mongo.Documents;
/// <summary>
/// MongoDB document representing a policy exception.
/// Collection: exceptions
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PolicyExceptionDocument
{
/// <summary>
/// Unique identifier.
/// </summary>
[BsonId]
[BsonElement("_id")]
public string Id { get; set; } = string.Empty;
/// <summary>
/// Tenant identifier (normalized to lowercase).
/// </summary>
[BsonElement("tenantId")]
public string TenantId { get; set; } = string.Empty;
/// <summary>
/// Human-readable name for the exception.
/// </summary>
[BsonElement("name")]
public string Name { get; set; } = string.Empty;
/// <summary>
/// Description and justification for the exception.
/// </summary>
[BsonElement("description")]
[BsonIgnoreIfNull]
public string? Description { get; set; }
/// <summary>
/// Exception type: waiver, override, temporary, permanent.
/// </summary>
[BsonElement("exceptionType")]
public string ExceptionType { get; set; } = "waiver";
/// <summary>
/// Exception status: draft, pending_review, approved, active, expired, revoked.
/// </summary>
[BsonElement("status")]
public string Status { get; set; } = "draft";
/// <summary>
/// Scope of the exception (e.g., advisory IDs, PURL patterns, CVE IDs).
/// </summary>
[BsonElement("scope")]
public ExceptionScopeDocument Scope { get; set; } = new();
/// <summary>
/// Risk assessment and mitigation details.
/// </summary>
[BsonElement("riskAssessment")]
[BsonIgnoreIfNull]
public ExceptionRiskAssessmentDocument? RiskAssessment { get; set; }
/// <summary>
/// Compensating controls in place while exception is active.
/// </summary>
[BsonElement("compensatingControls")]
public List<string> CompensatingControls { get; set; } = [];
/// <summary>
/// Tags for categorization and filtering.
/// </summary>
[BsonElement("tags")]
public List<string> Tags { get; set; } = [];
/// <summary>
/// Priority for conflict resolution (higher = more precedence).
/// </summary>
[BsonElement("priority")]
public int Priority { get; set; }
/// <summary>
/// When the exception becomes active (null = immediately upon approval).
/// </summary>
[BsonElement("effectiveFrom")]
[BsonIgnoreIfNull]
public DateTimeOffset? EffectiveFrom { get; set; }
/// <summary>
/// When the exception expires (null = no expiration).
/// </summary>
[BsonElement("expiresAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? ExpiresAt { get; set; }
/// <summary>
/// User who created the exception.
/// </summary>
[BsonElement("createdBy")]
public string CreatedBy { get; set; } = string.Empty;
/// <summary>
/// Creation timestamp.
/// </summary>
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; }
/// <summary>
/// Last update timestamp.
/// </summary>
[BsonElement("updatedAt")]
public DateTimeOffset UpdatedAt { get; set; }
/// <summary>
/// When the exception was activated.
/// </summary>
[BsonElement("activatedAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? ActivatedAt { get; set; }
/// <summary>
/// When the exception was revoked.
/// </summary>
[BsonElement("revokedAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? RevokedAt { get; set; }
/// <summary>
/// User who revoked the exception.
/// </summary>
[BsonElement("revokedBy")]
[BsonIgnoreIfNull]
public string? RevokedBy { get; set; }
/// <summary>
/// Reason for revocation.
/// </summary>
[BsonElement("revocationReason")]
[BsonIgnoreIfNull]
public string? RevocationReason { get; set; }
/// <summary>
/// Reference to the active review (if pending_review status).
/// </summary>
[BsonElement("activeReviewId")]
[BsonIgnoreIfNull]
public string? ActiveReviewId { get; set; }
/// <summary>
/// Correlation ID for tracing.
/// </summary>
[BsonElement("correlationId")]
[BsonIgnoreIfNull]
public string? CorrelationId { get; set; }
}
/// <summary>
/// Embedded document for exception scope definition.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class ExceptionScopeDocument
{
/// <summary>
/// Advisory IDs covered by this exception.
/// </summary>
[BsonElement("advisoryIds")]
public List<string> AdvisoryIds { get; set; } = [];
/// <summary>
/// CVE IDs covered by this exception.
/// </summary>
[BsonElement("cveIds")]
public List<string> CveIds { get; set; } = [];
/// <summary>
/// PURL patterns (supports wildcards) covered by this exception.
/// </summary>
[BsonElement("purlPatterns")]
public List<string> PurlPatterns { get; set; } = [];
/// <summary>
/// Specific asset IDs covered.
/// </summary>
[BsonElement("assetIds")]
public List<string> AssetIds { get; set; } = [];
/// <summary>
/// Repository IDs covered (scope limiter).
/// </summary>
[BsonElement("repositoryIds")]
public List<string> RepositoryIds { get; set; } = [];
/// <summary>
/// Snapshot IDs covered (scope limiter).
/// </summary>
[BsonElement("snapshotIds")]
public List<string> SnapshotIds { get; set; } = [];
/// <summary>
/// Severity levels to apply exception to.
/// </summary>
[BsonElement("severities")]
public List<string> Severities { get; set; } = [];
/// <summary>
/// Whether this exception applies to all assets (tenant-wide).
/// </summary>
[BsonElement("applyToAll")]
public bool ApplyToAll { get; set; }
}
/// <summary>
/// Embedded document for risk assessment.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class ExceptionRiskAssessmentDocument
{
/// <summary>
/// Original risk level being excepted.
/// </summary>
[BsonElement("originalRiskLevel")]
public string OriginalRiskLevel { get; set; } = string.Empty;
/// <summary>
/// Residual risk level after compensating controls.
/// </summary>
[BsonElement("residualRiskLevel")]
public string ResidualRiskLevel { get; set; } = string.Empty;
/// <summary>
/// Business justification for accepting the risk.
/// </summary>
[BsonElement("businessJustification")]
[BsonIgnoreIfNull]
public string? BusinessJustification { get; set; }
/// <summary>
/// Impact assessment if vulnerability is exploited.
/// </summary>
[BsonElement("impactAssessment")]
[BsonIgnoreIfNull]
public string? ImpactAssessment { get; set; }
/// <summary>
/// Exploitability assessment.
/// </summary>
[BsonElement("exploitability")]
[BsonIgnoreIfNull]
public string? Exploitability { get; set; }
}
/// <summary>
/// MongoDB document representing an exception review.
/// Collection: exception_reviews
/// </summary>
[BsonIgnoreExtraElements]
public sealed class ExceptionReviewDocument
{
/// <summary>
/// Unique identifier.
/// </summary>
[BsonId]
[BsonElement("_id")]
public string Id { get; set; } = string.Empty;
/// <summary>
/// Tenant identifier.
/// </summary>
[BsonElement("tenantId")]
public string TenantId { get; set; } = string.Empty;
/// <summary>
/// Reference to the exception being reviewed.
/// </summary>
[BsonElement("exceptionId")]
public string ExceptionId { get; set; } = string.Empty;
/// <summary>
/// Review status: pending, approved, rejected.
/// </summary>
[BsonElement("status")]
public string Status { get; set; } = "pending";
/// <summary>
/// Type of review: initial, renewal, modification.
/// </summary>
[BsonElement("reviewType")]
public string ReviewType { get; set; } = "initial";
/// <summary>
/// Whether multiple approvers are required.
/// </summary>
[BsonElement("requiresMultipleApprovers")]
public bool RequiresMultipleApprovers { get; set; }
/// <summary>
/// Minimum number of approvals required.
/// </summary>
[BsonElement("requiredApprovals")]
public int RequiredApprovals { get; set; } = 1;
/// <summary>
/// Designated reviewers (user or group IDs).
/// </summary>
[BsonElement("designatedReviewers")]
public List<string> DesignatedReviewers { get; set; } = [];
/// <summary>
/// Individual approval/rejection decisions.
/// </summary>
[BsonElement("decisions")]
public List<ReviewDecisionDocument> Decisions { get; set; } = [];
/// <summary>
/// User who requested the review.
/// </summary>
[BsonElement("requestedBy")]
public string RequestedBy { get; set; } = string.Empty;
/// <summary>
/// When the review was requested.
/// </summary>
[BsonElement("requestedAt")]
public DateTimeOffset RequestedAt { get; set; }
/// <summary>
/// When the review was completed.
/// </summary>
[BsonElement("completedAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? CompletedAt { get; set; }
/// <summary>
/// Review deadline.
/// </summary>
[BsonElement("deadline")]
[BsonIgnoreIfNull]
public DateTimeOffset? Deadline { get; set; }
/// <summary>
/// Notes or comments on the review.
/// </summary>
[BsonElement("notes")]
[BsonIgnoreIfNull]
public string? Notes { get; set; }
/// <summary>
/// Creates the composite ID for a review.
/// </summary>
public static string CreateId(string exceptionId, string reviewType, DateTimeOffset timestamp)
=> $"{exceptionId}:{reviewType}:{timestamp:yyyyMMddHHmmss}";
}
/// <summary>
/// Embedded document for an individual reviewer's decision.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class ReviewDecisionDocument
{
/// <summary>
/// Reviewer ID (user or service account).
/// </summary>
[BsonElement("reviewerId")]
public string ReviewerId { get; set; } = string.Empty;
/// <summary>
/// Decision: approved, rejected, abstained.
/// </summary>
[BsonElement("decision")]
public string Decision { get; set; } = string.Empty;
/// <summary>
/// Timestamp of the decision.
/// </summary>
[BsonElement("decidedAt")]
public DateTimeOffset DecidedAt { get; set; }
/// <summary>
/// Comment explaining the decision.
/// </summary>
[BsonElement("comment")]
[BsonIgnoreIfNull]
public string? Comment { get; set; }
/// <summary>
/// Conditions attached to approval.
/// </summary>
[BsonElement("conditions")]
public List<string> Conditions { get; set; } = [];
}
/// <summary>
/// MongoDB document representing an exception binding to specific assets.
/// Collection: exception_bindings
/// </summary>
[BsonIgnoreExtraElements]
public sealed class ExceptionBindingDocument
{
/// <summary>
/// Unique identifier: {exceptionId}:{assetId}:{advisoryId}
/// </summary>
[BsonId]
[BsonElement("_id")]
public string Id { get; set; } = string.Empty;
/// <summary>
/// Tenant identifier.
/// </summary>
[BsonElement("tenantId")]
public string TenantId { get; set; } = string.Empty;
/// <summary>
/// Reference to the exception.
/// </summary>
[BsonElement("exceptionId")]
public string ExceptionId { get; set; } = string.Empty;
/// <summary>
/// Asset ID (PURL or other identifier) this binding applies to.
/// </summary>
[BsonElement("assetId")]
public string AssetId { get; set; } = string.Empty;
/// <summary>
/// Advisory ID this binding covers.
/// </summary>
[BsonElement("advisoryId")]
[BsonIgnoreIfNull]
public string? AdvisoryId { get; set; }
/// <summary>
/// CVE ID this binding covers.
/// </summary>
[BsonElement("cveId")]
[BsonIgnoreIfNull]
public string? CveId { get; set; }
/// <summary>
/// Snapshot ID where binding was created.
/// </summary>
[BsonElement("snapshotId")]
[BsonIgnoreIfNull]
public string? SnapshotId { get; set; }
/// <summary>
/// Binding status: active, expired, revoked.
/// </summary>
[BsonElement("status")]
public string Status { get; set; } = "active";
/// <summary>
/// Policy decision override applied by this binding.
/// </summary>
[BsonElement("decisionOverride")]
public string DecisionOverride { get; set; } = "allow";
/// <summary>
/// When the binding becomes effective.
/// </summary>
[BsonElement("effectiveFrom")]
public DateTimeOffset EffectiveFrom { get; set; }
/// <summary>
/// When the binding expires.
/// </summary>
[BsonElement("expiresAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? ExpiresAt { get; set; }
/// <summary>
/// When the binding was created.
/// </summary>
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; }
/// <summary>
/// Creates the composite ID for a binding.
/// </summary>
public static string CreateId(string exceptionId, string assetId, string? advisoryId)
=> $"{exceptionId}:{assetId}:{advisoryId ?? "all"}";
}

View File

@@ -0,0 +1,383 @@
using System.Collections.Immutable;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Policy.Engine.Storage.Mongo.Documents;
/// <summary>
/// MongoDB document for storing policy explain traces.
/// Collection: policy_explains
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PolicyExplainDocument
{
/// <summary>
/// Unique identifier (combination of runId and subjectHash).
/// </summary>
[BsonId]
[BsonElement("_id")]
public string Id { get; set; } = string.Empty;
/// <summary>
/// Tenant identifier.
/// </summary>
[BsonElement("tenantId")]
public string TenantId { get; set; } = string.Empty;
/// <summary>
/// Policy run identifier.
/// </summary>
[BsonElement("runId")]
public string RunId { get; set; } = string.Empty;
/// <summary>
/// Policy pack identifier.
/// </summary>
[BsonElement("policyId")]
public string PolicyId { get; set; } = string.Empty;
/// <summary>
/// Policy version at time of evaluation.
/// </summary>
[BsonElement("policyVersion")]
[BsonIgnoreIfNull]
public int? PolicyVersion { get; set; }
/// <summary>
/// Hash of the evaluation subject (component + advisory).
/// </summary>
[BsonElement("subjectHash")]
public string SubjectHash { get; set; } = string.Empty;
/// <summary>
/// Hash of the policy bundle used.
/// </summary>
[BsonElement("bundleDigest")]
[BsonIgnoreIfNull]
public string? BundleDigest { get; set; }
/// <summary>
/// Evaluation timestamp (deterministic).
/// </summary>
[BsonElement("evaluatedAt")]
public DateTimeOffset EvaluatedAt { get; set; }
/// <summary>
/// Evaluation duration in milliseconds.
/// </summary>
[BsonElement("durationMs")]
public long DurationMs { get; set; }
/// <summary>
/// Final outcome of the evaluation.
/// </summary>
[BsonElement("finalOutcome")]
public string FinalOutcome { get; set; } = string.Empty;
/// <summary>
/// Input context information.
/// </summary>
[BsonElement("inputContext")]
public ExplainInputContextDocument InputContext { get; set; } = new();
/// <summary>
/// Rule evaluation steps.
/// </summary>
[BsonElement("ruleSteps")]
public List<ExplainRuleStepDocument> RuleSteps { get; set; } = [];
/// <summary>
/// VEX evidence applied.
/// </summary>
[BsonElement("vexEvidence")]
public List<ExplainVexEvidenceDocument> VexEvidence { get; set; } = [];
/// <summary>
/// Statistics summary.
/// </summary>
[BsonElement("statistics")]
public ExplainStatisticsDocument Statistics { get; set; } = new();
/// <summary>
/// Determinism hash for reproducibility verification.
/// </summary>
[BsonElement("determinismHash")]
[BsonIgnoreIfNull]
public string? DeterminismHash { get; set; }
/// <summary>
/// Reference to AOC chain for this evaluation.
/// </summary>
[BsonElement("aocChain")]
[BsonIgnoreIfNull]
public ExplainAocChainDocument? AocChain { get; set; }
/// <summary>
/// Additional metadata.
/// </summary>
[BsonElement("metadata")]
public Dictionary<string, string> Metadata { get; set; } = new();
/// <summary>
/// Creation timestamp.
/// </summary>
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; }
/// <summary>
/// TTL expiration timestamp for automatic cleanup.
/// </summary>
[BsonElement("expiresAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? ExpiresAt { get; set; }
/// <summary>
/// Creates the composite ID for an explain trace.
/// </summary>
public static string CreateId(string runId, string subjectHash) => $"{runId}:{subjectHash}";
}
/// <summary>
/// Input context embedded document.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class ExplainInputContextDocument
{
[BsonElement("componentPurl")]
[BsonIgnoreIfNull]
public string? ComponentPurl { get; set; }
[BsonElement("componentName")]
[BsonIgnoreIfNull]
public string? ComponentName { get; set; }
[BsonElement("componentVersion")]
[BsonIgnoreIfNull]
public string? ComponentVersion { get; set; }
[BsonElement("advisoryId")]
[BsonIgnoreIfNull]
public string? AdvisoryId { get; set; }
[BsonElement("vulnerabilityId")]
[BsonIgnoreIfNull]
public string? VulnerabilityId { get; set; }
[BsonElement("inputSeverity")]
[BsonIgnoreIfNull]
public string? InputSeverity { get; set; }
[BsonElement("inputCvssScore")]
[BsonIgnoreIfNull]
public decimal? InputCvssScore { get; set; }
[BsonElement("environment")]
public Dictionary<string, string> Environment { get; set; } = new();
[BsonElement("sbomTags")]
public List<string> SbomTags { get; set; } = [];
[BsonElement("reachabilityState")]
[BsonIgnoreIfNull]
public string? ReachabilityState { get; set; }
[BsonElement("reachabilityConfidence")]
[BsonIgnoreIfNull]
public double? ReachabilityConfidence { get; set; }
}
/// <summary>
/// Rule step embedded document.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class ExplainRuleStepDocument
{
[BsonElement("stepNumber")]
public int StepNumber { get; set; }
[BsonElement("ruleName")]
public string RuleName { get; set; } = string.Empty;
[BsonElement("rulePriority")]
public int RulePriority { get; set; }
[BsonElement("ruleCategory")]
[BsonIgnoreIfNull]
public string? RuleCategory { get; set; }
[BsonElement("expression")]
[BsonIgnoreIfNull]
public string? Expression { get; set; }
[BsonElement("matched")]
public bool Matched { get; set; }
[BsonElement("outcome")]
[BsonIgnoreIfNull]
public string? Outcome { get; set; }
[BsonElement("assignedSeverity")]
[BsonIgnoreIfNull]
public string? AssignedSeverity { get; set; }
[BsonElement("isFinalMatch")]
public bool IsFinalMatch { get; set; }
[BsonElement("explanation")]
[BsonIgnoreIfNull]
public string? Explanation { get; set; }
[BsonElement("evaluationMicroseconds")]
public long EvaluationMicroseconds { get; set; }
[BsonElement("intermediateValues")]
public Dictionary<string, string> IntermediateValues { get; set; } = new();
}
/// <summary>
/// VEX evidence embedded document.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class ExplainVexEvidenceDocument
{
[BsonElement("vendor")]
public string Vendor { get; set; } = string.Empty;
[BsonElement("status")]
public string Status { get; set; } = string.Empty;
[BsonElement("justification")]
[BsonIgnoreIfNull]
public string? Justification { get; set; }
[BsonElement("confidence")]
[BsonIgnoreIfNull]
public double? Confidence { get; set; }
[BsonElement("wasApplied")]
public bool WasApplied { get; set; }
[BsonElement("explanation")]
[BsonIgnoreIfNull]
public string? Explanation { get; set; }
}
/// <summary>
/// Statistics embedded document.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class ExplainStatisticsDocument
{
[BsonElement("totalRulesEvaluated")]
public int TotalRulesEvaluated { get; set; }
[BsonElement("totalRulesFired")]
public int TotalRulesFired { get; set; }
[BsonElement("totalVexOverrides")]
public int TotalVexOverrides { get; set; }
[BsonElement("totalEvaluationMs")]
public long TotalEvaluationMs { get; set; }
[BsonElement("averageRuleEvaluationMicroseconds")]
public double AverageRuleEvaluationMicroseconds { get; set; }
[BsonElement("rulesFiredByCategory")]
public Dictionary<string, int> RulesFiredByCategory { get; set; } = new();
[BsonElement("rulesFiredByOutcome")]
public Dictionary<string, int> RulesFiredByOutcome { get; set; } = new();
}
/// <summary>
/// AOC chain reference for linking decisions to attestations.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class ExplainAocChainDocument
{
/// <summary>
/// Compilation ID that produced the policy bundle.
/// </summary>
[BsonElement("compilationId")]
public string CompilationId { get; set; } = string.Empty;
/// <summary>
/// Compiler version used.
/// </summary>
[BsonElement("compilerVersion")]
public string CompilerVersion { get; set; } = string.Empty;
/// <summary>
/// Source digest of the policy document.
/// </summary>
[BsonElement("sourceDigest")]
public string SourceDigest { get; set; } = string.Empty;
/// <summary>
/// Artifact digest of the compiled bundle.
/// </summary>
[BsonElement("artifactDigest")]
public string ArtifactDigest { get; set; } = string.Empty;
/// <summary>
/// Reference to the signed attestation.
/// </summary>
[BsonElement("attestationRef")]
[BsonIgnoreIfNull]
public ExplainAttestationRefDocument? AttestationRef { get; set; }
/// <summary>
/// Provenance information.
/// </summary>
[BsonElement("provenance")]
[BsonIgnoreIfNull]
public ExplainProvenanceDocument? Provenance { get; set; }
}
/// <summary>
/// Attestation reference embedded document.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class ExplainAttestationRefDocument
{
[BsonElement("attestationId")]
public string AttestationId { get; set; } = string.Empty;
[BsonElement("envelopeDigest")]
public string EnvelopeDigest { get; set; } = string.Empty;
[BsonElement("uri")]
[BsonIgnoreIfNull]
public string? Uri { get; set; }
[BsonElement("signingKeyId")]
[BsonIgnoreIfNull]
public string? SigningKeyId { get; set; }
}
/// <summary>
/// Provenance embedded document.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class ExplainProvenanceDocument
{
[BsonElement("sourceType")]
public string SourceType { get; set; } = string.Empty;
[BsonElement("sourceUrl")]
[BsonIgnoreIfNull]
public string? SourceUrl { get; set; }
[BsonElement("submitter")]
[BsonIgnoreIfNull]
public string? Submitter { get; set; }
[BsonElement("commitSha")]
[BsonIgnoreIfNull]
public string? CommitSha { get; set; }
[BsonElement("branch")]
[BsonIgnoreIfNull]
public string? Branch { get; set; }
}

View File

@@ -0,0 +1,319 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Policy.Engine.Storage.Mongo.Documents;
/// <summary>
/// MongoDB document representing a policy evaluation run.
/// Collection: policy_runs
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PolicyRunDocument
{
/// <summary>
/// Unique run identifier.
/// </summary>
[BsonId]
[BsonElement("_id")]
public string Id { get; set; } = string.Empty;
/// <summary>
/// Tenant identifier.
/// </summary>
[BsonElement("tenantId")]
public string TenantId { get; set; } = string.Empty;
/// <summary>
/// Policy pack identifier.
/// </summary>
[BsonElement("policyId")]
public string PolicyId { get; set; } = string.Empty;
/// <summary>
/// Policy version evaluated.
/// </summary>
[BsonElement("policyVersion")]
public int PolicyVersion { get; set; }
/// <summary>
/// Run mode (full, incremental, simulation, batch).
/// </summary>
[BsonElement("mode")]
public string Mode { get; set; } = "full";
/// <summary>
/// Run status (pending, running, completed, failed, cancelled).
/// </summary>
[BsonElement("status")]
public string Status { get; set; } = "pending";
/// <summary>
/// Trigger type (scheduled, manual, event, api).
/// </summary>
[BsonElement("triggerType")]
public string TriggerType { get; set; } = "manual";
/// <summary>
/// Correlation ID for distributed tracing.
/// </summary>
[BsonElement("correlationId")]
[BsonIgnoreIfNull]
public string? CorrelationId { get; set; }
/// <summary>
/// Trace ID for OpenTelemetry.
/// </summary>
[BsonElement("traceId")]
[BsonIgnoreIfNull]
public string? TraceId { get; set; }
/// <summary>
/// Parent span ID if part of larger operation.
/// </summary>
[BsonElement("parentSpanId")]
[BsonIgnoreIfNull]
public string? ParentSpanId { get; set; }
/// <summary>
/// User or service that initiated the run.
/// </summary>
[BsonElement("initiatedBy")]
[BsonIgnoreIfNull]
public string? InitiatedBy { get; set; }
/// <summary>
/// Deterministic evaluation timestamp used for this run.
/// </summary>
[BsonElement("evaluationTimestamp")]
public DateTimeOffset EvaluationTimestamp { get; set; }
/// <summary>
/// When the run started.
/// </summary>
[BsonElement("startedAt")]
public DateTimeOffset StartedAt { get; set; }
/// <summary>
/// When the run completed (null if still running).
/// </summary>
[BsonElement("completedAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? CompletedAt { get; set; }
/// <summary>
/// Run metrics and statistics.
/// </summary>
[BsonElement("metrics")]
public PolicyRunMetricsDocument Metrics { get; set; } = new();
/// <summary>
/// Input parameters for the run.
/// </summary>
[BsonElement("input")]
[BsonIgnoreIfNull]
public PolicyRunInputDocument? Input { get; set; }
/// <summary>
/// Run outcome summary.
/// </summary>
[BsonElement("outcome")]
[BsonIgnoreIfNull]
public PolicyRunOutcomeDocument? Outcome { get; set; }
/// <summary>
/// Error information if run failed.
/// </summary>
[BsonElement("error")]
[BsonIgnoreIfNull]
public PolicyRunErrorDocument? Error { get; set; }
/// <summary>
/// Determinism hash for reproducibility verification.
/// </summary>
[BsonElement("determinismHash")]
[BsonIgnoreIfNull]
public string? DeterminismHash { get; set; }
/// <summary>
/// TTL expiration timestamp for automatic cleanup.
/// </summary>
[BsonElement("expiresAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? ExpiresAt { get; set; }
}
/// <summary>
/// Embedded metrics document for policy runs.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PolicyRunMetricsDocument
{
/// <summary>
/// Total components evaluated.
/// </summary>
[BsonElement("totalComponents")]
public int TotalComponents { get; set; }
/// <summary>
/// Total advisories evaluated.
/// </summary>
[BsonElement("totalAdvisories")]
public int TotalAdvisories { get; set; }
/// <summary>
/// Total findings generated.
/// </summary>
[BsonElement("totalFindings")]
public int TotalFindings { get; set; }
/// <summary>
/// Rules evaluated count.
/// </summary>
[BsonElement("rulesEvaluated")]
public int RulesEvaluated { get; set; }
/// <summary>
/// Rules that matched/fired.
/// </summary>
[BsonElement("rulesFired")]
public int RulesFired { get; set; }
/// <summary>
/// VEX overrides applied.
/// </summary>
[BsonElement("vexOverridesApplied")]
public int VexOverridesApplied { get; set; }
/// <summary>
/// Findings created (new).
/// </summary>
[BsonElement("findingsCreated")]
public int FindingsCreated { get; set; }
/// <summary>
/// Findings updated (changed).
/// </summary>
[BsonElement("findingsUpdated")]
public int FindingsUpdated { get; set; }
/// <summary>
/// Findings unchanged.
/// </summary>
[BsonElement("findingsUnchanged")]
public int FindingsUnchanged { get; set; }
/// <summary>
/// Duration in milliseconds.
/// </summary>
[BsonElement("durationMs")]
public long DurationMs { get; set; }
/// <summary>
/// Memory used in bytes.
/// </summary>
[BsonElement("memoryUsedBytes")]
public long MemoryUsedBytes { get; set; }
}
/// <summary>
/// Embedded input parameters document.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PolicyRunInputDocument
{
/// <summary>
/// SBOM IDs included in evaluation.
/// </summary>
[BsonElement("sbomIds")]
public List<string> SbomIds { get; set; } = [];
/// <summary>
/// Product keys included in evaluation.
/// </summary>
[BsonElement("productKeys")]
public List<string> ProductKeys { get; set; } = [];
/// <summary>
/// Advisory IDs to evaluate (empty = all).
/// </summary>
[BsonElement("advisoryIds")]
public List<string> AdvisoryIds { get; set; } = [];
/// <summary>
/// Filter criteria applied.
/// </summary>
[BsonElement("filters")]
[BsonIgnoreIfNull]
public Dictionary<string, string>? Filters { get; set; }
}
/// <summary>
/// Embedded outcome summary document.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PolicyRunOutcomeDocument
{
/// <summary>
/// Overall outcome (pass, fail, warn).
/// </summary>
[BsonElement("result")]
public string Result { get; set; } = "pass";
/// <summary>
/// Findings by severity.
/// </summary>
[BsonElement("bySeverity")]
public Dictionary<string, int> BySeverity { get; set; } = new();
/// <summary>
/// Findings by status.
/// </summary>
[BsonElement("byStatus")]
public Dictionary<string, int> ByStatus { get; set; } = new();
/// <summary>
/// Blocking findings count.
/// </summary>
[BsonElement("blockingCount")]
public int BlockingCount { get; set; }
/// <summary>
/// Summary message.
/// </summary>
[BsonElement("message")]
[BsonIgnoreIfNull]
public string? Message { get; set; }
}
/// <summary>
/// Embedded error document.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PolicyRunErrorDocument
{
/// <summary>
/// Error code.
/// </summary>
[BsonElement("code")]
public string Code { get; set; } = string.Empty;
/// <summary>
/// Error message.
/// </summary>
[BsonElement("message")]
public string Message { get; set; } = string.Empty;
/// <summary>
/// Stack trace (if available).
/// </summary>
[BsonElement("stackTrace")]
[BsonIgnoreIfNull]
public string? StackTrace { get; set; }
/// <summary>
/// Inner error details.
/// </summary>
[BsonElement("innerError")]
[BsonIgnoreIfNull]
public string? InnerError { get; set; }
}

View File

@@ -0,0 +1,59 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.Policy.Engine.Storage.Mongo.Options;
namespace StellaOps.Policy.Engine.Storage.Mongo.Internal;
/// <summary>
/// MongoDB context for Policy Engine storage operations.
/// Provides configured access to the database with appropriate read/write concerns.
/// </summary>
internal sealed class PolicyEngineMongoContext
{
public PolicyEngineMongoContext(IOptions<PolicyEngineMongoOptions> options, ILogger<PolicyEngineMongoContext> logger)
{
ArgumentNullException.ThrowIfNull(logger);
var value = options?.Value ?? throw new ArgumentNullException(nameof(options));
if (string.IsNullOrWhiteSpace(value.ConnectionString))
{
throw new InvalidOperationException("Policy Engine Mongo connection string is not configured.");
}
if (string.IsNullOrWhiteSpace(value.Database))
{
throw new InvalidOperationException("Policy Engine Mongo database name is not configured.");
}
Client = new MongoClient(value.ConnectionString);
var settings = new MongoDatabaseSettings();
if (value.UseMajorityReadConcern)
{
settings.ReadConcern = ReadConcern.Majority;
}
if (value.UseMajorityWriteConcern)
{
settings.WriteConcern = WriteConcern.WMajority;
}
Database = Client.GetDatabase(value.Database, settings);
Options = value;
}
/// <summary>
/// MongoDB client instance.
/// </summary>
public MongoClient Client { get; }
/// <summary>
/// MongoDB database instance with configured read/write concerns.
/// </summary>
public IMongoDatabase Database { get; }
/// <summary>
/// Policy Engine MongoDB options.
/// </summary>
public PolicyEngineMongoOptions Options { get; }
}

View File

@@ -0,0 +1,44 @@
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.Storage.Mongo.Migrations;
namespace StellaOps.Policy.Engine.Storage.Mongo.Internal;
/// <summary>
/// Interface for Policy Engine MongoDB initialization.
/// </summary>
internal interface IPolicyEngineMongoInitializer
{
/// <summary>
/// Ensures all migrations are applied to the database.
/// </summary>
Task EnsureMigrationsAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// Initializes Policy Engine MongoDB storage by applying migrations.
/// </summary>
internal sealed class PolicyEngineMongoInitializer : IPolicyEngineMongoInitializer
{
private readonly PolicyEngineMongoContext _context;
private readonly PolicyEngineMigrationRunner _migrationRunner;
private readonly ILogger<PolicyEngineMongoInitializer> _logger;
public PolicyEngineMongoInitializer(
PolicyEngineMongoContext context,
PolicyEngineMigrationRunner migrationRunner,
ILogger<PolicyEngineMongoInitializer> logger)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_migrationRunner = migrationRunner ?? throw new ArgumentNullException(nameof(migrationRunner));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task EnsureMigrationsAsync(CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"Ensuring Policy Engine Mongo migrations are applied for database {Database}.",
_context.Options.Database);
await _migrationRunner.RunAsync(cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,69 @@
using MongoDB.Driver;
namespace StellaOps.Policy.Engine.Storage.Mongo.Internal;
/// <summary>
/// Builds tenant-scoped filters for Policy Engine MongoDB queries.
/// Ensures all queries are properly scoped to the current tenant.
/// </summary>
internal static class TenantFilterBuilder
{
/// <summary>
/// Creates a filter that matches documents for the specified tenant.
/// </summary>
/// <typeparam name="TDocument">Document type with tenantId field.</typeparam>
/// <param name="tenantId">Tenant identifier (will be normalized to lowercase).</param>
/// <returns>A filter definition scoped to the tenant.</returns>
public static FilterDefinition<TDocument> ForTenant<TDocument>(string tenantId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var normalizedTenantId = tenantId.ToLowerInvariant();
return Builders<TDocument>.Filter.Eq("tenantId", normalizedTenantId);
}
/// <summary>
/// Combines a tenant filter with an additional filter using AND.
/// </summary>
/// <typeparam name="TDocument">Document type with tenantId field.</typeparam>
/// <param name="tenantId">Tenant identifier (will be normalized to lowercase).</param>
/// <param name="additionalFilter">Additional filter to combine.</param>
/// <returns>A combined filter definition.</returns>
public static FilterDefinition<TDocument> ForTenantAnd<TDocument>(
string tenantId,
FilterDefinition<TDocument> additionalFilter)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(additionalFilter);
var tenantFilter = ForTenant<TDocument>(tenantId);
return Builders<TDocument>.Filter.And(tenantFilter, additionalFilter);
}
/// <summary>
/// Creates a filter that matches documents by ID within a tenant scope.
/// </summary>
/// <typeparam name="TDocument">Document type with tenantId and _id fields.</typeparam>
/// <param name="tenantId">Tenant identifier (will be normalized to lowercase).</param>
/// <param name="documentId">Document identifier.</param>
/// <returns>A filter definition matching both tenant and ID.</returns>
public static FilterDefinition<TDocument> ForTenantById<TDocument>(string tenantId, string documentId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(documentId);
var tenantFilter = ForTenant<TDocument>(tenantId);
var idFilter = Builders<TDocument>.Filter.Eq("_id", documentId);
return Builders<TDocument>.Filter.And(tenantFilter, idFilter);
}
/// <summary>
/// Normalizes a tenant ID to lowercase for consistent storage and queries.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <returns>Normalized (lowercase) tenant identifier.</returns>
public static string NormalizeTenantId(string tenantId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
return tenantId.ToLowerInvariant();
}
}

View File

@@ -0,0 +1,283 @@
using Microsoft.Extensions.Logging;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Policy.Engine.Storage.Mongo.Internal;
namespace StellaOps.Policy.Engine.Storage.Mongo.Migrations;
/// <summary>
/// Initializes effective_finding_* and effective_finding_history_* collections for a policy.
/// Creates collections and indexes on-demand when a policy is first evaluated.
/// </summary>
internal interface IEffectiveFindingCollectionInitializer
{
/// <summary>
/// Ensures the effective finding collection and indexes exist for a policy.
/// </summary>
/// <param name="policyId">The policy identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask EnsureCollectionAsync(string policyId, CancellationToken cancellationToken);
}
/// <inheritdoc />
internal sealed class EffectiveFindingCollectionInitializer : IEffectiveFindingCollectionInitializer
{
private readonly PolicyEngineMongoContext _context;
private readonly ILogger<EffectiveFindingCollectionInitializer> _logger;
private readonly HashSet<string> _initializedCollections = new(StringComparer.OrdinalIgnoreCase);
private readonly SemaphoreSlim _lock = new(1, 1);
public EffectiveFindingCollectionInitializer(
PolicyEngineMongoContext context,
ILogger<EffectiveFindingCollectionInitializer> logger)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async ValueTask EnsureCollectionAsync(string policyId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(policyId);
var findingsCollectionName = _context.Options.GetEffectiveFindingsCollectionName(policyId);
var historyCollectionName = _context.Options.GetEffectiveFindingsHistoryCollectionName(policyId);
// Fast path: already initialized in memory
if (_initializedCollections.Contains(findingsCollectionName))
{
return;
}
await _lock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// Double-check after acquiring lock
if (_initializedCollections.Contains(findingsCollectionName))
{
return;
}
await EnsureEffectiveFindingCollectionAsync(findingsCollectionName, cancellationToken).ConfigureAwait(false);
await EnsureEffectiveFindingHistoryCollectionAsync(historyCollectionName, cancellationToken).ConfigureAwait(false);
_initializedCollections.Add(findingsCollectionName);
}
finally
{
_lock.Release();
}
}
private async Task EnsureEffectiveFindingCollectionAsync(string collectionName, CancellationToken cancellationToken)
{
var cursor = await _context.Database
.ListCollectionNamesAsync(cancellationToken: cancellationToken)
.ConfigureAwait(false);
var existing = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
if (!existing.Contains(collectionName, StringComparer.Ordinal))
{
_logger.LogInformation("Creating effective finding collection '{CollectionName}'.", collectionName);
await _context.Database.CreateCollectionAsync(collectionName, cancellationToken: cancellationToken).ConfigureAwait(false);
}
var collection = _context.Database.GetCollection<BsonDocument>(collectionName);
// Unique constraint on (tenantId, componentPurl, advisoryId)
var tenantComponentAdvisory = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("componentPurl")
.Ascending("advisoryId"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_component_advisory_unique",
Unique = true
});
// Tenant + severity for filtering by risk level
var tenantSeverity = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("severity")
.Descending("updatedAt"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_severity_updatedAt_desc"
});
// Tenant + status for filtering by policy status
var tenantStatus = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("status")
.Descending("updatedAt"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_status_updatedAt_desc"
});
// Product key lookup for SBOM-based queries
var tenantProduct = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("productKey"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_product",
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("productKey", true)
});
// SBOM ID lookup
var tenantSbom = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("sbomId"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_sbom",
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("sbomId", true)
});
// Component name lookup for search
var tenantComponentName = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("componentName"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_componentName"
});
// Advisory ID lookup for cross-policy queries
var tenantAdvisory = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("advisoryId"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_advisory"
});
// Policy run reference for traceability
var policyRun = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("policyRunId"),
new CreateIndexOptions<BsonDocument>
{
Name = "policyRun_lookup",
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("policyRunId", true)
});
// Content hash for deduplication checks
var contentHash = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("contentHash"),
new CreateIndexOptions<BsonDocument>
{
Name = "contentHash_lookup"
});
await collection.Indexes.CreateManyAsync(
new[]
{
tenantComponentAdvisory,
tenantSeverity,
tenantStatus,
tenantProduct,
tenantSbom,
tenantComponentName,
tenantAdvisory,
policyRun,
contentHash
},
cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Created indexes for effective finding collection '{CollectionName}'.", collectionName);
}
private async Task EnsureEffectiveFindingHistoryCollectionAsync(string collectionName, CancellationToken cancellationToken)
{
var cursor = await _context.Database
.ListCollectionNamesAsync(cancellationToken: cancellationToken)
.ConfigureAwait(false);
var existing = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
if (!existing.Contains(collectionName, StringComparer.Ordinal))
{
_logger.LogInformation("Creating effective finding history collection '{CollectionName}'.", collectionName);
await _context.Database.CreateCollectionAsync(collectionName, cancellationToken: cancellationToken).ConfigureAwait(false);
}
var collection = _context.Database.GetCollection<BsonDocument>(collectionName);
// Finding + version for retrieving history
var findingVersion = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("findingId")
.Descending("version"),
new CreateIndexOptions<BsonDocument>
{
Name = "finding_version_desc"
});
// Tenant + occurred for chronological history
var tenantOccurred = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Descending("occurredAt"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_occurredAt_desc"
});
// Change type lookup for filtering history events
var tenantChangeType = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("changeType"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_changeType"
});
// Policy run reference
var policyRun = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("policyRunId"),
new CreateIndexOptions<BsonDocument>
{
Name = "policyRun_lookup",
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("policyRunId", true)
});
var models = new List<CreateIndexModel<BsonDocument>>
{
findingVersion,
tenantOccurred,
tenantChangeType,
policyRun
};
// TTL index for automatic cleanup of old history entries
if (_context.Options.EffectiveFindingsHistoryRetention > TimeSpan.Zero)
{
var ttlModel = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys.Ascending("expiresAt"),
new CreateIndexOptions<BsonDocument>
{
Name = "expiresAt_ttl",
ExpireAfter = TimeSpan.Zero
});
models.Add(ttlModel);
}
await collection.Indexes.CreateManyAsync(models, cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Created indexes for effective finding history collection '{CollectionName}'.", collectionName);
}
}

View File

@@ -0,0 +1,345 @@
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Policy.Engine.Storage.Mongo.Internal;
namespace StellaOps.Policy.Engine.Storage.Mongo.Migrations;
/// <summary>
/// Migration to ensure all required indexes exist for exception collections.
/// Creates indexes for efficient tenant-scoped queries and status lookups.
/// </summary>
internal sealed class EnsureExceptionIndexesMigration : IPolicyEngineMongoMigration
{
/// <inheritdoc />
public string Id => "20251128_exception_indexes_v1";
/// <inheritdoc />
public async ValueTask ExecuteAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
await EnsureExceptionsIndexesAsync(context, cancellationToken).ConfigureAwait(false);
await EnsureExceptionReviewsIndexesAsync(context, cancellationToken).ConfigureAwait(false);
await EnsureExceptionBindingsIndexesAsync(context, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Creates indexes for the exceptions collection.
/// </summary>
private static async Task EnsureExceptionsIndexesAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
{
var collection = context.Database.GetCollection<BsonDocument>(context.Options.ExceptionsCollection);
// Tenant + status for finding active/pending exceptions
var tenantStatus = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("status"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_status"
});
// Tenant + type + status for filtering
var tenantTypeStatus = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("exceptionType")
.Ascending("status"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_type_status"
});
// Tenant + created descending for recent exceptions
var tenantCreated = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Descending("createdAt"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_createdAt_desc"
});
// Tenant + tags for filtering by tag
var tenantTags = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("tags"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_tags"
});
// Tenant + expiresAt for finding expiring exceptions
var tenantExpires = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("status")
.Ascending("expiresAt"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_status_expiresAt",
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("expiresAt", true)
});
// Tenant + effectiveFrom for finding pending activations
var tenantEffectiveFrom = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("status")
.Ascending("effectiveFrom"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_status_effectiveFrom",
PartialFilterExpression = Builders<BsonDocument>.Filter.Eq("status", "approved")
});
// Scope advisory IDs for finding applicable exceptions
var scopeAdvisoryIds = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("status")
.Ascending("scope.advisoryIds"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_status_scope_advisoryIds"
});
// Scope asset IDs for finding applicable exceptions
var scopeAssetIds = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("status")
.Ascending("scope.assetIds"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_status_scope_assetIds"
});
// Scope CVE IDs for finding applicable exceptions
var scopeCveIds = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("status")
.Ascending("scope.cveIds"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_status_scope_cveIds"
});
// CreatedBy for audit queries
var tenantCreatedBy = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("createdBy"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_createdBy"
});
// Priority for ordering applicable exceptions
var tenantPriority = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("status")
.Descending("priority"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_status_priority_desc"
});
// Correlation ID for tracing
var correlationId = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("correlationId"),
new CreateIndexOptions<BsonDocument>
{
Name = "correlationId_lookup",
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("correlationId", true)
});
await collection.Indexes.CreateManyAsync(
new[]
{
tenantStatus,
tenantTypeStatus,
tenantCreated,
tenantTags,
tenantExpires,
tenantEffectiveFrom,
scopeAdvisoryIds,
scopeAssetIds,
scopeCveIds,
tenantCreatedBy,
tenantPriority,
correlationId
},
cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Creates indexes for the exception_reviews collection.
/// </summary>
private static async Task EnsureExceptionReviewsIndexesAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
{
var collection = context.Database.GetCollection<BsonDocument>(context.Options.ExceptionReviewsCollection);
// Tenant + exception for finding reviews of an exception
var tenantException = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("exceptionId")
.Descending("requestedAt"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_exceptionId_requestedAt_desc"
});
// Tenant + status for finding pending reviews
var tenantStatus = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("status"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_status"
});
// Tenant + designated reviewers for reviewer's queue
var tenantReviewers = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("status")
.Ascending("designatedReviewers"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_status_designatedReviewers"
});
// Deadline for finding overdue reviews
var tenantDeadline = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("status")
.Ascending("deadline"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_status_deadline",
PartialFilterExpression = Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("status", "pending"),
Builders<BsonDocument>.Filter.Exists("deadline", true))
});
// RequestedBy for audit queries
var tenantRequestedBy = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("requestedBy"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_requestedBy"
});
await collection.Indexes.CreateManyAsync(
new[]
{
tenantException,
tenantStatus,
tenantReviewers,
tenantDeadline,
tenantRequestedBy
},
cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Creates indexes for the exception_bindings collection.
/// </summary>
private static async Task EnsureExceptionBindingsIndexesAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
{
var collection = context.Database.GetCollection<BsonDocument>(context.Options.ExceptionBindingsCollection);
// Tenant + exception for finding bindings of an exception
var tenantException = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("exceptionId"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_exceptionId"
});
// Tenant + asset for finding bindings for an asset
var tenantAsset = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("assetId")
.Ascending("status"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_assetId_status"
});
// Tenant + advisory for finding bindings by advisory
var tenantAdvisory = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("advisoryId")
.Ascending("status"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_advisoryId_status",
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("advisoryId", true)
});
// Tenant + CVE for finding bindings by CVE
var tenantCve = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("cveId")
.Ascending("status"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_cveId_status",
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("cveId", true)
});
// Tenant + status + expiresAt for finding expired bindings
var tenantExpires = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("status")
.Ascending("expiresAt"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_status_expiresAt",
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("expiresAt", true)
});
// Effective time range for finding active bindings at a point in time
var tenantEffectiveRange = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("assetId")
.Ascending("status")
.Ascending("effectiveFrom")
.Ascending("expiresAt"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_asset_status_effectiveRange"
});
await collection.Indexes.CreateManyAsync(
new[]
{
tenantException,
tenantAsset,
tenantAdvisory,
tenantCve,
tenantExpires,
tenantEffectiveRange
},
cancellationToken: cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,54 @@
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using StellaOps.Policy.Engine.Storage.Mongo.Internal;
namespace StellaOps.Policy.Engine.Storage.Mongo.Migrations;
/// <summary>
/// Migration to ensure all required Policy Engine collections exist.
/// Creates: policies, policy_revisions, policy_bundles, policy_runs, policy_audit, _policy_migrations
/// Note: effective_finding_* and effective_finding_history_* collections are created dynamically per-policy.
/// </summary>
internal sealed class EnsurePolicyCollectionsMigration : IPolicyEngineMongoMigration
{
private readonly ILogger<EnsurePolicyCollectionsMigration> _logger;
public EnsurePolicyCollectionsMigration(ILogger<EnsurePolicyCollectionsMigration> logger)
=> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
/// <inheritdoc />
public string Id => "20251128_policy_collections_v1";
/// <inheritdoc />
public async ValueTask ExecuteAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var requiredCollections = new[]
{
context.Options.PoliciesCollection,
context.Options.PolicyRevisionsCollection,
context.Options.PolicyBundlesCollection,
context.Options.PolicyRunsCollection,
context.Options.AuditCollection,
context.Options.MigrationsCollection
};
var cursor = await context.Database
.ListCollectionNamesAsync(cancellationToken: cancellationToken)
.ConfigureAwait(false);
var existing = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
foreach (var collection in requiredCollections)
{
if (existing.Contains(collection, StringComparer.Ordinal))
{
continue;
}
_logger.LogInformation("Creating Policy Engine Mongo collection '{CollectionName}'.", collection);
await context.Database.CreateCollectionAsync(collection, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,312 @@
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Policy.Engine.Storage.Mongo.Internal;
namespace StellaOps.Policy.Engine.Storage.Mongo.Migrations;
/// <summary>
/// Migration to ensure all required indexes exist for Policy Engine collections.
/// Creates indexes for efficient tenant-scoped queries and TTL cleanup.
/// </summary>
internal sealed class EnsurePolicyIndexesMigration : IPolicyEngineMongoMigration
{
/// <inheritdoc />
public string Id => "20251128_policy_indexes_v1";
/// <inheritdoc />
public async ValueTask ExecuteAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
await EnsurePoliciesIndexesAsync(context, cancellationToken).ConfigureAwait(false);
await EnsurePolicyRevisionsIndexesAsync(context, cancellationToken).ConfigureAwait(false);
await EnsurePolicyBundlesIndexesAsync(context, cancellationToken).ConfigureAwait(false);
await EnsurePolicyRunsIndexesAsync(context, cancellationToken).ConfigureAwait(false);
await EnsureAuditIndexesAsync(context, cancellationToken).ConfigureAwait(false);
await EnsureExplainsIndexesAsync(context, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Creates indexes for the policies collection.
/// </summary>
private static async Task EnsurePoliciesIndexesAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
{
var collection = context.Database.GetCollection<BsonDocument>(context.Options.PoliciesCollection);
// Tenant lookup with optional tag filtering
var tenantTags = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("tags"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_tags"
});
// Tenant + updated for recent changes
var tenantUpdated = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Descending("updatedAt"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_updatedAt_desc"
});
await collection.Indexes.CreateManyAsync(new[] { tenantTags, tenantUpdated }, cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
/// <summary>
/// Creates indexes for the policy_revisions collection.
/// </summary>
private static async Task EnsurePolicyRevisionsIndexesAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
{
var collection = context.Database.GetCollection<BsonDocument>(context.Options.PolicyRevisionsCollection);
// Tenant + pack for finding revisions of a policy
var tenantPack = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("packId")
.Descending("version"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_pack_version_desc"
});
// Status lookup for finding active/draft revisions
var tenantStatus = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("status"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_status"
});
// Bundle digest lookup for integrity verification
var bundleDigest = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("bundleDigest"),
new CreateIndexOptions<BsonDocument>
{
Name = "bundleDigest_lookup",
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("bundleDigest", true)
});
await collection.Indexes.CreateManyAsync(new[] { tenantPack, tenantStatus, bundleDigest }, cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
/// <summary>
/// Creates indexes for the policy_bundles collection.
/// </summary>
private static async Task EnsurePolicyBundlesIndexesAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
{
var collection = context.Database.GetCollection<BsonDocument>(context.Options.PolicyBundlesCollection);
// Tenant + pack + version for finding specific bundles
var tenantPackVersion = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("packId")
.Ascending("version"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_pack_version",
Unique = true
});
await collection.Indexes.CreateManyAsync(new[] { tenantPackVersion }, cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
/// <summary>
/// Creates indexes for the policy_runs collection.
/// </summary>
private static async Task EnsurePolicyRunsIndexesAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
{
var collection = context.Database.GetCollection<BsonDocument>(context.Options.PolicyRunsCollection);
// Tenant + policy + started for recent runs
var tenantPolicyStarted = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("policyId")
.Descending("startedAt"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_policy_startedAt_desc"
});
// Status lookup for finding pending/running evaluations
var tenantStatus = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("status"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_status"
});
// Correlation ID lookup for tracing
var correlationId = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("correlationId"),
new CreateIndexOptions<BsonDocument>
{
Name = "correlationId_lookup",
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("correlationId", true)
});
// Trace ID lookup for OpenTelemetry
var traceId = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("traceId"),
new CreateIndexOptions<BsonDocument>
{
Name = "traceId_lookup",
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("traceId", true)
});
var models = new List<CreateIndexModel<BsonDocument>>
{
tenantPolicyStarted,
tenantStatus,
correlationId,
traceId
};
// TTL index for automatic cleanup of completed runs
if (context.Options.PolicyRunRetention > TimeSpan.Zero)
{
var ttlModel = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys.Ascending("expiresAt"),
new CreateIndexOptions<BsonDocument>
{
Name = "expiresAt_ttl",
ExpireAfter = TimeSpan.Zero
});
models.Add(ttlModel);
}
await collection.Indexes.CreateManyAsync(models, cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Creates indexes for the policy_audit collection.
/// </summary>
private static async Task EnsureAuditIndexesAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
{
var collection = context.Database.GetCollection<BsonDocument>(context.Options.AuditCollection);
// Tenant + occurred for chronological audit trail
var tenantOccurred = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Descending("occurredAt"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_occurredAt_desc"
});
// Actor lookup for finding actions by user
var tenantActor = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("actorId"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_actor"
});
// Resource lookup for finding actions on specific policy
var tenantResource = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("resourceType")
.Ascending("resourceId"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_resource"
});
await collection.Indexes.CreateManyAsync(new[] { tenantOccurred, tenantActor, tenantResource }, cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
/// <summary>
/// Creates indexes for the policy_explains collection.
/// </summary>
private static async Task EnsureExplainsIndexesAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
{
var collection = context.Database.GetCollection<BsonDocument>(context.Options.PolicyExplainsCollection);
// Tenant + run for finding all explains in a run
var tenantRun = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("runId"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_runId"
});
// Tenant + policy + evaluated time for recent explains
var tenantPolicyEvaluated = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("policyId")
.Descending("evaluatedAt"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_policy_evaluatedAt_desc"
});
// Subject hash lookup for decision linkage
var subjectHash = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("subjectHash"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_subjectHash"
});
// AOC chain lookup for attestation queries
var aocCompilation = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("aocChain.compilationId"),
new CreateIndexOptions<BsonDocument>
{
Name = "aocChain_compilationId",
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("aocChain.compilationId", true)
});
var models = new List<CreateIndexModel<BsonDocument>>
{
tenantRun,
tenantPolicyEvaluated,
subjectHash,
aocCompilation
};
// TTL index for automatic cleanup
if (context.Options.ExplainTraceRetention > TimeSpan.Zero)
{
var ttlModel = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys.Ascending("expiresAt"),
new CreateIndexOptions<BsonDocument>
{
Name = "expiresAt_ttl",
ExpireAfter = TimeSpan.Zero
});
models.Add(ttlModel);
}
await collection.Indexes.CreateManyAsync(models, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,23 @@
using StellaOps.Policy.Engine.Storage.Mongo.Internal;
namespace StellaOps.Policy.Engine.Storage.Mongo.Migrations;
/// <summary>
/// Interface for Policy Engine MongoDB migrations.
/// Migrations are applied in lexical order by Id and tracked to ensure idempotency.
/// </summary>
internal interface IPolicyEngineMongoMigration
{
/// <summary>
/// Unique migration identifier.
/// Format: YYYYMMDD_description_vN (e.g., "20251128_policy_collections_v1")
/// </summary>
string Id { get; }
/// <summary>
/// Executes the migration against the Policy Engine database.
/// </summary>
/// <param name="context">MongoDB context with database access.</param>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask ExecuteAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,30 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Policy.Engine.Storage.Mongo.Migrations;
/// <summary>
/// MongoDB document for tracking applied migrations.
/// Collection: _policy_migrations
/// </summary>
[BsonIgnoreExtraElements]
internal sealed class PolicyEngineMigrationRecord
{
/// <summary>
/// MongoDB ObjectId.
/// </summary>
[BsonId]
public ObjectId Id { get; set; }
/// <summary>
/// Unique migration identifier (matches IPolicyEngineMongoMigration.Id).
/// </summary>
[BsonElement("migrationId")]
public string MigrationId { get; set; } = string.Empty;
/// <summary>
/// When the migration was applied.
/// </summary>
[BsonElement("appliedAt")]
public DateTimeOffset AppliedAt { get; set; }
}

View File

@@ -0,0 +1,85 @@
using Microsoft.Extensions.Logging;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Policy.Engine.Storage.Mongo.Internal;
namespace StellaOps.Policy.Engine.Storage.Mongo.Migrations;
/// <summary>
/// Executes Policy Engine MongoDB migrations in order.
/// Tracks applied migrations to ensure idempotency.
/// </summary>
internal sealed class PolicyEngineMigrationRunner
{
private readonly PolicyEngineMongoContext _context;
private readonly IReadOnlyList<IPolicyEngineMongoMigration> _migrations;
private readonly ILogger<PolicyEngineMigrationRunner> _logger;
public PolicyEngineMigrationRunner(
PolicyEngineMongoContext context,
IEnumerable<IPolicyEngineMongoMigration> migrations,
ILogger<PolicyEngineMigrationRunner> logger)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
ArgumentNullException.ThrowIfNull(migrations);
_migrations = migrations.OrderBy(m => m.Id, StringComparer.Ordinal).ToArray();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Runs all pending migrations.
/// </summary>
public async ValueTask RunAsync(CancellationToken cancellationToken)
{
if (_migrations.Count == 0)
{
return;
}
var collection = _context.Database.GetCollection<PolicyEngineMigrationRecord>(_context.Options.MigrationsCollection);
await EnsureMigrationIndexAsync(collection, cancellationToken).ConfigureAwait(false);
var applied = await collection
.Find(FilterDefinition<PolicyEngineMigrationRecord>.Empty)
.Project(record => record.MigrationId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var appliedSet = applied.ToHashSet(StringComparer.Ordinal);
foreach (var migration in _migrations)
{
if (appliedSet.Contains(migration.Id))
{
continue;
}
_logger.LogInformation("Applying Policy Engine Mongo migration {MigrationId}.", migration.Id);
await migration.ExecuteAsync(_context, cancellationToken).ConfigureAwait(false);
var record = new PolicyEngineMigrationRecord
{
Id = ObjectId.GenerateNewId(),
MigrationId = migration.Id,
AppliedAt = DateTimeOffset.UtcNow
};
await collection.InsertOneAsync(record, cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Completed Policy Engine Mongo migration {MigrationId}.", migration.Id);
}
}
private static async Task EnsureMigrationIndexAsync(
IMongoCollection<PolicyEngineMigrationRecord> collection,
CancellationToken cancellationToken)
{
var keys = Builders<PolicyEngineMigrationRecord>.IndexKeys.Ascending(record => record.MigrationId);
var model = new CreateIndexModel<PolicyEngineMigrationRecord>(keys, new CreateIndexOptions
{
Name = "migrationId_unique",
Unique = true
});
await collection.Indexes.CreateOneAsync(model, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,140 @@
namespace StellaOps.Policy.Engine.Storage.Mongo.Options;
/// <summary>
/// Configures MongoDB connectivity and collection names for Policy Engine storage.
/// </summary>
public sealed class PolicyEngineMongoOptions
{
/// <summary>
/// MongoDB connection string.
/// </summary>
public string ConnectionString { get; set; } = "mongodb://localhost:27017";
/// <summary>
/// Database name for policy storage.
/// </summary>
public string Database { get; set; } = "stellaops_policy";
/// <summary>
/// Collection name for policy packs.
/// </summary>
public string PoliciesCollection { get; set; } = "policies";
/// <summary>
/// Collection name for policy revisions.
/// </summary>
public string PolicyRevisionsCollection { get; set; } = "policy_revisions";
/// <summary>
/// Collection name for policy bundles (compiled artifacts).
/// </summary>
public string PolicyBundlesCollection { get; set; } = "policy_bundles";
/// <summary>
/// Collection name for policy evaluation runs.
/// </summary>
public string PolicyRunsCollection { get; set; } = "policy_runs";
/// <summary>
/// Collection prefix for effective findings (per-policy tenant-scoped).
/// Final collection name: {prefix}_{policyId}
/// </summary>
public string EffectiveFindingsCollectionPrefix { get; set; } = "effective_finding";
/// <summary>
/// Collection prefix for effective findings history (append-only).
/// Final collection name: {prefix}_{policyId}
/// </summary>
public string EffectiveFindingsHistoryCollectionPrefix { get; set; } = "effective_finding_history";
/// <summary>
/// Collection name for policy audit log.
/// </summary>
public string AuditCollection { get; set; } = "policy_audit";
/// <summary>
/// Collection name for policy explain traces.
/// </summary>
public string PolicyExplainsCollection { get; set; } = "policy_explains";
/// <summary>
/// Collection name for policy exceptions.
/// </summary>
public string ExceptionsCollection { get; set; } = "exceptions";
/// <summary>
/// Collection name for exception reviews.
/// </summary>
public string ExceptionReviewsCollection { get; set; } = "exception_reviews";
/// <summary>
/// Collection name for exception bindings.
/// </summary>
public string ExceptionBindingsCollection { get; set; } = "exception_bindings";
/// <summary>
/// Collection name for tracking applied migrations.
/// </summary>
public string MigrationsCollection { get; set; } = "_policy_migrations";
/// <summary>
/// TTL for completed policy runs. Zero or negative disables TTL.
/// </summary>
public TimeSpan PolicyRunRetention { get; set; } = TimeSpan.FromDays(90);
/// <summary>
/// TTL for effective findings history entries. Zero or negative disables TTL.
/// </summary>
public TimeSpan EffectiveFindingsHistoryRetention { get; set; } = TimeSpan.FromDays(365);
/// <summary>
/// TTL for explain traces. Zero or negative disables TTL.
/// </summary>
public TimeSpan ExplainTraceRetention { get; set; } = TimeSpan.FromDays(30);
/// <summary>
/// Use majority read concern for consistency.
/// </summary>
public bool UseMajorityReadConcern { get; set; } = true;
/// <summary>
/// Use majority write concern for durability.
/// </summary>
public bool UseMajorityWriteConcern { get; set; } = true;
/// <summary>
/// Command timeout in seconds.
/// </summary>
public int CommandTimeoutSeconds { get; set; } = 30;
/// <summary>
/// Gets the effective findings collection name for a policy.
/// </summary>
public string GetEffectiveFindingsCollectionName(string policyId)
{
var safePolicyId = SanitizeCollectionName(policyId);
return $"{EffectiveFindingsCollectionPrefix}_{safePolicyId}";
}
/// <summary>
/// Gets the effective findings history collection name for a policy.
/// </summary>
public string GetEffectiveFindingsHistoryCollectionName(string policyId)
{
var safePolicyId = SanitizeCollectionName(policyId);
return $"{EffectiveFindingsHistoryCollectionPrefix}_{safePolicyId}";
}
private static string SanitizeCollectionName(string name)
{
// Replace invalid characters with underscores
return string.Create(name.Length, name, (span, source) =>
{
for (int i = 0; i < source.Length; i++)
{
var c = source[i];
span[i] = char.IsLetterOrDigit(c) || c == '_' || c == '-' ? c : '_';
}
}).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,254 @@
using System.Collections.Immutable;
using StellaOps.Policy.Engine.Storage.Mongo.Documents;
namespace StellaOps.Policy.Engine.Storage.Mongo.Repositories;
/// <summary>
/// Repository interface for policy exception operations.
/// </summary>
internal interface IExceptionRepository
{
// Exception operations
/// <summary>
/// Creates a new exception.
/// </summary>
Task<PolicyExceptionDocument> CreateExceptionAsync(
PolicyExceptionDocument exception,
CancellationToken cancellationToken);
/// <summary>
/// Gets an exception by ID.
/// </summary>
Task<PolicyExceptionDocument?> GetExceptionAsync(
string tenantId,
string exceptionId,
CancellationToken cancellationToken);
/// <summary>
/// Updates an existing exception.
/// </summary>
Task<PolicyExceptionDocument?> UpdateExceptionAsync(
PolicyExceptionDocument exception,
CancellationToken cancellationToken);
/// <summary>
/// Lists exceptions with filtering and pagination.
/// </summary>
Task<ImmutableArray<PolicyExceptionDocument>> ListExceptionsAsync(
string tenantId,
ExceptionQueryOptions options,
CancellationToken cancellationToken);
/// <summary>
/// Finds active exceptions that apply to a specific asset/advisory.
/// </summary>
Task<ImmutableArray<PolicyExceptionDocument>> FindApplicableExceptionsAsync(
string tenantId,
string assetId,
string? advisoryId,
DateTimeOffset evaluationTime,
CancellationToken cancellationToken);
/// <summary>
/// Updates exception status.
/// </summary>
Task<bool> UpdateExceptionStatusAsync(
string tenantId,
string exceptionId,
string newStatus,
DateTimeOffset timestamp,
CancellationToken cancellationToken);
/// <summary>
/// Revokes an exception.
/// </summary>
Task<bool> RevokeExceptionAsync(
string tenantId,
string exceptionId,
string revokedBy,
string? reason,
DateTimeOffset timestamp,
CancellationToken cancellationToken);
/// <summary>
/// Gets exceptions expiring within a time window.
/// </summary>
Task<ImmutableArray<PolicyExceptionDocument>> GetExpiringExceptionsAsync(
string tenantId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken cancellationToken);
/// <summary>
/// Gets exceptions that should be auto-activated.
/// </summary>
Task<ImmutableArray<PolicyExceptionDocument>> GetPendingActivationsAsync(
string tenantId,
DateTimeOffset asOf,
CancellationToken cancellationToken);
// Review operations
/// <summary>
/// Creates a new review for an exception.
/// </summary>
Task<ExceptionReviewDocument> CreateReviewAsync(
ExceptionReviewDocument review,
CancellationToken cancellationToken);
/// <summary>
/// Gets a review by ID.
/// </summary>
Task<ExceptionReviewDocument?> GetReviewAsync(
string tenantId,
string reviewId,
CancellationToken cancellationToken);
/// <summary>
/// Adds a decision to a review.
/// </summary>
Task<ExceptionReviewDocument?> AddReviewDecisionAsync(
string tenantId,
string reviewId,
ReviewDecisionDocument decision,
CancellationToken cancellationToken);
/// <summary>
/// Completes a review with final status.
/// </summary>
Task<ExceptionReviewDocument?> CompleteReviewAsync(
string tenantId,
string reviewId,
string finalStatus,
DateTimeOffset completedAt,
CancellationToken cancellationToken);
/// <summary>
/// Gets reviews for an exception.
/// </summary>
Task<ImmutableArray<ExceptionReviewDocument>> GetReviewsForExceptionAsync(
string tenantId,
string exceptionId,
CancellationToken cancellationToken);
/// <summary>
/// Gets pending reviews for a reviewer.
/// </summary>
Task<ImmutableArray<ExceptionReviewDocument>> GetPendingReviewsAsync(
string tenantId,
string? reviewerId,
CancellationToken cancellationToken);
// Binding operations
/// <summary>
/// Creates or updates a binding.
/// </summary>
Task<ExceptionBindingDocument> UpsertBindingAsync(
ExceptionBindingDocument binding,
CancellationToken cancellationToken);
/// <summary>
/// Gets bindings for an exception.
/// </summary>
Task<ImmutableArray<ExceptionBindingDocument>> GetBindingsForExceptionAsync(
string tenantId,
string exceptionId,
CancellationToken cancellationToken);
/// <summary>
/// Gets active bindings for an asset.
/// </summary>
Task<ImmutableArray<ExceptionBindingDocument>> GetActiveBindingsForAssetAsync(
string tenantId,
string assetId,
DateTimeOffset asOf,
CancellationToken cancellationToken);
/// <summary>
/// Deletes bindings for an exception.
/// </summary>
Task<long> DeleteBindingsForExceptionAsync(
string tenantId,
string exceptionId,
CancellationToken cancellationToken);
/// <summary>
/// Updates binding status.
/// </summary>
Task<bool> UpdateBindingStatusAsync(
string tenantId,
string bindingId,
string newStatus,
CancellationToken cancellationToken);
/// <summary>
/// Gets expired bindings for cleanup.
/// </summary>
Task<ImmutableArray<ExceptionBindingDocument>> GetExpiredBindingsAsync(
string tenantId,
DateTimeOffset asOf,
int limit,
CancellationToken cancellationToken);
// Statistics
/// <summary>
/// Gets exception counts by status.
/// </summary>
Task<IReadOnlyDictionary<string, int>> GetExceptionCountsByStatusAsync(
string tenantId,
CancellationToken cancellationToken);
}
/// <summary>
/// Query options for listing exceptions.
/// </summary>
public sealed record ExceptionQueryOptions
{
/// <summary>
/// Filter by status.
/// </summary>
public ImmutableArray<string> Statuses { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Filter by exception type.
/// </summary>
public ImmutableArray<string> Types { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Filter by tag.
/// </summary>
public ImmutableArray<string> Tags { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Filter by creator.
/// </summary>
public string? CreatedBy { get; init; }
/// <summary>
/// Include expired exceptions.
/// </summary>
public bool IncludeExpired { get; init; }
/// <summary>
/// Skip count for pagination.
/// </summary>
public int Skip { get; init; }
/// <summary>
/// Limit for pagination (default 100).
/// </summary>
public int Limit { get; init; } = 100;
/// <summary>
/// Sort field.
/// </summary>
public string SortBy { get; init; } = "createdAt";
/// <summary>
/// Sort direction (asc or desc).
/// </summary>
public string SortDirection { get; init; } = "desc";
}

View File

@@ -0,0 +1,611 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Policy.Engine.Storage.Mongo.Documents;
using StellaOps.Policy.Engine.Storage.Mongo.Options;
using StellaOps.Policy.Engine.Telemetry;
namespace StellaOps.Policy.Engine.Storage.Mongo.Repositories;
/// <summary>
/// MongoDB implementation of the exception repository.
/// </summary>
internal sealed class MongoExceptionRepository : IExceptionRepository
{
private readonly IMongoDatabase _database;
private readonly PolicyEngineMongoOptions _options;
private readonly ILogger<MongoExceptionRepository> _logger;
public MongoExceptionRepository(
IMongoClient mongoClient,
IOptions<PolicyEngineMongoOptions> options,
ILogger<MongoExceptionRepository> logger)
{
ArgumentNullException.ThrowIfNull(mongoClient);
ArgumentNullException.ThrowIfNull(options);
_options = options.Value;
_database = mongoClient.GetDatabase(_options.Database);
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
private IMongoCollection<PolicyExceptionDocument> Exceptions
=> _database.GetCollection<PolicyExceptionDocument>(_options.ExceptionsCollection);
private IMongoCollection<ExceptionReviewDocument> Reviews
=> _database.GetCollection<ExceptionReviewDocument>(_options.ExceptionReviewsCollection);
private IMongoCollection<ExceptionBindingDocument> Bindings
=> _database.GetCollection<ExceptionBindingDocument>(_options.ExceptionBindingsCollection);
#region Exception Operations
public async Task<PolicyExceptionDocument> CreateExceptionAsync(
PolicyExceptionDocument exception,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(exception);
exception.TenantId = exception.TenantId.ToLowerInvariant();
await Exceptions.InsertOneAsync(exception, cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Created exception {ExceptionId} for tenant {TenantId}",
exception.Id, exception.TenantId);
PolicyEngineTelemetry.RecordExceptionOperation(exception.TenantId, "create");
return exception;
}
public async Task<PolicyExceptionDocument?> GetExceptionAsync(
string tenantId,
string exceptionId,
CancellationToken cancellationToken)
{
var filter = Builders<PolicyExceptionDocument>.Filter.And(
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.TenantId, tenantId.ToLowerInvariant()),
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.Id, exceptionId));
return await Exceptions.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<PolicyExceptionDocument?> UpdateExceptionAsync(
PolicyExceptionDocument exception,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(exception);
var filter = Builders<PolicyExceptionDocument>.Filter.And(
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.TenantId, exception.TenantId.ToLowerInvariant()),
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.Id, exception.Id));
var result = await Exceptions.ReplaceOneAsync(filter, exception, cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (result.ModifiedCount > 0)
{
_logger.LogInformation(
"Updated exception {ExceptionId} for tenant {TenantId}",
exception.Id, exception.TenantId);
PolicyEngineTelemetry.RecordExceptionOperation(exception.TenantId, "update");
return exception;
}
return null;
}
public async Task<ImmutableArray<PolicyExceptionDocument>> ListExceptionsAsync(
string tenantId,
ExceptionQueryOptions options,
CancellationToken cancellationToken)
{
var filterBuilder = Builders<PolicyExceptionDocument>.Filter;
var filters = new List<FilterDefinition<PolicyExceptionDocument>>
{
filterBuilder.Eq(e => e.TenantId, tenantId.ToLowerInvariant())
};
if (options.Statuses.Length > 0)
{
filters.Add(filterBuilder.In(e => e.Status, options.Statuses));
}
if (options.Types.Length > 0)
{
filters.Add(filterBuilder.In(e => e.ExceptionType, options.Types));
}
if (options.Tags.Length > 0)
{
filters.Add(filterBuilder.AnyIn(e => e.Tags, options.Tags));
}
if (!string.IsNullOrEmpty(options.CreatedBy))
{
filters.Add(filterBuilder.Eq(e => e.CreatedBy, options.CreatedBy));
}
if (!options.IncludeExpired)
{
var now = DateTimeOffset.UtcNow;
filters.Add(filterBuilder.Or(
filterBuilder.Eq(e => e.ExpiresAt, null),
filterBuilder.Gt(e => e.ExpiresAt, now)));
}
var filter = filterBuilder.And(filters);
var sort = options.SortDirection.Equals("asc", StringComparison.OrdinalIgnoreCase)
? Builders<PolicyExceptionDocument>.Sort.Ascending(options.SortBy)
: Builders<PolicyExceptionDocument>.Sort.Descending(options.SortBy);
var results = await Exceptions
.Find(filter)
.Sort(sort)
.Skip(options.Skip)
.Limit(options.Limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return results.ToImmutableArray();
}
public async Task<ImmutableArray<PolicyExceptionDocument>> FindApplicableExceptionsAsync(
string tenantId,
string assetId,
string? advisoryId,
DateTimeOffset evaluationTime,
CancellationToken cancellationToken)
{
var filterBuilder = Builders<PolicyExceptionDocument>.Filter;
var filters = new List<FilterDefinition<PolicyExceptionDocument>>
{
filterBuilder.Eq(e => e.TenantId, tenantId.ToLowerInvariant()),
filterBuilder.Eq(e => e.Status, "active"),
filterBuilder.Or(
filterBuilder.Eq(e => e.EffectiveFrom, null),
filterBuilder.Lte(e => e.EffectiveFrom, evaluationTime)),
filterBuilder.Or(
filterBuilder.Eq(e => e.ExpiresAt, null),
filterBuilder.Gt(e => e.ExpiresAt, evaluationTime))
};
// Scope matching - must match at least one criterion
var scopeFilters = new List<FilterDefinition<PolicyExceptionDocument>>
{
filterBuilder.Eq("scope.applyToAll", true),
filterBuilder.AnyEq("scope.assetIds", assetId)
};
// Add PURL pattern matching (simplified - would need regex in production)
scopeFilters.Add(filterBuilder.Not(filterBuilder.Size("scope.purlPatterns", 0)));
if (!string.IsNullOrEmpty(advisoryId))
{
scopeFilters.Add(filterBuilder.AnyEq("scope.advisoryIds", advisoryId));
}
filters.Add(filterBuilder.Or(scopeFilters));
var filter = filterBuilder.And(filters);
var results = await Exceptions
.Find(filter)
.Sort(Builders<PolicyExceptionDocument>.Sort.Descending(e => e.Priority))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return results.ToImmutableArray();
}
public async Task<bool> UpdateExceptionStatusAsync(
string tenantId,
string exceptionId,
string newStatus,
DateTimeOffset timestamp,
CancellationToken cancellationToken)
{
var filter = Builders<PolicyExceptionDocument>.Filter.And(
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.TenantId, tenantId.ToLowerInvariant()),
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.Id, exceptionId));
var updateBuilder = Builders<PolicyExceptionDocument>.Update;
var updates = new List<UpdateDefinition<PolicyExceptionDocument>>
{
updateBuilder.Set(e => e.Status, newStatus),
updateBuilder.Set(e => e.UpdatedAt, timestamp)
};
if (newStatus == "active")
{
updates.Add(updateBuilder.Set(e => e.ActivatedAt, timestamp));
}
var update = updateBuilder.Combine(updates);
var result = await Exceptions.UpdateOneAsync(filter, update, cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (result.ModifiedCount > 0)
{
_logger.LogInformation(
"Updated exception {ExceptionId} status to {Status} for tenant {TenantId}",
exceptionId, newStatus, tenantId);
PolicyEngineTelemetry.RecordExceptionOperation(tenantId, $"status_{newStatus}");
}
return result.ModifiedCount > 0;
}
public async Task<bool> RevokeExceptionAsync(
string tenantId,
string exceptionId,
string revokedBy,
string? reason,
DateTimeOffset timestamp,
CancellationToken cancellationToken)
{
var filter = Builders<PolicyExceptionDocument>.Filter.And(
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.TenantId, tenantId.ToLowerInvariant()),
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.Id, exceptionId));
var update = Builders<PolicyExceptionDocument>.Update
.Set(e => e.Status, "revoked")
.Set(e => e.RevokedAt, timestamp)
.Set(e => e.RevokedBy, revokedBy)
.Set(e => e.RevocationReason, reason)
.Set(e => e.UpdatedAt, timestamp);
var result = await Exceptions.UpdateOneAsync(filter, update, cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (result.ModifiedCount > 0)
{
_logger.LogInformation(
"Revoked exception {ExceptionId} by {RevokedBy} for tenant {TenantId}",
exceptionId, revokedBy, tenantId);
PolicyEngineTelemetry.RecordExceptionOperation(tenantId, "revoke");
}
return result.ModifiedCount > 0;
}
public async Task<ImmutableArray<PolicyExceptionDocument>> GetExpiringExceptionsAsync(
string tenantId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken cancellationToken)
{
var filter = Builders<PolicyExceptionDocument>.Filter.And(
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.TenantId, tenantId.ToLowerInvariant()),
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.Status, "active"),
Builders<PolicyExceptionDocument>.Filter.Gte(e => e.ExpiresAt, from),
Builders<PolicyExceptionDocument>.Filter.Lte(e => e.ExpiresAt, to));
var results = await Exceptions
.Find(filter)
.Sort(Builders<PolicyExceptionDocument>.Sort.Ascending(e => e.ExpiresAt))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return results.ToImmutableArray();
}
public async Task<ImmutableArray<PolicyExceptionDocument>> GetPendingActivationsAsync(
string tenantId,
DateTimeOffset asOf,
CancellationToken cancellationToken)
{
var filter = Builders<PolicyExceptionDocument>.Filter.And(
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.TenantId, tenantId.ToLowerInvariant()),
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.Status, "approved"),
Builders<PolicyExceptionDocument>.Filter.Lte(e => e.EffectiveFrom, asOf));
var results = await Exceptions
.Find(filter)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return results.ToImmutableArray();
}
#endregion
#region Review Operations
public async Task<ExceptionReviewDocument> CreateReviewAsync(
ExceptionReviewDocument review,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(review);
review.TenantId = review.TenantId.ToLowerInvariant();
await Reviews.InsertOneAsync(review, cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Created review {ReviewId} for exception {ExceptionId}, tenant {TenantId}",
review.Id, review.ExceptionId, review.TenantId);
PolicyEngineTelemetry.RecordExceptionOperation(review.TenantId, "review_create");
return review;
}
public async Task<ExceptionReviewDocument?> GetReviewAsync(
string tenantId,
string reviewId,
CancellationToken cancellationToken)
{
var filter = Builders<ExceptionReviewDocument>.Filter.And(
Builders<ExceptionReviewDocument>.Filter.Eq(r => r.TenantId, tenantId.ToLowerInvariant()),
Builders<ExceptionReviewDocument>.Filter.Eq(r => r.Id, reviewId));
return await Reviews.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<ExceptionReviewDocument?> AddReviewDecisionAsync(
string tenantId,
string reviewId,
ReviewDecisionDocument decision,
CancellationToken cancellationToken)
{
var filter = Builders<ExceptionReviewDocument>.Filter.And(
Builders<ExceptionReviewDocument>.Filter.Eq(r => r.TenantId, tenantId.ToLowerInvariant()),
Builders<ExceptionReviewDocument>.Filter.Eq(r => r.Id, reviewId),
Builders<ExceptionReviewDocument>.Filter.Eq(r => r.Status, "pending"));
var update = Builders<ExceptionReviewDocument>.Update
.Push(r => r.Decisions, decision);
var options = new FindOneAndUpdateOptions<ExceptionReviewDocument>
{
ReturnDocument = ReturnDocument.After
};
var result = await Reviews.FindOneAndUpdateAsync(filter, update, options, cancellationToken)
.ConfigureAwait(false);
if (result is not null)
{
_logger.LogInformation(
"Added decision from {ReviewerId} to review {ReviewId} for tenant {TenantId}",
decision.ReviewerId, reviewId, tenantId);
PolicyEngineTelemetry.RecordExceptionOperation(tenantId, $"review_decision_{decision.Decision}");
}
return result;
}
public async Task<ExceptionReviewDocument?> CompleteReviewAsync(
string tenantId,
string reviewId,
string finalStatus,
DateTimeOffset completedAt,
CancellationToken cancellationToken)
{
var filter = Builders<ExceptionReviewDocument>.Filter.And(
Builders<ExceptionReviewDocument>.Filter.Eq(r => r.TenantId, tenantId.ToLowerInvariant()),
Builders<ExceptionReviewDocument>.Filter.Eq(r => r.Id, reviewId));
var update = Builders<ExceptionReviewDocument>.Update
.Set(r => r.Status, finalStatus)
.Set(r => r.CompletedAt, completedAt);
var options = new FindOneAndUpdateOptions<ExceptionReviewDocument>
{
ReturnDocument = ReturnDocument.After
};
var result = await Reviews.FindOneAndUpdateAsync(filter, update, options, cancellationToken)
.ConfigureAwait(false);
if (result is not null)
{
_logger.LogInformation(
"Completed review {ReviewId} with status {Status} for tenant {TenantId}",
reviewId, finalStatus, tenantId);
PolicyEngineTelemetry.RecordExceptionOperation(tenantId, $"review_complete_{finalStatus}");
}
return result;
}
public async Task<ImmutableArray<ExceptionReviewDocument>> GetReviewsForExceptionAsync(
string tenantId,
string exceptionId,
CancellationToken cancellationToken)
{
var filter = Builders<ExceptionReviewDocument>.Filter.And(
Builders<ExceptionReviewDocument>.Filter.Eq(r => r.TenantId, tenantId.ToLowerInvariant()),
Builders<ExceptionReviewDocument>.Filter.Eq(r => r.ExceptionId, exceptionId));
var results = await Reviews
.Find(filter)
.Sort(Builders<ExceptionReviewDocument>.Sort.Descending(r => r.RequestedAt))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return results.ToImmutableArray();
}
public async Task<ImmutableArray<ExceptionReviewDocument>> GetPendingReviewsAsync(
string tenantId,
string? reviewerId,
CancellationToken cancellationToken)
{
var filterBuilder = Builders<ExceptionReviewDocument>.Filter;
var filters = new List<FilterDefinition<ExceptionReviewDocument>>
{
filterBuilder.Eq(r => r.TenantId, tenantId.ToLowerInvariant()),
filterBuilder.Eq(r => r.Status, "pending")
};
if (!string.IsNullOrEmpty(reviewerId))
{
filters.Add(filterBuilder.AnyEq(r => r.DesignatedReviewers, reviewerId));
}
var filter = filterBuilder.And(filters);
var results = await Reviews
.Find(filter)
.Sort(Builders<ExceptionReviewDocument>.Sort.Ascending(r => r.Deadline))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return results.ToImmutableArray();
}
#endregion
#region Binding Operations
public async Task<ExceptionBindingDocument> UpsertBindingAsync(
ExceptionBindingDocument binding,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(binding);
binding.TenantId = binding.TenantId.ToLowerInvariant();
var filter = Builders<ExceptionBindingDocument>.Filter.And(
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.TenantId, binding.TenantId),
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.Id, binding.Id));
var options = new ReplaceOptions { IsUpsert = true };
await Bindings.ReplaceOneAsync(filter, binding, options, cancellationToken).ConfigureAwait(false);
_logger.LogDebug(
"Upserted binding {BindingId} for tenant {TenantId}",
binding.Id, binding.TenantId);
return binding;
}
public async Task<ImmutableArray<ExceptionBindingDocument>> GetBindingsForExceptionAsync(
string tenantId,
string exceptionId,
CancellationToken cancellationToken)
{
var filter = Builders<ExceptionBindingDocument>.Filter.And(
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.TenantId, tenantId.ToLowerInvariant()),
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.ExceptionId, exceptionId));
var results = await Bindings
.Find(filter)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return results.ToImmutableArray();
}
public async Task<ImmutableArray<ExceptionBindingDocument>> GetActiveBindingsForAssetAsync(
string tenantId,
string assetId,
DateTimeOffset asOf,
CancellationToken cancellationToken)
{
var filter = Builders<ExceptionBindingDocument>.Filter.And(
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.TenantId, tenantId.ToLowerInvariant()),
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.AssetId, assetId),
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.Status, "active"),
Builders<ExceptionBindingDocument>.Filter.Lte(b => b.EffectiveFrom, asOf),
Builders<ExceptionBindingDocument>.Filter.Or(
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.ExpiresAt, null),
Builders<ExceptionBindingDocument>.Filter.Gt(b => b.ExpiresAt, asOf)));
var results = await Bindings
.Find(filter)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return results.ToImmutableArray();
}
public async Task<long> DeleteBindingsForExceptionAsync(
string tenantId,
string exceptionId,
CancellationToken cancellationToken)
{
var filter = Builders<ExceptionBindingDocument>.Filter.And(
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.TenantId, tenantId.ToLowerInvariant()),
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.ExceptionId, exceptionId));
var result = await Bindings.DeleteManyAsync(filter, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Deleted {Count} bindings for exception {ExceptionId} tenant {TenantId}",
result.DeletedCount, exceptionId, tenantId);
return result.DeletedCount;
}
public async Task<bool> UpdateBindingStatusAsync(
string tenantId,
string bindingId,
string newStatus,
CancellationToken cancellationToken)
{
var filter = Builders<ExceptionBindingDocument>.Filter.And(
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.TenantId, tenantId.ToLowerInvariant()),
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.Id, bindingId));
var update = Builders<ExceptionBindingDocument>.Update.Set(b => b.Status, newStatus);
var result = await Bindings.UpdateOneAsync(filter, update, cancellationToken: cancellationToken)
.ConfigureAwait(false);
return result.ModifiedCount > 0;
}
public async Task<ImmutableArray<ExceptionBindingDocument>> GetExpiredBindingsAsync(
string tenantId,
DateTimeOffset asOf,
int limit,
CancellationToken cancellationToken)
{
var filter = Builders<ExceptionBindingDocument>.Filter.And(
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.TenantId, tenantId.ToLowerInvariant()),
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.Status, "active"),
Builders<ExceptionBindingDocument>.Filter.Lt(b => b.ExpiresAt, asOf));
var results = await Bindings
.Find(filter)
.Limit(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return results.ToImmutableArray();
}
#endregion
#region Statistics
public async Task<IReadOnlyDictionary<string, int>> GetExceptionCountsByStatusAsync(
string tenantId,
CancellationToken cancellationToken)
{
var pipeline = new BsonDocument[]
{
new("$match", new BsonDocument("tenantId", tenantId.ToLowerInvariant())),
new("$group", new BsonDocument
{
{ "_id", "$status" },
{ "count", new BsonDocument("$sum", 1) }
})
};
var results = await Exceptions
.Aggregate<BsonDocument>(pipeline, cancellationToken: cancellationToken)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return results.ToDictionary(
r => r["_id"].AsString,
r => r["count"].AsInt32);
}
#endregion
}

View File

@@ -0,0 +1,496 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using StellaOps.Policy.Engine.Domain;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Storage.Mongo.Internal;
// Alias to disambiguate from StellaOps.Policy.PolicyDocument (compiled policy IR)
using PolicyPackDocument = StellaOps.Policy.Engine.Storage.Mongo.Documents.PolicyDocument;
using PolicyRevisionDoc = StellaOps.Policy.Engine.Storage.Mongo.Documents.PolicyRevisionDocument;
using PolicyBundleDoc = StellaOps.Policy.Engine.Storage.Mongo.Documents.PolicyBundleDocument;
using PolicyApprovalRec = StellaOps.Policy.Engine.Storage.Mongo.Documents.PolicyApprovalRecord;
using PolicyAocMetadataDoc = StellaOps.Policy.Engine.Storage.Mongo.Documents.PolicyAocMetadataDocument;
using PolicyProvenanceDoc = StellaOps.Policy.Engine.Storage.Mongo.Documents.PolicyProvenanceDocument;
using PolicyAttestationRefDoc = StellaOps.Policy.Engine.Storage.Mongo.Documents.PolicyAttestationRefDocument;
namespace StellaOps.Policy.Engine.Storage.Mongo.Repositories;
/// <summary>
/// MongoDB implementation of policy pack repository with tenant scoping.
/// </summary>
internal sealed class MongoPolicyPackRepository : IPolicyPackRepository
{
private readonly PolicyEngineMongoContext _context;
private readonly ILogger<MongoPolicyPackRepository> _logger;
private readonly TimeProvider _timeProvider;
private readonly string _tenantId;
public MongoPolicyPackRepository(
PolicyEngineMongoContext context,
ILogger<MongoPolicyPackRepository> logger,
TimeProvider timeProvider,
string tenantId)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_tenantId = tenantId?.ToLowerInvariant() ?? throw new ArgumentNullException(nameof(tenantId));
}
private IMongoCollection<PolicyPackDocument> Policies =>
_context.Database.GetCollection<PolicyPackDocument>(_context.Options.PoliciesCollection);
private IMongoCollection<PolicyRevisionDoc> Revisions =>
_context.Database.GetCollection<PolicyRevisionDoc>(_context.Options.PolicyRevisionsCollection);
private IMongoCollection<PolicyBundleDoc> Bundles =>
_context.Database.GetCollection<PolicyBundleDoc>(_context.Options.PolicyBundlesCollection);
/// <inheritdoc />
public async Task<PolicyPackRecord> CreateAsync(string packId, string? displayName, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(packId);
var now = _timeProvider.GetUtcNow();
var document = new PolicyPackDocument
{
Id = packId,
TenantId = _tenantId,
DisplayName = displayName,
LatestVersion = 0,
CreatedAt = now,
UpdatedAt = now
};
try
{
await Policies.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Created policy pack {PackId} for tenant {TenantId}", packId, _tenantId);
}
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
_logger.LogDebug("Policy pack {PackId} already exists for tenant {TenantId}", packId, _tenantId);
var existing = await Policies.Find(p => p.Id == packId && p.TenantId == _tenantId)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
if (existing is null)
{
throw new InvalidOperationException($"Policy pack {packId} exists but not for tenant {_tenantId}");
}
return ToDomain(existing);
}
return ToDomain(document);
}
/// <inheritdoc />
public async Task<IReadOnlyList<PolicyPackRecord>> ListAsync(CancellationToken cancellationToken)
{
var documents = await Policies
.Find(p => p.TenantId == _tenantId)
.SortBy(p => p.Id)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return documents.Select(ToDomain).ToList().AsReadOnly();
}
/// <inheritdoc />
public async Task<PolicyRevisionRecord> UpsertRevisionAsync(
string packId,
int version,
bool requiresTwoPersonApproval,
PolicyRevisionStatus initialStatus,
CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
// Ensure pack exists
var pack = await Policies.Find(p => p.Id == packId && p.TenantId == _tenantId)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
if (pack is null)
{
pack = new PolicyPackDocument
{
Id = packId,
TenantId = _tenantId,
LatestVersion = 0,
CreatedAt = now,
UpdatedAt = now
};
try
{
await Policies.InsertOneAsync(pack, cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
pack = await Policies.Find(p => p.Id == packId && p.TenantId == _tenantId)
.FirstAsync(cancellationToken)
.ConfigureAwait(false);
}
}
// Determine version
var targetVersion = version > 0 ? version : pack.LatestVersion + 1;
var revisionId = PolicyRevisionDoc.CreateId(packId, targetVersion);
// Upsert revision
var filter = Builders<PolicyRevisionDoc>.Filter.Eq(r => r.Id, revisionId);
var update = Builders<PolicyRevisionDoc>.Update
.SetOnInsert(r => r.Id, revisionId)
.SetOnInsert(r => r.TenantId, _tenantId)
.SetOnInsert(r => r.PackId, packId)
.SetOnInsert(r => r.Version, targetVersion)
.SetOnInsert(r => r.RequiresTwoPersonApproval, requiresTwoPersonApproval)
.SetOnInsert(r => r.CreatedAt, now)
.Set(r => r.Status, initialStatus.ToString());
var options = new FindOneAndUpdateOptions<PolicyRevisionDoc>
{
IsUpsert = true,
ReturnDocument = ReturnDocument.After
};
var revision = await Revisions.FindOneAndUpdateAsync(filter, update, options, cancellationToken)
.ConfigureAwait(false);
// Update pack latest version
if (targetVersion > pack.LatestVersion)
{
await Policies.UpdateOneAsync(
p => p.Id == packId && p.TenantId == _tenantId,
Builders<PolicyPackDocument>.Update
.Set(p => p.LatestVersion, targetVersion)
.Set(p => p.UpdatedAt, now),
cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
_logger.LogDebug(
"Upserted revision {PackId}:{Version} for tenant {TenantId}",
packId, targetVersion, _tenantId);
return ToDomain(revision);
}
/// <inheritdoc />
public async Task<PolicyRevisionRecord?> GetRevisionAsync(string packId, int version, CancellationToken cancellationToken)
{
var revisionId = PolicyRevisionDoc.CreateId(packId, version);
var revision = await Revisions
.Find(r => r.Id == revisionId && r.TenantId == _tenantId)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
if (revision is null)
{
return null;
}
// Load bundle if referenced
PolicyBundleDoc? bundle = null;
if (!string.IsNullOrEmpty(revision.BundleId))
{
bundle = await Bundles
.Find(b => b.Id == revision.BundleId && b.TenantId == _tenantId)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
}
return ToDomain(revision, bundle);
}
/// <inheritdoc />
public async Task<PolicyActivationResult> RecordActivationAsync(
string packId,
int version,
string actorId,
DateTimeOffset timestamp,
string? comment,
CancellationToken cancellationToken)
{
var revisionId = PolicyRevisionDoc.CreateId(packId, version);
// Get current revision
var revision = await Revisions
.Find(r => r.Id == revisionId && r.TenantId == _tenantId)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
if (revision is null)
{
var pack = await Policies.Find(p => p.Id == packId && p.TenantId == _tenantId)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return pack is null
? new PolicyActivationResult(PolicyActivationResultStatus.PackNotFound, null)
: new PolicyActivationResult(PolicyActivationResultStatus.RevisionNotFound, null);
}
if (revision.Status == PolicyRevisionStatus.Active.ToString())
{
return new PolicyActivationResult(PolicyActivationResultStatus.AlreadyActive, ToDomain(revision));
}
if (revision.Status != PolicyRevisionStatus.Approved.ToString())
{
return new PolicyActivationResult(PolicyActivationResultStatus.NotApproved, ToDomain(revision));
}
// Check for duplicate approval
if (revision.Approvals.Any(a => a.ActorId.Equals(actorId, StringComparison.OrdinalIgnoreCase)))
{
return new PolicyActivationResult(PolicyActivationResultStatus.DuplicateApproval, ToDomain(revision));
}
// Add approval
var approval = new PolicyApprovalRec
{
ActorId = actorId,
ApprovedAt = timestamp,
Comment = comment
};
var approvalUpdate = Builders<PolicyRevisionDoc>.Update.Push(r => r.Approvals, approval);
await Revisions.UpdateOneAsync(r => r.Id == revisionId, approvalUpdate, cancellationToken: cancellationToken)
.ConfigureAwait(false);
revision.Approvals.Add(approval);
// Check if we have enough approvals
var approvalCount = revision.Approvals.Count;
if (revision.RequiresTwoPersonApproval && approvalCount < 2)
{
return new PolicyActivationResult(PolicyActivationResultStatus.PendingSecondApproval, ToDomain(revision));
}
// Activate
var activateUpdate = Builders<PolicyRevisionDoc>.Update
.Set(r => r.Status, PolicyRevisionStatus.Active.ToString())
.Set(r => r.ActivatedAt, timestamp);
await Revisions.UpdateOneAsync(r => r.Id == revisionId, activateUpdate, cancellationToken: cancellationToken)
.ConfigureAwait(false);
// Update pack active version
await Policies.UpdateOneAsync(
p => p.Id == packId && p.TenantId == _tenantId,
Builders<PolicyPackDocument>.Update
.Set(p => p.ActiveVersion, version)
.Set(p => p.UpdatedAt, timestamp),
cancellationToken: cancellationToken)
.ConfigureAwait(false);
revision.Status = PolicyRevisionStatus.Active.ToString();
revision.ActivatedAt = timestamp;
_logger.LogInformation(
"Activated revision {PackId}:{Version} for tenant {TenantId} by {ActorId}",
packId, version, _tenantId, actorId);
return new PolicyActivationResult(PolicyActivationResultStatus.Activated, ToDomain(revision));
}
/// <inheritdoc />
public async Task<PolicyBundleRecord> StoreBundleAsync(
string packId,
int version,
PolicyBundleRecord bundle,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(bundle);
var now = _timeProvider.GetUtcNow();
// Ensure revision exists
await UpsertRevisionAsync(packId, version, requiresTwoPersonApproval: false, PolicyRevisionStatus.Draft, cancellationToken)
.ConfigureAwait(false);
// Create bundle document
var bundleDoc = new PolicyBundleDoc
{
Id = bundle.Digest,
TenantId = _tenantId,
PackId = packId,
Version = version,
Signature = bundle.Signature,
SizeBytes = bundle.Size,
Payload = bundle.Payload.ToArray(),
CreatedAt = bundle.CreatedAt,
AocMetadata = bundle.AocMetadata is not null ? ToDocument(bundle.AocMetadata) : null
};
// Upsert bundle
await Bundles.ReplaceOneAsync(
b => b.Id == bundle.Digest && b.TenantId == _tenantId,
bundleDoc,
new ReplaceOptions { IsUpsert = true },
cancellationToken)
.ConfigureAwait(false);
// Link revision to bundle
var revisionId = PolicyRevisionDoc.CreateId(packId, version);
await Revisions.UpdateOneAsync(
r => r.Id == revisionId && r.TenantId == _tenantId,
Builders<PolicyRevisionDoc>.Update
.Set(r => r.BundleId, bundle.Digest)
.Set(r => r.BundleDigest, bundle.Digest),
cancellationToken: cancellationToken)
.ConfigureAwait(false);
_logger.LogDebug(
"Stored bundle {Digest} for {PackId}:{Version} tenant {TenantId}",
bundle.Digest, packId, version, _tenantId);
return bundle;
}
/// <inheritdoc />
public async Task<PolicyBundleRecord?> GetBundleAsync(string packId, int version, CancellationToken cancellationToken)
{
var bundle = await Bundles
.Find(b => b.PackId == packId && b.Version == version && b.TenantId == _tenantId)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return bundle is null ? null : ToDomain(bundle);
}
#region Mapping
private static PolicyPackRecord ToDomain(PolicyPackDocument doc)
{
return new PolicyPackRecord(doc.Id, doc.DisplayName, doc.CreatedAt);
}
private static PolicyRevisionRecord ToDomain(PolicyRevisionDoc doc, PolicyBundleDoc? bundleDoc = null)
{
var status = Enum.TryParse<PolicyRevisionStatus>(doc.Status, ignoreCase: true, out var s)
? s
: PolicyRevisionStatus.Draft;
var revision = new PolicyRevisionRecord(doc.Version, doc.RequiresTwoPersonApproval, status, doc.CreatedAt);
if (doc.ActivatedAt.HasValue)
{
revision.SetStatus(PolicyRevisionStatus.Active, doc.ActivatedAt.Value);
}
foreach (var approval in doc.Approvals)
{
revision.AddApproval(new PolicyActivationApproval(approval.ActorId, approval.ApprovedAt, approval.Comment));
}
if (bundleDoc is not null)
{
revision.SetBundle(ToDomain(bundleDoc));
}
return revision;
}
private static PolicyBundleRecord ToDomain(PolicyBundleDoc doc)
{
PolicyAocMetadata? aocMetadata = null;
if (doc.AocMetadata is not null)
{
var aoc = doc.AocMetadata;
PolicyProvenance? provenance = null;
if (aoc.Provenance is not null)
{
var p = aoc.Provenance;
provenance = new PolicyProvenance(
p.SourceType,
p.SourceUrl,
p.Submitter,
p.CommitSha,
p.Branch,
p.IngestedAt);
}
PolicyAttestationRef? attestationRef = null;
if (aoc.AttestationRef is not null)
{
var a = aoc.AttestationRef;
attestationRef = new PolicyAttestationRef(
a.AttestationId,
a.EnvelopeDigest,
a.Uri,
a.SigningKeyId,
a.CreatedAt);
}
aocMetadata = new PolicyAocMetadata(
aoc.CompilationId,
aoc.CompilerVersion,
aoc.CompiledAt,
aoc.SourceDigest,
aoc.ArtifactDigest,
aoc.ComplexityScore,
aoc.RuleCount,
aoc.DurationMilliseconds,
provenance,
attestationRef);
}
return new PolicyBundleRecord(
doc.Id,
doc.Signature,
doc.SizeBytes,
doc.CreatedAt,
doc.Payload.ToImmutableArray(),
CompiledDocument: null, // Cannot serialize IR document to/from Mongo
aocMetadata);
}
private static PolicyAocMetadataDoc ToDocument(PolicyAocMetadata aoc)
{
return new PolicyAocMetadataDoc
{
CompilationId = aoc.CompilationId,
CompilerVersion = aoc.CompilerVersion,
CompiledAt = aoc.CompiledAt,
SourceDigest = aoc.SourceDigest,
ArtifactDigest = aoc.ArtifactDigest,
ComplexityScore = aoc.ComplexityScore,
RuleCount = aoc.RuleCount,
DurationMilliseconds = aoc.DurationMilliseconds,
Provenance = aoc.Provenance is not null ? ToDocument(aoc.Provenance) : null,
AttestationRef = aoc.AttestationRef is not null ? ToDocument(aoc.AttestationRef) : null
};
}
private static PolicyProvenanceDoc ToDocument(PolicyProvenance p)
{
return new PolicyProvenanceDoc
{
SourceType = p.SourceType,
SourceUrl = p.SourceUrl,
Submitter = p.Submitter,
CommitSha = p.CommitSha,
Branch = p.Branch,
IngestedAt = p.IngestedAt
};
}
private static PolicyAttestationRefDoc ToDocument(PolicyAttestationRef a)
{
return new PolicyAttestationRefDoc
{
AttestationId = a.AttestationId,
EnvelopeDigest = a.EnvelopeDigest,
Uri = a.Uri,
SigningKeyId = a.SigningKeyId,
CreatedAt = a.CreatedAt
};
}
#endregion
}

View File

@@ -0,0 +1,72 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Policy.Engine.Storage.Mongo.Internal;
using StellaOps.Policy.Engine.Storage.Mongo.Migrations;
using StellaOps.Policy.Engine.Storage.Mongo.Options;
using StellaOps.Policy.Engine.Storage.Mongo.Repositories;
namespace StellaOps.Policy.Engine.Storage.Mongo;
/// <summary>
/// Extension methods for registering Policy Engine MongoDB storage services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds Policy Engine MongoDB storage services to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configure">Optional configuration action for PolicyEngineMongoOptions.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddPolicyEngineMongoStorage(
this IServiceCollection services,
Action<PolicyEngineMongoOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);
// Register options
if (configure is not null)
{
services.Configure(configure);
}
// Register context (singleton for connection pooling)
services.AddSingleton<PolicyEngineMongoContext>();
// Register migrations
services.AddSingleton<IPolicyEngineMongoMigration, EnsurePolicyCollectionsMigration>();
services.AddSingleton<IPolicyEngineMongoMigration, EnsurePolicyIndexesMigration>();
services.AddSingleton<IPolicyEngineMongoMigration, EnsureExceptionIndexesMigration>();
// Register migration runner
services.AddSingleton<PolicyEngineMigrationRunner>();
// Register initializer
services.AddSingleton<IPolicyEngineMongoInitializer, PolicyEngineMongoInitializer>();
// Register dynamic collection initializer for effective findings
services.AddSingleton<IEffectiveFindingCollectionInitializer, EffectiveFindingCollectionInitializer>();
// Register repositories
services.AddSingleton<IExceptionRepository, MongoExceptionRepository>();
return services;
}
/// <summary>
/// Adds Policy Engine MongoDB storage services with configuration binding from a configuration section.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">Configuration section containing PolicyEngineMongoOptions.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddPolicyEngineMongoStorage(
this IServiceCollection services,
Microsoft.Extensions.Configuration.IConfigurationSection configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
services.Configure<PolicyEngineMongoOptions>(configuration);
return services.AddPolicyEngineMongoStorage(configure: null);
}
}

View File

@@ -291,6 +291,90 @@ public static class PolicyEngineTelemetry
/// </summary>
public static Counter<long> ProfileEventsPublished => ProfileEventsPublishedCounter;
// Counter: policy_events_processed_total
private static readonly Counter<long> PolicyEventsProcessedCounter =
Meter.CreateCounter<long>(
"policy_events_processed_total",
unit: "events",
description: "Total policy change events processed.");
/// <summary>
/// Counter for policy change events processed.
/// </summary>
public static Counter<long> PolicyEventsProcessed => PolicyEventsProcessedCounter;
// Counter: policy_effective_events_published_total
private static readonly Counter<long> PolicyEffectiveEventsPublishedCounter =
Meter.CreateCounter<long>(
"policy_effective_events_published_total",
unit: "events",
description: "Total policy.effective.* events published.");
/// <summary>
/// Counter for policy effective events published.
/// </summary>
public static Counter<long> PolicyEffectiveEventsPublished => PolicyEffectiveEventsPublishedCounter;
// Counter: policy_reevaluation_jobs_scheduled_total
private static readonly Counter<long> ReEvaluationJobsScheduledCounter =
Meter.CreateCounter<long>(
"policy_reevaluation_jobs_scheduled_total",
unit: "jobs",
description: "Total re-evaluation jobs scheduled.");
/// <summary>
/// Counter for re-evaluation jobs scheduled.
/// </summary>
public static Counter<long> ReEvaluationJobsScheduled => ReEvaluationJobsScheduledCounter;
// Counter: policy_explain_traces_stored_total
private static readonly Counter<long> ExplainTracesStoredCounter =
Meter.CreateCounter<long>(
"policy_explain_traces_stored_total",
unit: "traces",
description: "Total explain traces stored for decision audit.");
/// <summary>
/// Counter for explain traces stored.
/// </summary>
public static Counter<long> ExplainTracesStored => ExplainTracesStoredCounter;
// Counter: policy_effective_decision_map_operations_total
private static readonly Counter<long> EffectiveDecisionMapOperationsCounter =
Meter.CreateCounter<long>(
"policy_effective_decision_map_operations_total",
unit: "operations",
description: "Total effective decision map operations (set, get, invalidate).");
/// <summary>
/// Counter for effective decision map operations.
/// </summary>
public static Counter<long> EffectiveDecisionMapOperations => EffectiveDecisionMapOperationsCounter;
// Counter: policy_exception_operations_total{tenant,operation}
private static readonly Counter<long> ExceptionOperationsCounter =
Meter.CreateCounter<long>(
"policy_exception_operations_total",
unit: "operations",
description: "Total policy exception operations (create, update, revoke, review_*).");
/// <summary>
/// Counter for policy exception operations.
/// </summary>
public static Counter<long> ExceptionOperations => ExceptionOperationsCounter;
// Counter: policy_exception_cache_operations_total{tenant,operation}
private static readonly Counter<long> ExceptionCacheOperationsCounter =
Meter.CreateCounter<long>(
"policy_exception_cache_operations_total",
unit: "operations",
description: "Total exception cache operations (hit, miss, set, warm, invalidate).");
/// <summary>
/// Counter for exception cache operations.
/// </summary>
public static Counter<long> ExceptionCacheOperations => ExceptionCacheOperationsCounter;
#endregion
#region Reachability Metrics
@@ -506,6 +590,38 @@ public static class PolicyEngineTelemetry
PolicySimulationCounter.Add(1, tags);
}
/// <summary>
/// Records a policy exception operation.
/// </summary>
/// <param name="tenant">Tenant identifier.</param>
/// <param name="operation">Operation type (create, update, revoke, review_create, review_decision_*, etc.).</param>
public static void RecordExceptionOperation(string tenant, string operation)
{
var tags = new TagList
{
{ "tenant", NormalizeTenant(tenant) },
{ "operation", NormalizeTag(operation) },
};
ExceptionOperationsCounter.Add(1, tags);
}
/// <summary>
/// Records an exception cache operation.
/// </summary>
/// <param name="tenant">Tenant identifier.</param>
/// <param name="operation">Operation type (hit, miss, set, warm, invalidate_*, event_*).</param>
public static void RecordExceptionCacheOperation(string tenant, string operation)
{
var tags = new TagList
{
{ "tenant", NormalizeTenant(tenant) },
{ "operation", NormalizeTag(operation) },
};
ExceptionCacheOperationsCounter.Add(1, tags);
}
#region Golden Signals - Recording Methods
/// <summary>

View File

@@ -127,7 +127,7 @@ public sealed class PolicyEvaluationPredicate
/// Environment information.
/// </summary>
[JsonPropertyName("environment")]
public required PolicyEvaluationEnvironment Environment { get; init; }
public required AttestationEnvironment Environment { get; init; }
}
/// <summary>
@@ -167,9 +167,9 @@ public sealed class PolicyEvaluationMetrics
}
/// <summary>
/// Environment information for the evaluation.
/// Environment information for the attestation.
/// </summary>
public sealed class PolicyEvaluationEnvironment
public sealed class AttestationEnvironment
{
[JsonPropertyName("serviceVersion")]
public required string ServiceVersion { get; init; }
@@ -243,7 +243,7 @@ public sealed class PolicyEvaluationAttestationService
VexOverridesApplied = vexOverridesApplied,
DurationSeconds = durationSeconds,
},
Environment = new PolicyEvaluationEnvironment
Environment = new AttestationEnvironment
{
ServiceVersion = serviceVersion,
HostId = Environment.MachineName,
@@ -338,7 +338,7 @@ public sealed class DsseEnvelopeRequest
[JsonSerializable(typeof(InTotoSubject))]
[JsonSerializable(typeof(EvidenceBundleRef))]
[JsonSerializable(typeof(PolicyEvaluationMetrics))]
[JsonSerializable(typeof(PolicyEvaluationEnvironment))]
[JsonSerializable(typeof(AttestationEnvironment))]
[JsonSourceGenerationOptions(
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]

View File

@@ -0,0 +1,371 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.WhatIfSimulation;
/// <summary>
/// Request for what-if simulation supporting hypothetical SBOM diffs and draft policies.
/// </summary>
public sealed record WhatIfSimulationRequest
{
/// <summary>
/// Tenant identifier.
/// </summary>
[JsonPropertyName("tenant_id")]
public required string TenantId { get; init; }
/// <summary>
/// Base snapshot ID to apply diffs to.
/// </summary>
[JsonPropertyName("base_snapshot_id")]
public required string BaseSnapshotId { get; init; }
/// <summary>
/// Active policy pack ID to use as baseline.
/// If DraftPolicy is provided, this will be compared against.
/// </summary>
[JsonPropertyName("baseline_pack_id")]
public string? BaselinePackId { get; init; }
/// <summary>
/// Baseline policy version. If null, uses active version.
/// </summary>
[JsonPropertyName("baseline_pack_version")]
public int? BaselinePackVersion { get; init; }
/// <summary>
/// Draft policy to simulate (not yet activated).
/// If null, uses baseline policy.
/// </summary>
[JsonPropertyName("draft_policy")]
public WhatIfDraftPolicy? DraftPolicy { get; init; }
/// <summary>
/// SBOM diffs to apply hypothetically.
/// </summary>
[JsonPropertyName("sbom_diffs")]
public ImmutableArray<WhatIfSbomDiff> SbomDiffs { get; init; } = ImmutableArray<WhatIfSbomDiff>.Empty;
/// <summary>
/// Specific component PURLs to evaluate. If empty, evaluates affected by diffs.
/// </summary>
[JsonPropertyName("target_purls")]
public ImmutableArray<string> TargetPurls { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Maximum number of components to evaluate.
/// </summary>
[JsonPropertyName("limit")]
public int Limit { get; init; } = 1000;
/// <summary>
/// Whether to include detailed explanations for each decision.
/// </summary>
[JsonPropertyName("include_explanations")]
public bool IncludeExplanations { get; init; } = false;
/// <summary>
/// Correlation ID for tracing.
/// </summary>
[JsonPropertyName("correlation_id")]
public string? CorrelationId { get; init; }
}
/// <summary>
/// Draft policy definition for simulation.
/// </summary>
public sealed record WhatIfDraftPolicy
{
/// <summary>
/// Draft policy pack ID.
/// </summary>
[JsonPropertyName("pack_id")]
public required string PackId { get; init; }
/// <summary>
/// Draft policy version.
/// </summary>
[JsonPropertyName("version")]
public int Version { get; init; }
/// <summary>
/// Raw YAML policy definition to compile and evaluate.
/// If provided, this is compiled on-the-fly.
/// </summary>
[JsonPropertyName("policy_yaml")]
public string? PolicyYaml { get; init; }
/// <summary>
/// Pre-compiled bundle digest if available.
/// </summary>
[JsonPropertyName("bundle_digest")]
public string? BundleDigest { get; init; }
}
/// <summary>
/// Hypothetical SBOM modification for what-if simulation.
/// </summary>
public sealed record WhatIfSbomDiff
{
/// <summary>
/// Type of modification: add, remove, upgrade, downgrade.
/// </summary>
[JsonPropertyName("operation")]
public required string Operation { get; init; }
/// <summary>
/// Component PURL being modified.
/// </summary>
[JsonPropertyName("purl")]
public required string Purl { get; init; }
/// <summary>
/// New version for upgrade/downgrade operations.
/// </summary>
[JsonPropertyName("new_version")]
public string? NewVersion { get; init; }
/// <summary>
/// Original version (for reference in upgrades/downgrades).
/// </summary>
[JsonPropertyName("original_version")]
public string? OriginalVersion { get; init; }
/// <summary>
/// Hypothetical advisory IDs affecting this component.
/// </summary>
[JsonPropertyName("advisory_ids")]
public ImmutableArray<string> AdvisoryIds { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Hypothetical VEX status for this component.
/// </summary>
[JsonPropertyName("vex_status")]
public string? VexStatus { get; init; }
/// <summary>
/// Hypothetical reachability state.
/// </summary>
[JsonPropertyName("reachability")]
public string? Reachability { get; init; }
}
/// <summary>
/// Response from what-if simulation.
/// </summary>
public sealed record WhatIfSimulationResponse
{
/// <summary>
/// Simulation identifier.
/// </summary>
[JsonPropertyName("simulation_id")]
public required string SimulationId { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
[JsonPropertyName("tenant_id")]
public required string TenantId { get; init; }
/// <summary>
/// Base snapshot ID used.
/// </summary>
[JsonPropertyName("base_snapshot_id")]
public required string BaseSnapshotId { get; init; }
/// <summary>
/// Baseline policy used for comparison.
/// </summary>
[JsonPropertyName("baseline_policy")]
public required WhatIfPolicyRef BaselinePolicy { get; init; }
/// <summary>
/// Simulated policy (draft or modified).
/// </summary>
[JsonPropertyName("simulated_policy")]
public WhatIfPolicyRef? SimulatedPolicy { get; init; }
/// <summary>
/// Decision changes between baseline and simulation.
/// </summary>
[JsonPropertyName("decision_changes")]
public required ImmutableArray<WhatIfDecisionChange> DecisionChanges { get; init; }
/// <summary>
/// Summary of changes.
/// </summary>
[JsonPropertyName("summary")]
public required WhatIfSummary Summary { get; init; }
/// <summary>
/// When the simulation was executed.
/// </summary>
[JsonPropertyName("executed_at")]
public required DateTimeOffset ExecutedAt { get; init; }
/// <summary>
/// Execution duration in milliseconds.
/// </summary>
[JsonPropertyName("duration_ms")]
public long DurationMs { get; init; }
/// <summary>
/// Correlation ID.
/// </summary>
[JsonPropertyName("correlation_id")]
public string? CorrelationId { get; init; }
}
/// <summary>
/// Policy reference in simulation.
/// </summary>
public sealed record WhatIfPolicyRef(
[property: JsonPropertyName("pack_id")] string PackId,
[property: JsonPropertyName("version")] int Version,
[property: JsonPropertyName("bundle_digest")] string? BundleDigest,
[property: JsonPropertyName("is_draft")] bool IsDraft);
/// <summary>
/// A decision change detected in what-if simulation.
/// </summary>
public sealed record WhatIfDecisionChange
{
/// <summary>
/// Component PURL.
/// </summary>
[JsonPropertyName("purl")]
public required string Purl { get; init; }
/// <summary>
/// Advisory ID if applicable.
/// </summary>
[JsonPropertyName("advisory_id")]
public string? AdvisoryId { get; init; }
/// <summary>
/// Type of change: new, removed, status_changed, severity_changed.
/// </summary>
[JsonPropertyName("change_type")]
public required string ChangeType { get; init; }
/// <summary>
/// Baseline decision.
/// </summary>
[JsonPropertyName("baseline")]
public WhatIfDecision? Baseline { get; init; }
/// <summary>
/// Simulated decision.
/// </summary>
[JsonPropertyName("simulated")]
public WhatIfDecision? Simulated { get; init; }
/// <summary>
/// SBOM diff that caused this change, if any.
/// </summary>
[JsonPropertyName("caused_by_diff")]
public WhatIfSbomDiff? CausedByDiff { get; init; }
/// <summary>
/// Explanation for the change.
/// </summary>
[JsonPropertyName("explanation")]
public WhatIfExplanation? Explanation { get; init; }
}
/// <summary>
/// A decision in what-if simulation.
/// </summary>
public sealed record WhatIfDecision(
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("severity")] string? Severity,
[property: JsonPropertyName("rule_name")] string? RuleName,
[property: JsonPropertyName("priority")] int? Priority,
[property: JsonPropertyName("exception_applied")] bool ExceptionApplied);
/// <summary>
/// Explanation for a what-if decision.
/// </summary>
public sealed record WhatIfExplanation
{
/// <summary>
/// Rules that matched.
/// </summary>
[JsonPropertyName("matched_rules")]
public ImmutableArray<string> MatchedRules { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Key factors in the decision.
/// </summary>
[JsonPropertyName("factors")]
public ImmutableArray<string> Factors { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// VEX evidence considered.
/// </summary>
[JsonPropertyName("vex_evidence")]
public string? VexEvidence { get; init; }
/// <summary>
/// Reachability state.
/// </summary>
[JsonPropertyName("reachability")]
public string? Reachability { get; init; }
}
/// <summary>
/// Summary of what-if simulation results.
/// </summary>
public sealed record WhatIfSummary
{
/// <summary>
/// Total components evaluated.
/// </summary>
[JsonPropertyName("total_evaluated")]
public int TotalEvaluated { get; init; }
/// <summary>
/// Components with changed decisions.
/// </summary>
[JsonPropertyName("total_changed")]
public int TotalChanged { get; init; }
/// <summary>
/// Components newly affected.
/// </summary>
[JsonPropertyName("newly_affected")]
public int NewlyAffected { get; init; }
/// <summary>
/// Components no longer affected.
/// </summary>
[JsonPropertyName("no_longer_affected")]
public int NoLongerAffected { get; init; }
/// <summary>
/// Status changes by type.
/// </summary>
[JsonPropertyName("status_changes")]
public required ImmutableDictionary<string, int> StatusChanges { get; init; }
/// <summary>
/// Severity changes by type (e.g., "low_to_high").
/// </summary>
[JsonPropertyName("severity_changes")]
public required ImmutableDictionary<string, int> SeverityChanges { get; init; }
/// <summary>
/// Impact assessment.
/// </summary>
[JsonPropertyName("impact")]
public required WhatIfImpact Impact { get; init; }
}
/// <summary>
/// Impact assessment from what-if simulation.
/// </summary>
public sealed record WhatIfImpact(
[property: JsonPropertyName("risk_delta")] string RiskDelta, // increased, decreased, unchanged
[property: JsonPropertyName("blocked_count_delta")] int BlockedCountDelta,
[property: JsonPropertyName("warning_count_delta")] int WarningCountDelta,
[property: JsonPropertyName("recommendation")] string? Recommendation);

View File

@@ -0,0 +1,548 @@
using System.Collections.Immutable;
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.Domain;
using StellaOps.Policy.Engine.EffectiveDecisionMap;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Telemetry;
namespace StellaOps.Policy.Engine.WhatIfSimulation;
/// <summary>
/// Service for Graph What-if API simulations.
/// Supports hypothetical SBOM diffs and draft policies without persisting results.
/// </summary>
internal sealed class WhatIfSimulationService
{
private readonly IEffectiveDecisionMap _decisionMap;
private readonly IPolicyPackRepository _policyRepository;
private readonly PolicyCompilationService _compilationService;
private readonly ILogger<WhatIfSimulationService> _logger;
private readonly TimeProvider _timeProvider;
public WhatIfSimulationService(
IEffectiveDecisionMap decisionMap,
IPolicyPackRepository policyRepository,
PolicyCompilationService compilationService,
ILogger<WhatIfSimulationService> logger,
TimeProvider timeProvider)
{
_decisionMap = decisionMap ?? throw new ArgumentNullException(nameof(decisionMap));
_policyRepository = policyRepository ?? throw new ArgumentNullException(nameof(policyRepository));
_compilationService = compilationService ?? throw new ArgumentNullException(nameof(compilationService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
/// <summary>
/// Executes a what-if simulation without persisting results.
/// </summary>
public async Task<WhatIfSimulationResponse> SimulateAsync(
WhatIfSimulationRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity(
"policy.whatif.simulate", ActivityKind.Internal);
activity?.SetTag("tenant_id", request.TenantId);
activity?.SetTag("base_snapshot_id", request.BaseSnapshotId);
activity?.SetTag("has_draft_policy", request.DraftPolicy is not null);
activity?.SetTag("sbom_diff_count", request.SbomDiffs.Length);
var sw = Stopwatch.StartNew();
var simulationId = GenerateSimulationId(request);
var executedAt = _timeProvider.GetUtcNow();
_logger.LogInformation(
"Starting what-if simulation {SimulationId} for tenant {TenantId}, snapshot {SnapshotId}",
simulationId, request.TenantId, request.BaseSnapshotId);
try
{
// Get baseline policy info
var baselinePolicy = await GetBaselinePolicyAsync(request, cancellationToken).ConfigureAwait(false);
// Get simulated policy info (draft or same as baseline)
var simulatedPolicy = await GetSimulatedPolicyAsync(request, cancellationToken).ConfigureAwait(false);
// Determine which components to evaluate
var targetPurls = await DetermineTargetPurlsAsync(request, cancellationToken).ConfigureAwait(false);
// Get baseline decisions from effective decision map
var baselineDecisions = await GetBaselineDecisionsAsync(
request.TenantId, request.BaseSnapshotId, targetPurls, cancellationToken).ConfigureAwait(false);
// Simulate decisions with hypothetical changes
var simulatedDecisions = await SimulateDecisionsAsync(
request, targetPurls, simulatedPolicy, cancellationToken).ConfigureAwait(false);
// Compute changes between baseline and simulated
var changes = ComputeChanges(
targetPurls, baselineDecisions, simulatedDecisions, request.SbomDiffs, request.IncludeExplanations);
// Compute summary
var summary = ComputeSummary(changes, baselineDecisions, simulatedDecisions);
sw.Stop();
_logger.LogInformation(
"Completed what-if simulation {SimulationId}: {Evaluated} evaluated, {Changed} changed in {Duration}ms",
simulationId, summary.TotalEvaluated, summary.TotalChanged, sw.ElapsedMilliseconds);
PolicyEngineTelemetry.RecordSimulation(request.TenantId, "success");
return new WhatIfSimulationResponse
{
SimulationId = simulationId,
TenantId = request.TenantId,
BaseSnapshotId = request.BaseSnapshotId,
BaselinePolicy = baselinePolicy,
SimulatedPolicy = simulatedPolicy,
DecisionChanges = changes,
Summary = summary,
ExecutedAt = executedAt,
DurationMs = sw.ElapsedMilliseconds,
CorrelationId = request.CorrelationId,
};
}
catch (Exception ex)
{
sw.Stop();
_logger.LogError(ex, "What-if simulation {SimulationId} failed", simulationId);
PolicyEngineTelemetry.RecordSimulation(request.TenantId, "failure");
PolicyEngineTelemetry.RecordError("whatif_simulation", request.TenantId);
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
throw;
}
}
private async Task<WhatIfPolicyRef> GetBaselinePolicyAsync(
WhatIfSimulationRequest request,
CancellationToken cancellationToken)
{
if (request.BaselinePackId is not null)
{
var version = request.BaselinePackVersion ?? 1;
// If no version specified, try to get the latest revision to find the active version
if (request.BaselinePackVersion is null)
{
var revision = await _policyRepository.GetRevisionAsync(request.BaselinePackId, 1, cancellationToken)
.ConfigureAwait(false);
if (revision?.Status == PolicyRevisionStatus.Active)
{
version = revision.Version;
}
}
var bundle = await _policyRepository.GetBundleAsync(request.BaselinePackId, version, cancellationToken)
.ConfigureAwait(false);
return new WhatIfPolicyRef(
request.BaselinePackId,
version,
bundle?.Digest,
IsDraft: false);
}
// Return a placeholder for "current effective policy"
return new WhatIfPolicyRef("default", 1, null, IsDraft: false);
}
private async Task<WhatIfPolicyRef?> GetSimulatedPolicyAsync(
WhatIfSimulationRequest request,
CancellationToken cancellationToken)
{
if (request.DraftPolicy is null)
{
return null; // No draft - comparison is baseline vs hypothetical SBOM changes
}
string? bundleDigest = request.DraftPolicy.BundleDigest;
// If we have YAML, we could compile it on-the-fly (not persisting)
// For now, we just reference the draft
if (request.DraftPolicy.PolicyYaml is not null && bundleDigest is null)
{
// Compute a digest from the YAML for reference
bundleDigest = ComputeYamlDigest(request.DraftPolicy.PolicyYaml);
}
return new WhatIfPolicyRef(
request.DraftPolicy.PackId,
request.DraftPolicy.Version,
bundleDigest,
IsDraft: true);
}
private async Task<ImmutableArray<string>> DetermineTargetPurlsAsync(
WhatIfSimulationRequest request,
CancellationToken cancellationToken)
{
if (request.TargetPurls.Length > 0)
{
return request.TargetPurls.Take(request.Limit).ToImmutableArray();
}
// Get PURLs from SBOM diffs
var diffPurls = request.SbomDiffs.Select(d => d.Purl).Distinct().ToList();
if (diffPurls.Count > 0)
{
return diffPurls.Take(request.Limit).ToImmutableArray();
}
// Get from effective decision map
var allDecisions = await _decisionMap.GetAllForSnapshotAsync(
request.TenantId,
request.BaseSnapshotId,
new EffectiveDecisionFilter { Limit = request.Limit },
cancellationToken).ConfigureAwait(false);
return allDecisions.Select(d => d.AssetId).ToImmutableArray();
}
private async Task<Dictionary<string, WhatIfDecision>> GetBaselineDecisionsAsync(
string tenantId,
string snapshotId,
ImmutableArray<string> purls,
CancellationToken cancellationToken)
{
var result = await _decisionMap.GetBatchAsync(tenantId, snapshotId, purls.ToList(), cancellationToken)
.ConfigureAwait(false);
var decisions = new Dictionary<string, WhatIfDecision>(StringComparer.OrdinalIgnoreCase);
foreach (var (purl, entry) in result.Entries)
{
decisions[purl] = new WhatIfDecision(
entry.Status,
entry.Severity,
entry.RuleName,
entry.Priority,
entry.ExceptionId is not null);
}
return decisions;
}
private Task<Dictionary<string, WhatIfDecision>> SimulateDecisionsAsync(
WhatIfSimulationRequest request,
ImmutableArray<string> targetPurls,
WhatIfPolicyRef? simulatedPolicy,
CancellationToken cancellationToken)
{
// In a full implementation, this would:
// 1. Apply SBOM diffs to compute hypothetical component states
// 2. If draft policy, compile and evaluate against the draft
// 3. Otherwise, re-evaluate with hypothetical context changes
//
// For now, we compute simulated decisions based on the diffs
var decisions = new Dictionary<string, WhatIfDecision>(StringComparer.OrdinalIgnoreCase);
var diffsByPurl = request.SbomDiffs.ToDictionary(d => d.Purl, StringComparer.OrdinalIgnoreCase);
foreach (var purl in targetPurls)
{
cancellationToken.ThrowIfCancellationRequested();
if (diffsByPurl.TryGetValue(purl, out var diff))
{
var decision = SimulateDecisionForDiff(diff, simulatedPolicy);
decisions[purl] = decision;
}
else
{
// No diff for this PURL - simulate based on policy change if any
decisions[purl] = SimulateDecisionWithoutDiff(purl, simulatedPolicy);
}
}
return Task.FromResult(decisions);
}
private static WhatIfDecision SimulateDecisionForDiff(WhatIfSbomDiff diff, WhatIfPolicyRef? policy)
{
// Simulate based on diff operation and properties
return diff.Operation.ToLowerInvariant() switch
{
"remove" => new WhatIfDecision("allow", null, null, null, false),
"add" => SimulateNewComponentDecision(diff),
"upgrade" => SimulateUpgradeDecision(diff),
"downgrade" => SimulateDowngradeDecision(diff),
_ => new WhatIfDecision("allow", null, null, null, false),
};
}
private static WhatIfDecision SimulateNewComponentDecision(WhatIfSbomDiff diff)
{
// New components are evaluated based on advisory presence
if (diff.AdvisoryIds.Length > 0)
{
var severity = DetermineSeverityFromAdvisories(diff.AdvisoryIds);
var status = severity switch
{
"critical" or "high" => "deny",
"medium" => "warn",
_ => "allow"
};
// VEX can override
if (diff.VexStatus?.Equals("not_affected", StringComparison.OrdinalIgnoreCase) == true)
{
status = "allow";
}
// Reachability can downgrade
if (diff.Reachability?.Equals("unreachable", StringComparison.OrdinalIgnoreCase) == true &&
status == "deny")
{
status = "warn";
}
return new WhatIfDecision(status, severity, "simulated_rule", 100, false);
}
return new WhatIfDecision("allow", null, null, null, false);
}
private static WhatIfDecision SimulateUpgradeDecision(WhatIfSbomDiff diff)
{
// Upgrades typically fix vulnerabilities
if (diff.AdvisoryIds.Length > 0)
{
// Some advisories remain
return new WhatIfDecision("warn", "low", "simulated_upgrade_rule", 50, false);
}
// Upgrade fixed all issues
return new WhatIfDecision("allow", null, "simulated_upgrade_rule", 50, false);
}
private static WhatIfDecision SimulateDowngradeDecision(WhatIfSbomDiff diff)
{
// Downgrades may introduce vulnerabilities
if (diff.AdvisoryIds.Length > 0)
{
var severity = DetermineSeverityFromAdvisories(diff.AdvisoryIds);
return new WhatIfDecision("deny", severity, "simulated_downgrade_rule", 150, false);
}
return new WhatIfDecision("warn", "low", "simulated_downgrade_rule", 150, false);
}
private static WhatIfDecision SimulateDecisionWithoutDiff(string purl, WhatIfPolicyRef? policy)
{
// If there's a draft policy, simulate potential changes from policy modification
if (policy?.IsDraft == true)
{
// Draft policies might change thresholds - simulate a potential change
return new WhatIfDecision("warn", "medium", "draft_policy_rule", 100, false);
}
// No change - return unchanged placeholder
return new WhatIfDecision("allow", null, null, null, false);
}
private static string DetermineSeverityFromAdvisories(ImmutableArray<string> advisoryIds)
{
// In reality, would look up actual severity from advisories
// For simulation, use a heuristic based on advisory count
if (advisoryIds.Length >= 5) return "critical";
if (advisoryIds.Length >= 3) return "high";
if (advisoryIds.Length >= 1) return "medium";
return "low";
}
private static ImmutableArray<WhatIfDecisionChange> ComputeChanges(
ImmutableArray<string> targetPurls,
Dictionary<string, WhatIfDecision> baseline,
Dictionary<string, WhatIfDecision> simulated,
ImmutableArray<WhatIfSbomDiff> diffs,
bool includeExplanations)
{
var changes = new List<WhatIfDecisionChange>();
var diffsByPurl = diffs.ToDictionary(d => d.Purl, StringComparer.OrdinalIgnoreCase);
foreach (var purl in targetPurls)
{
var hasBaseline = baseline.TryGetValue(purl, out var baselineDecision);
var hasSimulated = simulated.TryGetValue(purl, out var simulatedDecision);
diffsByPurl.TryGetValue(purl, out var diff);
string? changeType = null;
if (!hasBaseline && hasSimulated)
{
changeType = "new";
}
else if (hasBaseline && !hasSimulated)
{
changeType = "removed";
}
else if (hasBaseline && hasSimulated)
{
if (baselineDecision!.Status != simulatedDecision!.Status)
{
changeType = "status_changed";
}
else if (baselineDecision.Severity != simulatedDecision.Severity)
{
changeType = "severity_changed";
}
}
if (changeType is not null)
{
var explanation = includeExplanations
? BuildExplanation(diff, baselineDecision, simulatedDecision)
: null;
changes.Add(new WhatIfDecisionChange
{
Purl = purl,
AdvisoryId = diff?.AdvisoryIds.FirstOrDefault(),
ChangeType = changeType,
Baseline = baselineDecision,
Simulated = simulatedDecision,
CausedByDiff = diff,
Explanation = explanation,
});
}
}
return changes.ToImmutableArray();
}
private static WhatIfExplanation BuildExplanation(
WhatIfSbomDiff? diff,
WhatIfDecision? baseline,
WhatIfDecision? simulated)
{
var factors = new List<string>();
var rules = new List<string>();
if (diff is not null)
{
factors.Add($"SBOM {diff.Operation}: {diff.Purl}");
if (diff.NewVersion is not null)
{
factors.Add($"Version change: {diff.OriginalVersion ?? "unknown"} -> {diff.NewVersion}");
}
if (diff.AdvisoryIds.Length > 0)
{
factors.Add($"Advisories: {string.Join(", ", diff.AdvisoryIds.Take(3))}");
}
}
if (baseline?.RuleName is not null)
{
rules.Add($"baseline:{baseline.RuleName}");
}
if (simulated?.RuleName is not null)
{
rules.Add($"simulated:{simulated.RuleName}");
}
return new WhatIfExplanation
{
MatchedRules = rules.ToImmutableArray(),
Factors = factors.ToImmutableArray(),
VexEvidence = diff?.VexStatus,
Reachability = diff?.Reachability,
};
}
private static WhatIfSummary ComputeSummary(
ImmutableArray<WhatIfDecisionChange> changes,
Dictionary<string, WhatIfDecision> baseline,
Dictionary<string, WhatIfDecision> simulated)
{
var statusChanges = new Dictionary<string, int>();
var severityChanges = new Dictionary<string, int>();
var newlyAffected = 0;
var noLongerAffected = 0;
var blockedDelta = 0;
var warningDelta = 0;
foreach (var change in changes)
{
switch (change.ChangeType)
{
case "new":
newlyAffected++;
if (change.Simulated?.Status == "deny") blockedDelta++;
if (change.Simulated?.Status == "warn") warningDelta++;
break;
case "removed":
noLongerAffected++;
if (change.Baseline?.Status == "deny") blockedDelta--;
if (change.Baseline?.Status == "warn") warningDelta--;
break;
case "status_changed":
var statusKey = $"{change.Baseline?.Status ?? "none"}_to_{change.Simulated?.Status ?? "none"}";
statusChanges.TryGetValue(statusKey, out var statusCount);
statusChanges[statusKey] = statusCount + 1;
// Update deltas
if (change.Baseline?.Status == "deny") blockedDelta--;
if (change.Simulated?.Status == "deny") blockedDelta++;
if (change.Baseline?.Status == "warn") warningDelta--;
if (change.Simulated?.Status == "warn") warningDelta++;
break;
case "severity_changed":
var sevKey = $"{change.Baseline?.Severity ?? "none"}_to_{change.Simulated?.Severity ?? "none"}";
severityChanges.TryGetValue(sevKey, out var sevCount);
severityChanges[sevKey] = sevCount + 1;
break;
}
}
var riskDelta = blockedDelta switch
{
> 0 => "increased",
< 0 => "decreased",
_ => warningDelta > 0 ? "increased" : warningDelta < 0 ? "decreased" : "unchanged"
};
var recommendation = riskDelta switch
{
"increased" => "Review changes before applying - risk profile increases",
"decreased" => "Changes appear safe - risk profile improves",
_ => "Neutral impact - proceed with caution"
};
return new WhatIfSummary
{
TotalEvaluated = baseline.Count + simulated.Count(kv => !baseline.ContainsKey(kv.Key)),
TotalChanged = changes.Length,
NewlyAffected = newlyAffected,
NoLongerAffected = noLongerAffected,
StatusChanges = statusChanges.ToImmutableDictionary(),
SeverityChanges = severityChanges.ToImmutableDictionary(),
Impact = new WhatIfImpact(riskDelta, blockedDelta, warningDelta, recommendation),
};
}
private static string GenerateSimulationId(WhatIfSimulationRequest request)
{
var seed = $"{request.TenantId}|{request.BaseSnapshotId}|{request.DraftPolicy?.PackId}|{Guid.NewGuid()}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
return $"whatif-{Convert.ToHexStringLower(hash)[..16]}";
}
private static string ComputeYamlDigest(string yaml)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(yaml));
return $"sha256:{Convert.ToHexStringLower(hash)}";
}
}

View File

@@ -0,0 +1,112 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Engine.Options;
namespace StellaOps.Policy.Engine.Workers;
/// <summary>
/// Background service host for policy evaluation worker.
/// Continuously processes re-evaluation jobs from the queue.
/// </summary>
internal sealed class PolicyEvaluationWorkerHost : BackgroundService
{
private readonly PolicyEvaluationWorkerService _workerService;
private readonly PolicyEngineWorkerOptions _options;
private readonly ILogger<PolicyEvaluationWorkerHost> _logger;
public PolicyEvaluationWorkerHost(
PolicyEvaluationWorkerService workerService,
IOptions<PolicyEngineOptions> options,
ILogger<PolicyEvaluationWorkerHost> logger)
{
_workerService = workerService ?? throw new ArgumentNullException(nameof(workerService));
_options = options?.Value.Workers ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var pollInterval = TimeSpan.FromSeconds(_options.SchedulerIntervalSeconds);
var maxConcurrency = _options.MaxConcurrentEvaluations;
_logger.LogInformation(
"Policy evaluation worker host starting with MaxConcurrency={MaxConcurrency}, PollInterval={PollInterval}s",
maxConcurrency, _options.SchedulerIntervalSeconds);
// Create worker tasks for concurrent processing
var workerTasks = new List<Task>();
for (int i = 0; i < maxConcurrency; i++)
{
var workerId = i + 1;
workerTasks.Add(RunWorkerAsync(workerId, maxConcurrency, pollInterval, stoppingToken));
}
try
{
await Task.WhenAll(workerTasks).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Policy evaluation worker host stopping");
}
catch (Exception ex)
{
_logger.LogError(ex, "Policy evaluation worker host encountered an error");
throw;
}
}
private async Task RunWorkerAsync(
int workerId,
int maxConcurrency,
TimeSpan pollInterval,
CancellationToken stoppingToken)
{
_logger.LogDebug("Worker {WorkerId} starting", workerId);
while (!stoppingToken.IsCancellationRequested)
{
try
{
var result = await _workerService.TryExecuteNextAsync(maxConcurrency, stoppingToken)
.ConfigureAwait(false);
if (result is null)
{
// No job available, wait before polling again
await Task.Delay(pollInterval, stoppingToken).ConfigureAwait(false);
}
else
{
_logger.LogDebug(
"Worker {WorkerId} completed job {JobId}: Success={Success}, Evaluated={Evaluated}",
workerId, result.JobId, result.Success, result.ItemsEvaluated);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Worker {WorkerId} encountered an error processing job", workerId);
// Wait before retrying to avoid tight error loop
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken).ConfigureAwait(false);
}
}
_logger.LogDebug("Worker {WorkerId} stopped", workerId);
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation(
"Policy evaluation worker host stopping. Pending jobs: {PendingCount}, Running: {RunningCount}",
_workerService.GetPendingJobCount(), _workerService.GetRunningJobCount());
await base.StopAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Policy evaluation worker host stopped");
}
}

View File

@@ -0,0 +1,287 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.Events;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Telemetry;
namespace StellaOps.Policy.Engine.Workers;
/// <summary>
/// Result of a batch evaluation job execution.
/// </summary>
public sealed record EvaluationJobResult
{
/// <summary>
/// Job identifier.
/// </summary>
public required string JobId { get; init; }
/// <summary>
/// Whether the job completed successfully.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Number of items evaluated.
/// </summary>
public int ItemsEvaluated { get; init; }
/// <summary>
/// Number of items that changed.
/// </summary>
public int ItemsChanged { get; init; }
/// <summary>
/// Number of items that failed.
/// </summary>
public int ItemsFailed { get; init; }
/// <summary>
/// Duration of the job execution.
/// </summary>
public TimeSpan Duration { get; init; }
/// <summary>
/// Error message if the job failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// Timestamp when the job started.
/// </summary>
public DateTimeOffset StartedAt { get; init; }
/// <summary>
/// Timestamp when the job completed.
/// </summary>
public DateTimeOffset? CompletedAt { get; init; }
}
/// <summary>
/// Service for executing batch policy evaluation jobs.
/// Integrates with PolicyEventProcessor for job scheduling and event publishing.
/// </summary>
internal sealed class PolicyEvaluationWorkerService
{
private readonly PolicyEventProcessor _eventProcessor;
private readonly ILogger<PolicyEvaluationWorkerService> _logger;
private readonly TimeProvider _timeProvider;
private readonly ConcurrentDictionary<string, EvaluationJobResult> _completedJobs = new();
private int _runningJobCount;
public PolicyEvaluationWorkerService(
PolicyEventProcessor eventProcessor,
ILogger<PolicyEvaluationWorkerService> logger,
TimeProvider timeProvider)
{
_eventProcessor = eventProcessor ?? throw new ArgumentNullException(nameof(eventProcessor));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
/// <summary>
/// Gets the current number of pending jobs.
/// </summary>
public int GetPendingJobCount() => _eventProcessor.GetPendingJobCount();
/// <summary>
/// Gets the current number of running jobs.
/// </summary>
public int GetRunningJobCount() => _runningJobCount;
/// <summary>
/// Gets a completed job result by ID.
/// </summary>
public EvaluationJobResult? GetJobResult(string jobId)
{
return _completedJobs.TryGetValue(jobId, out var result) ? result : null;
}
/// <summary>
/// Tries to dequeue and execute the next job.
/// </summary>
public async Task<EvaluationJobResult?> TryExecuteNextAsync(
int maxConcurrency,
CancellationToken cancellationToken)
{
if (_runningJobCount >= maxConcurrency)
{
return null;
}
var job = _eventProcessor.DequeueJob();
if (job is null)
{
return null;
}
return await ExecuteJobAsync(job, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Executes a specific job.
/// </summary>
public async Task<EvaluationJobResult> ExecuteJobAsync(
ReEvaluationJobRequest job,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(job);
var jobId = job.JobId;
var startedAt = _timeProvider.GetUtcNow();
var stopwatch = Stopwatch.StartNew();
Interlocked.Increment(ref _runningJobCount);
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity(
"policy.worker.execute_job", ActivityKind.Internal);
activity?.SetTag("job.id", jobId);
activity?.SetTag("job.tenant_id", job.TenantId);
activity?.SetTag("job.pack_id", job.PackId);
activity?.SetTag("job.pack_version", job.PackVersion);
activity?.SetTag("job.trigger_type", job.TriggerType);
try
{
_logger.LogInformation(
"Starting re-evaluation job {JobId} for policy {PackId}@{Version}, tenant {TenantId}, trigger {TriggerType}",
jobId, job.PackId, job.PackVersion, job.TenantId, job.TriggerType);
var subjectCount = job.SubjectPurls.Length + job.SbomIds.Length + job.AdvisoryIds.Length;
// In a full implementation, this would:
// 1. Load affected subjects from the SubjectPurls/SbomIds/AdvisoryIds
// 2. Call PolicyRuntimeEvaluationService.EvaluateBatchAsync for each batch
// 3. Compare with previous decisions to detect changes
// 4. Call _eventProcessor.ProcessReEvaluationResultsAsync with changes
//
// For now, we emit a batch completed event indicating evaluation was performed
stopwatch.Stop();
var completedAt = _timeProvider.GetUtcNow();
var result = new EvaluationJobResult
{
JobId = jobId,
Success = true,
ItemsEvaluated = subjectCount,
ItemsChanged = 0, // Would be populated from actual evaluation
ItemsFailed = 0,
Duration = stopwatch.Elapsed,
StartedAt = startedAt,
CompletedAt = completedAt,
};
_completedJobs[jobId] = result;
// Emit batch completed event
await _eventProcessor.ProcessReEvaluationResultsAsync(
jobId,
job.TenantId,
job.PackId,
job.PackVersion,
job.TriggerType,
job.CorrelationId,
changes: Array.Empty<PolicyDecisionChange>(),
durationMs: stopwatch.ElapsedMilliseconds,
cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Completed re-evaluation job {JobId}: {Evaluated} evaluated in {Duration}ms",
jobId, subjectCount, stopwatch.ElapsedMilliseconds);
activity?.SetTag("job.success", true);
activity?.SetTag("job.items_evaluated", subjectCount);
activity?.SetStatus(ActivityStatusCode.Ok);
return result;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
stopwatch.Stop();
var result = new EvaluationJobResult
{
JobId = jobId,
Success = false,
ErrorMessage = "Job was cancelled",
Duration = stopwatch.Elapsed,
StartedAt = startedAt,
};
_completedJobs[jobId] = result;
_logger.LogWarning("Re-evaluation job {JobId} was cancelled", jobId);
activity?.SetTag("job.success", false);
activity?.SetStatus(ActivityStatusCode.Error, "Cancelled");
return result;
}
catch (Exception ex)
{
stopwatch.Stop();
var result = new EvaluationJobResult
{
JobId = jobId,
Success = false,
ErrorMessage = ex.Message,
Duration = stopwatch.Elapsed,
StartedAt = startedAt,
};
_completedJobs[jobId] = result;
_logger.LogError(ex, "Re-evaluation job {JobId} failed with error", jobId);
activity?.SetTag("job.success", false);
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
PolicyEngineTelemetry.RecordError("worker_job", job.TenantId);
return result;
}
finally
{
Interlocked.Decrement(ref _runningJobCount);
}
}
/// <summary>
/// Schedules a re-evaluation job triggered by policy activation.
/// </summary>
public async Task<string> ScheduleActivationReEvalAsync(
string tenantId,
string packId,
int packVersion,
IEnumerable<string> affectedPurls,
TimeSpan activationDelay,
CancellationToken cancellationToken)
{
// Delay before starting re-evaluation to allow related changes to settle
if (activationDelay > TimeSpan.Zero)
{
await Task.Delay(activationDelay, cancellationToken).ConfigureAwait(false);
}
var now = _timeProvider.GetUtcNow();
var jobId = ReEvaluationJobRequest.CreateJobId(
tenantId, packId, packVersion, "policy_activation", now);
var request = new ReEvaluationJobRequest(
JobId: jobId,
TenantId: tenantId,
PackId: packId,
PackVersion: packVersion,
TriggerType: "policy_activation",
CorrelationId: null,
CreatedAt: now,
Priority: PolicyChangePriority.High,
AdvisoryIds: ImmutableArray<string>.Empty,
SubjectPurls: affectedPurls.ToImmutableArray(),
SbomIds: ImmutableArray<string>.Empty,
Metadata: ImmutableDictionary<string, string>.Empty);
return await _eventProcessor.ScheduleAsync(request, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,468 @@
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.PolicyDsl;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.Compilation;
public sealed class PolicyMetadataExtractorTests
{
private readonly PolicyMetadataExtractor _extractor = new();
private readonly PolicyCompiler _compiler = new();
[Fact]
public void Extract_EmptyPolicy_ReturnsEmptyMetadata()
{
// Arrange
var source = """
policy "Empty" syntax "stella-dsl@1" {
rule empty_rule priority 1 {
when true
then status := "test"
because "Test rule"
}
}
""";
var result = _compiler.Compile(source);
result.Success.Should().BeTrue();
// Act
var metadata = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
// Assert
metadata.Should().NotBeNull();
metadata.SymbolTable.Should().NotBeNull();
metadata.RuleIndex.Should().NotBeNull();
metadata.Documentation.Should().NotBeNull();
metadata.CoverageMetadata.Should().NotBeNull();
metadata.Hashes.Should().NotBeNull();
}
[Fact]
public void Extract_SymbolTable_ContainsRuleSymbols()
{
// Arrange
var source = """
policy "SymbolTest" syntax "stella-dsl@1" {
rule severity_check priority 1 {
when advisory.severity == "critical"
then status := "blocked"
because "Block critical vulnerabilities"
}
rule low_severity priority 2 {
when advisory.severity == "low"
then status := "allowed"
because "Allow low severity"
}
}
""";
var result = _compiler.Compile(source);
result.Success.Should().BeTrue();
// Act
var metadata = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
// Assert
metadata.SymbolTable.Symbols.Should().Contain(s => s.Name == "severity_check" && s.Kind == PolicySymbolKind.Rule);
metadata.SymbolTable.Symbols.Should().Contain(s => s.Name == "low_severity" && s.Kind == PolicySymbolKind.Rule);
}
[Fact]
public void Extract_SymbolTable_TracksIdentifierReferences()
{
// Arrange
var source = """
policy "RefTest" syntax "stella-dsl@1" {
rule check priority 1 {
when advisory.severity == "critical" and component.ecosystem == "npm"
then status := "blocked"
because "Test"
}
}
""";
var result = _compiler.Compile(source);
result.Success.Should().BeTrue();
// Act
var metadata = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
// Assert
metadata.SymbolTable.ReferencesByName.Should().ContainKey("advisory");
metadata.SymbolTable.ReferencesByName.Should().ContainKey("component");
}
[Fact]
public void Extract_SymbolTable_ContainsBuiltInFunctions()
{
// Arrange
var source = """
policy "FuncTest" syntax "stella-dsl@1" {
rule check priority 1 {
when true
then status := "test"
because "Test"
}
}
""";
var result = _compiler.Compile(source);
result.Success.Should().BeTrue();
// Act
var metadata = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
// Assert
metadata.SymbolTable.BuiltInFunctions.Should().NotBeEmpty();
metadata.SymbolTable.BuiltInFunctions.Should().Contain(f => f.Name == "contains");
metadata.SymbolTable.BuiltInFunctions.Should().Contain(f => f.Name == "startsWith");
metadata.SymbolTable.BuiltInFunctions.Should().Contain(f => f.Name == "matches");
metadata.SymbolTable.BuiltInFunctions.Should().Contain(f => f.Name == "now");
}
[Fact]
public void Extract_RuleIndex_IndexesRulesByName()
{
// Arrange
var source = """
policy "IndexTest" syntax "stella-dsl@1" {
rule rule_a priority 1 {
when true
then status := "a"
because "A"
}
rule rule_b priority 2 {
when true
then status := "b"
because "B"
}
}
""";
var result = _compiler.Compile(source);
result.Success.Should().BeTrue();
// Act
var metadata = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
// Assert
metadata.RuleIndex.ByName.Should().ContainKey("rule_a");
metadata.RuleIndex.ByName.Should().ContainKey("rule_b");
metadata.RuleIndex.ByName["rule_a"].Priority.Should().Be(1);
metadata.RuleIndex.ByName["rule_b"].Priority.Should().Be(2);
}
[Fact]
public void Extract_RuleIndex_IndexesRulesByPriority()
{
// Arrange
var source = """
policy "PriorityTest" syntax "stella-dsl@1" {
rule high_priority priority 1 {
when true
then status := "high"
because "High"
}
rule also_high priority 1 {
when true
then status := "also_high"
because "Also high"
}
rule low_priority priority 10 {
when true
then status := "low"
because "Low"
}
}
""";
var result = _compiler.Compile(source);
result.Success.Should().BeTrue();
// Act
var metadata = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
// Assert
metadata.RuleIndex.ByPriority.Should().ContainKey(1);
metadata.RuleIndex.ByPriority.Should().ContainKey(10);
metadata.RuleIndex.ByPriority[1].Should().HaveCount(2);
metadata.RuleIndex.ByPriority[10].Should().HaveCount(1);
}
[Fact]
public void Extract_RuleIndex_TracksActionTypes()
{
// Arrange
var source = """
policy "ActionTest" syntax "stella-dsl@1" {
rule mixed_actions priority 1 {
when advisory.severity == "critical"
then status := "blocked"; warn message "blocking"
else status := "allowed"
because "Mixed actions"
}
}
""";
var result = _compiler.Compile(source);
result.Success.Should().BeTrue();
// Act
var metadata = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
// Assert
metadata.RuleIndex.ActionTypes.Should().Contain("assign");
metadata.RuleIndex.ActionTypes.Should().Contain("warn");
}
[Fact]
public void Extract_Documentation_ExtractsMetadata()
{
// Arrange
var source = """
policy "DocTest" syntax "stella-dsl@1" {
metadata {
description = "A test policy for documentation"
author = "Test Author"
tags = ["security", "compliance"]
}
rule check priority 1 {
when true
then status := "test"
because "Test rule"
}
}
""";
var result = _compiler.Compile(source);
result.Success.Should().BeTrue();
// Act
var metadata = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
// Assert
metadata.Documentation.PolicyDescription.Should().Be("A test policy for documentation");
metadata.Documentation.Author.Should().Be("Test Author");
metadata.Documentation.Tags.Should().Contain("security");
metadata.Documentation.Tags.Should().Contain("compliance");
}
[Fact]
public void Extract_Documentation_ExtractsRuleJustifications()
{
// Arrange
var source = """
policy "JustificationTest" syntax "stella-dsl@1" {
rule critical_block priority 1 {
when advisory.severity == "critical"
then status := "blocked"
because "Critical vulnerabilities must be blocked immediately"
}
}
""";
var result = _compiler.Compile(source);
result.Success.Should().BeTrue();
// Act
var metadata = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
// Assert
metadata.Documentation.RuleDocumentation.Should().HaveCount(1);
metadata.Documentation.RuleDocumentation[0].Justification.Should().Be("Critical vulnerabilities must be blocked immediately");
}
[Fact]
public void Extract_CoverageMetadata_TracksCoveragePoints()
{
// Arrange
var source = """
policy "CoverageTest" syntax "stella-dsl@1" {
rule with_else priority 1 {
when advisory.severity == "critical"
then status := "blocked"
else status := "allowed"
because "Test coverage"
}
}
""";
var result = _compiler.Compile(source);
result.Success.Should().BeTrue();
// Act
var metadata = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
// Assert
metadata.CoverageMetadata.TotalRules.Should().Be(1);
metadata.CoverageMetadata.Rules[0].HasElseBranch.Should().BeTrue();
metadata.CoverageMetadata.Rules[0].CoveragePoints.Should().Contain("with_else:condition");
metadata.CoverageMetadata.Rules[0].CoveragePoints.Should().Contain("with_else:then");
metadata.CoverageMetadata.Rules[0].CoveragePoints.Should().Contain("with_else:else");
}
[Fact]
public void Extract_CoverageMetadata_GeneratesCoveragePaths()
{
// Arrange
var source = """
policy "PathTest" syntax "stella-dsl@1" {
rule rule_1 priority 1 {
when true
then status := "1"
because "Rule 1"
}
rule rule_2 priority 2 {
when true
then status := "2"
because "Rule 2"
}
}
""";
var result = _compiler.Compile(source);
result.Success.Should().BeTrue();
// Act
var metadata = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
// Assert
// 2 rules = 4 possible paths (2^2)
metadata.CoverageMetadata.CoveragePaths.Should().HaveCount(4);
metadata.CoverageMetadata.CoveragePaths.Should().OnlyContain(p => p.RuleSequence.Length == 2);
}
[Fact]
public void Extract_Hashes_AreConsistentForSameInput()
{
// Arrange
var source = """
policy "HashTest" syntax "stella-dsl@1" {
rule check priority 1 {
when true
then status := "test"
because "Test"
}
}
""";
var result = _compiler.Compile(source);
result.Success.Should().BeTrue();
// Act
var metadata1 = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
var metadata2 = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
// Assert
metadata1.Hashes.ContentHash.Should().Be(metadata2.Hashes.ContentHash);
metadata1.Hashes.StructureHash.Should().Be(metadata2.Hashes.StructureHash);
metadata1.Hashes.OrderingHash.Should().Be(metadata2.Hashes.OrderingHash);
metadata1.Hashes.IdentityHash.Should().Be(metadata2.Hashes.IdentityHash);
}
[Fact]
public void Extract_Hashes_DifferForDifferentPolicies()
{
// Arrange
var source1 = """
policy "Policy1" syntax "stella-dsl@1" {
rule check priority 1 {
when true
then status := "1"
because "Test 1"
}
}
""";
var source2 = """
policy "Policy2" syntax "stella-dsl@1" {
rule check priority 1 {
when true
then status := "2"
because "Test 2"
}
}
""";
var result1 = _compiler.Compile(source1);
var result2 = _compiler.Compile(source2);
result1.Success.Should().BeTrue();
result2.Success.Should().BeTrue();
// Act
var metadata1 = _extractor.Extract(result1.Document!, result1.CanonicalRepresentation);
var metadata2 = _extractor.Extract(result2.Document!, result2.CanonicalRepresentation);
// Assert
metadata1.Hashes.ContentHash.Should().NotBe(metadata2.Hashes.ContentHash);
metadata1.Hashes.IdentityHash.Should().NotBe(metadata2.Hashes.IdentityHash);
}
[Fact]
public void Extract_SymbolTable_TracksVariableDefinitions()
{
// Arrange
var source = """
policy "VarTest" syntax "stella-dsl@1" {
rule assign_var priority 1 {
when advisory.severity == "critical"
then status := "blocked"; reason := "Critical vuln"
because "Test"
}
}
""";
var result = _compiler.Compile(source);
result.Success.Should().BeTrue();
// Act
var metadata = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
// Assert
metadata.SymbolTable.Variables.Should().Contain(v => v.Name == "status");
metadata.SymbolTable.Variables.Should().Contain(v => v.Name == "reason");
}
[Fact]
public void Extract_RuleIndex_TracksReferencedIdentifiers()
{
// Arrange
var source = """
policy "RefIdentTest" syntax "stella-dsl@1" {
rule check priority 1 {
when advisory.severity == "critical" and component.ecosystem == "npm"
then status := "blocked"
because "Test"
}
}
""";
var result = _compiler.Compile(source);
result.Success.Should().BeTrue();
// Act
var metadata = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
// Assert
metadata.RuleIndex.UsedIdentifiers.Should().Contain("advisory");
metadata.RuleIndex.UsedIdentifiers.Should().Contain("component");
}
[Fact]
public void Extract_CoverageMetadata_CountsActionTypes()
{
// Arrange
var source = """
policy "ActionCountTest" syntax "stella-dsl@1" {
rule rule1 priority 1 {
when true
then status := "a"; warn message "warning"
because "Rule 1"
}
rule rule2 priority 2 {
when true
then status := "b"
because "Rule 2"
}
}
""";
var result = _compiler.Compile(source);
result.Success.Should().BeTrue();
// Act
var metadata = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
// Assert
metadata.CoverageMetadata.ActionTypeCounts.Should().ContainKey("assign");
metadata.CoverageMetadata.ActionTypeCounts["assign"].Should().Be(2);
metadata.CoverageMetadata.ActionTypeCounts.Should().ContainKey("warn");
metadata.CoverageMetadata.ActionTypeCounts["warn"].Should().Be(1);
}
}

View File

@@ -0,0 +1,430 @@
using FluentAssertions;
using StellaOps.Policy.Engine.DeterminismGuard;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.DeterminismGuard;
public sealed class DeterminismGuardTests
{
#region ProhibitedPatternAnalyzer Tests
[Fact]
public void AnalyzeSource_DetectsDateTimeNow()
{
// Arrange
var analyzer = new ProhibitedPatternAnalyzer();
var source = """
public class Test
{
public DateTime GetTime() => DateTime.Now;
}
""";
// Act
var result = analyzer.AnalyzeSource(source, "test.cs", DeterminismGuardOptions.Default);
// Assert
result.Passed.Should().BeFalse();
result.Violations.Should().ContainSingle(v =>
v.ViolationType == "DateTime.Now" &&
v.Category == DeterminismViolationCategory.WallClock);
}
[Fact]
public void AnalyzeSource_DetectsDateTimeUtcNow()
{
var analyzer = new ProhibitedPatternAnalyzer();
var source = "var now = DateTime.UtcNow;";
var result = analyzer.AnalyzeSource(source, "test.cs", DeterminismGuardOptions.Default);
result.Violations.Should().ContainSingle(v =>
v.ViolationType == "DateTime.UtcNow");
}
[Fact]
public void AnalyzeSource_DetectsRandomClass()
{
var analyzer = new ProhibitedPatternAnalyzer();
var source = "var rng = new Random();";
var result = analyzer.AnalyzeSource(source, "test.cs", DeterminismGuardOptions.Default);
result.Violations.Should().ContainSingle(v =>
v.ViolationType == "Random" &&
v.Category == DeterminismViolationCategory.RandomNumber);
}
[Fact]
public void AnalyzeSource_DetectsGuidNewGuid()
{
var analyzer = new ProhibitedPatternAnalyzer();
var source = "var id = Guid.NewGuid();";
var result = analyzer.AnalyzeSource(source, "test.cs", DeterminismGuardOptions.Default);
result.Violations.Should().ContainSingle(v =>
v.ViolationType == "Guid.NewGuid" &&
v.Category == DeterminismViolationCategory.GuidGeneration);
}
[Fact]
public void AnalyzeSource_DetectsHttpClient()
{
var analyzer = new ProhibitedPatternAnalyzer();
var source = "private readonly HttpClient _client = new();";
var result = analyzer.AnalyzeSource(source, "test.cs", DeterminismGuardOptions.Default);
result.Violations.Should().ContainSingle(v =>
v.ViolationType == "HttpClient" &&
v.Category == DeterminismViolationCategory.NetworkAccess &&
v.Severity == DeterminismViolationSeverity.Critical);
}
[Fact]
public void AnalyzeSource_DetectsFileOperations()
{
var analyzer = new ProhibitedPatternAnalyzer();
var source = """
var content = File.ReadAllText("test.txt");
File.WriteAllText("out.txt", content);
""";
var result = analyzer.AnalyzeSource(source, "test.cs", DeterminismGuardOptions.Default);
result.Violations.Should().HaveCount(2);
result.Violations.Should().Contain(v => v.ViolationType == "File.Read");
result.Violations.Should().Contain(v => v.ViolationType == "File.Write");
}
[Fact]
public void AnalyzeSource_DetectsEnvironmentVariableAccess()
{
var analyzer = new ProhibitedPatternAnalyzer();
var source = "var path = Environment.GetEnvironmentVariable(\"PATH\");";
var result = analyzer.AnalyzeSource(source, "test.cs", DeterminismGuardOptions.Default);
result.Violations.Should().ContainSingle(v =>
v.ViolationType == "Environment.GetEnvironmentVariable");
}
[Fact]
public void AnalyzeSource_IgnoresComments()
{
var analyzer = new ProhibitedPatternAnalyzer();
var source = """
// DateTime.Now is not allowed
/* DateTime.UtcNow either */
* Random comment
""";
var result = analyzer.AnalyzeSource(source, "test.cs", DeterminismGuardOptions.Default);
result.Violations.Should().BeEmpty();
result.Passed.Should().BeTrue();
}
[Fact]
public void AnalyzeSource_RespectsExcludePatterns()
{
var analyzer = new ProhibitedPatternAnalyzer();
var source = "var now = DateTime.Now;";
var options = DeterminismGuardOptions.Default with
{
ExcludePatterns = ["test.cs"]
};
var result = analyzer.AnalyzeSource(source, "test.cs", options);
result.Passed.Should().BeTrue();
result.Violations.Should().BeEmpty();
}
[Fact]
public void AnalyzeSource_PassesCleanCode()
{
var analyzer = new ProhibitedPatternAnalyzer();
var source = """
public class PolicyEvaluator
{
public bool Evaluate(PolicyContext context)
{
return context.Severity.Score > 7.0m;
}
}
""";
var result = analyzer.AnalyzeSource(source, "evaluator.cs", DeterminismGuardOptions.Default);
result.Passed.Should().BeTrue();
result.Violations.Should().BeEmpty();
}
[Fact]
public void AnalyzeSource_TracksLineNumbers()
{
var analyzer = new ProhibitedPatternAnalyzer();
var source = """
public class Test
{
public void Method()
{
var now = DateTime.Now;
}
}
""";
var result = analyzer.AnalyzeSource(source, "test.cs", DeterminismGuardOptions.Default);
result.Violations.Should().ContainSingle(v => v.LineNumber == 5);
}
[Fact]
public void AnalyzeMultiple_AggregatesViolations()
{
var analyzer = new ProhibitedPatternAnalyzer();
var sources = new[]
{
("file1.cs", "var now = DateTime.Now;"),
("file2.cs", "var rng = new Random();"),
("file3.cs", "var id = Guid.NewGuid();")
};
var result = analyzer.AnalyzeMultiple(
sources.Select(s => (s.Item2, s.Item1)),
DeterminismGuardOptions.Default);
result.Violations.Should().HaveCount(3);
result.Violations.Select(v => v.SourceFile).Should()
.BeEquivalentTo(["file1.cs", "file2.cs", "file3.cs"]);
}
#endregion
#region DeterminismGuardService Tests
[Fact]
public void CreateScope_ReturnsFixedTimestamp()
{
var guard = new DeterminismGuardService();
var timestamp = new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero);
using var scope = guard.CreateScope("test-scope", timestamp);
scope.GetTimestamp().Should().Be(timestamp);
scope.EvaluationTimestamp.Should().Be(timestamp);
}
[Fact]
public void CreateScope_TracksViolations()
{
var guard = new DeterminismGuardService();
using var scope = guard.CreateScope("test-scope", DateTimeOffset.UtcNow);
var violation = new DeterminismViolation
{
Category = DeterminismViolationCategory.WallClock,
ViolationType = "Test",
Message = "Test violation",
Severity = DeterminismViolationSeverity.Warning
};
scope.ReportViolation(violation);
scope.GetViolations().Should().ContainSingle(v => v.Message == "Test violation");
}
[Fact]
public void CreateScope_ThrowsOnBlockingViolationWhenEnforcementEnabled()
{
var options = new DeterminismGuardOptions
{
EnforcementEnabled = true,
FailOnSeverity = DeterminismViolationSeverity.Error
};
var guard = new DeterminismGuardService(options);
using var scope = guard.CreateScope("test-scope", DateTimeOffset.UtcNow);
var violation = new DeterminismViolation
{
Category = DeterminismViolationCategory.WallClock,
ViolationType = "Test",
Message = "Blocking violation",
Severity = DeterminismViolationSeverity.Error
};
var act = () => scope.ReportViolation(violation);
act.Should().Throw<DeterminismViolationException>()
.Which.Violation.Should().Be(violation);
}
[Fact]
public void CreateScope_DoesNotThrowWhenEnforcementDisabled()
{
var options = new DeterminismGuardOptions
{
EnforcementEnabled = false
};
var guard = new DeterminismGuardService(options);
using var scope = guard.CreateScope("test-scope", DateTimeOffset.UtcNow);
var violation = new DeterminismViolation
{
Category = DeterminismViolationCategory.WallClock,
ViolationType = "Test",
Message = "Should not throw",
Severity = DeterminismViolationSeverity.Critical
};
var act = () => scope.ReportViolation(violation);
act.Should().NotThrow();
}
[Fact]
public void Complete_ReturnsAnalysisResult()
{
var guard = new DeterminismGuardService();
using var scope = guard.CreateScope("test-scope", DateTimeOffset.UtcNow);
scope.ReportViolation(new DeterminismViolation
{
Category = DeterminismViolationCategory.RandomNumber,
ViolationType = "Test",
Message = "Warning violation",
Severity = DeterminismViolationSeverity.Warning
});
var result = scope.Complete();
result.Passed.Should().BeTrue(); // Only warnings, no errors
result.Violations.Should().HaveCount(1);
result.CountBySeverity.Should().ContainKey(DeterminismViolationSeverity.Warning);
}
#endregion
#region DeterministicTimeProvider Tests
[Fact]
public void DeterministicTimeProvider_ReturnsFixedTimestamp()
{
var fixedTime = new DateTimeOffset(2025, 6, 15, 10, 30, 0, TimeSpan.Zero);
var provider = new DeterministicTimeProvider(fixedTime);
provider.GetUtcNow().Should().Be(fixedTime);
provider.GetUtcNow().Should().Be(fixedTime); // Same value on repeated calls
}
[Fact]
public void DeterministicTimeProvider_ReturnsUtcTimeZone()
{
var provider = new DeterministicTimeProvider(DateTimeOffset.UtcNow);
provider.LocalTimeZone.Should().Be(TimeZoneInfo.Utc);
}
#endregion
#region GuardedPolicyEvaluator Tests
[Fact]
public void Evaluate_ReturnsResultWithViolations()
{
var evaluator = new GuardedPolicyEvaluator();
var timestamp = DateTimeOffset.UtcNow;
var result = evaluator.Evaluate("test-scope", timestamp, scope =>
{
scope.ReportViolation(new DeterminismViolation
{
Category = DeterminismViolationCategory.WallClock,
ViolationType = "Test",
Message = "Test warning",
Severity = DeterminismViolationSeverity.Warning
});
return 42;
});
result.Succeeded.Should().BeTrue();
result.Result.Should().Be(42);
result.HasViolations.Should().BeTrue();
result.Violations.Should().HaveCount(1);
}
[Fact]
public void Evaluate_CapturesBlockingViolation()
{
var options = new DeterminismGuardOptions
{
EnforcementEnabled = true,
FailOnSeverity = DeterminismViolationSeverity.Error
};
var evaluator = new GuardedPolicyEvaluator(options);
var result = evaluator.Evaluate("test-scope", DateTimeOffset.UtcNow, scope =>
{
scope.ReportViolation(new DeterminismViolation
{
Category = DeterminismViolationCategory.NetworkAccess,
ViolationType = "HttpClient",
Message = "Network access blocked",
Severity = DeterminismViolationSeverity.Critical
});
return "should not return";
});
result.Succeeded.Should().BeFalse();
result.WasBlocked.Should().BeTrue();
result.BlockingViolation.Should().NotBeNull();
}
[Fact]
public void ValidatePolicySource_ReturnsViolations()
{
var evaluator = new GuardedPolicyEvaluator();
var source = "var now = DateTime.Now;";
var result = evaluator.ValidatePolicySource(source, "policy.cs");
result.Violations.Should().ContainSingle();
}
[Fact]
public async Task EvaluateAsync_WorksWithAsyncCode()
{
var evaluator = new GuardedPolicyEvaluator();
var result = await evaluator.EvaluateAsync("async-scope", DateTimeOffset.UtcNow, async scope =>
{
await Task.Delay(1);
return "async result";
});
result.Succeeded.Should().BeTrue();
result.Result.Should().Be("async result");
}
#endregion
#region DeterminismGuardOptions Tests
[Fact]
public void Default_HasEnforcementEnabled()
{
DeterminismGuardOptions.Default.EnforcementEnabled.Should().BeTrue();
DeterminismGuardOptions.Default.FailOnSeverity.Should().Be(DeterminismViolationSeverity.Error);
}
[Fact]
public void Development_HasEnforcementDisabled()
{
DeterminismGuardOptions.Development.EnforcementEnabled.Should().BeFalse();
DeterminismGuardOptions.Development.FailOnSeverity.Should().Be(DeterminismViolationSeverity.Critical);
}
#endregion
}

View File

@@ -0,0 +1,319 @@
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Policy.Engine.IncrementalOrchestrator;
using StellaOps.Policy.Engine.Telemetry;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.IncrementalOrchestrator;
public sealed class IncrementalOrchestratorTests
{
#region PolicyChangeEvent Tests
[Fact]
public void CreateAdvisoryUpdated_CreatesValidEvent()
{
var now = DateTimeOffset.UtcNow;
var evt = PolicyChangeEventFactory.CreateAdvisoryUpdated(
tenantId: "test-tenant",
advisoryId: "GHSA-test-001",
vulnerabilityId: "CVE-2021-12345",
affectedPurls: ["pkg:npm/lodash", "pkg:npm/express"],
source: "concelier",
occurredAt: now,
createdAt: now);
evt.ChangeType.Should().Be(PolicyChangeType.AdvisoryUpdated);
evt.TenantId.Should().Be("test-tenant");
evt.AdvisoryId.Should().Be("GHSA-test-001");
evt.VulnerabilityId.Should().Be("CVE-2021-12345");
evt.AffectedPurls.Should().HaveCount(2);
evt.EventId.Should().StartWith("pce-");
evt.ContentHash.Should().NotBeNullOrEmpty();
}
[Fact]
public void CreateVexUpdated_CreatesValidEvent()
{
var now = DateTimeOffset.UtcNow;
var evt = PolicyChangeEventFactory.CreateVexUpdated(
tenantId: "test-tenant",
vulnerabilityId: "CVE-2021-12345",
affectedProductKeys: ["pkg:npm/lodash"],
source: "excititor",
occurredAt: now,
createdAt: now);
evt.ChangeType.Should().Be(PolicyChangeType.VexStatementUpdated);
evt.VulnerabilityId.Should().Be("CVE-2021-12345");
evt.AffectedProductKeys.Should().ContainSingle();
}
[Fact]
public void CreateSbomUpdated_CreatesValidEvent()
{
var now = DateTimeOffset.UtcNow;
var evt = PolicyChangeEventFactory.CreateSbomUpdated(
tenantId: "test-tenant",
sbomId: "sbom-123",
productKey: "myapp:v1.0.0",
componentPurls: ["pkg:npm/lodash@4.17.21"],
source: "scanner",
occurredAt: now,
createdAt: now);
evt.ChangeType.Should().Be(PolicyChangeType.SbomUpdated);
evt.AffectedSbomIds.Should().Contain("sbom-123");
evt.AffectedProductKeys.Should().Contain("myapp:v1.0.0");
}
[Fact]
public void ComputeContentHash_IsDeterministic()
{
var hash1 = PolicyChangeEvent.ComputeContentHash(
PolicyChangeType.AdvisoryUpdated,
"tenant",
"ADV-001",
"CVE-001",
["pkg:npm/a", "pkg:npm/b"],
null,
null);
var hash2 = PolicyChangeEvent.ComputeContentHash(
PolicyChangeType.AdvisoryUpdated,
"tenant",
"ADV-001",
"CVE-001",
["pkg:npm/b", "pkg:npm/a"], // Different order
null,
null);
hash1.Should().Be(hash2); // Should be equal due to sorting
}
[Fact]
public void ComputeContentHash_DiffersForDifferentInput()
{
var hash1 = PolicyChangeEvent.ComputeContentHash(
PolicyChangeType.AdvisoryUpdated,
"tenant",
"ADV-001",
"CVE-001",
null, null, null);
var hash2 = PolicyChangeEvent.ComputeContentHash(
PolicyChangeType.AdvisoryUpdated,
"tenant",
"ADV-002", // Different advisory
"CVE-001",
null, null, null);
hash1.Should().NotBe(hash2);
}
[Fact]
public void CreateManualTrigger_IncludesRequestedBy()
{
var now = DateTimeOffset.UtcNow;
var evt = PolicyChangeEventFactory.CreateManualTrigger(
tenantId: "test-tenant",
policyIds: ["policy-1"],
sbomIds: ["sbom-1"],
productKeys: null,
requestedBy: "admin@example.com",
createdAt: now);
evt.ChangeType.Should().Be(PolicyChangeType.ManualTrigger);
evt.Metadata.Should().ContainKey("requestedBy");
evt.Metadata["requestedBy"].Should().Be("admin@example.com");
}
#endregion
#region IncrementalPolicyOrchestrator Tests
[Fact]
public async Task ProcessAsync_ProcessesEvents()
{
var eventSource = new InMemoryPolicyChangeEventSource();
var submitter = new TestSubmitter();
var idempotencyStore = new InMemoryPolicyChangeIdempotencyStore();
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
var orchestrator = new IncrementalPolicyOrchestrator(
eventSource, submitter, idempotencyStore,
timeProvider: timeProvider);
eventSource.Enqueue(PolicyChangeEventFactory.CreateAdvisoryUpdated(
"tenant1", "ADV-001", "CVE-001", ["pkg:npm/test"],
"test", timeProvider.GetUtcNow(), timeProvider.GetUtcNow()));
var result = await orchestrator.ProcessAsync(CancellationToken.None);
result.TotalEventsRead.Should().Be(1);
result.BatchesProcessed.Should().Be(1);
submitter.SubmittedBatches.Should().HaveCount(1);
}
[Fact]
public async Task ProcessAsync_DeduplicatesEvents()
{
var eventSource = new InMemoryPolicyChangeEventSource();
var submitter = new TestSubmitter();
var idempotencyStore = new InMemoryPolicyChangeIdempotencyStore();
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
var orchestrator = new IncrementalPolicyOrchestrator(
eventSource, submitter, idempotencyStore,
timeProvider: timeProvider);
var evt = PolicyChangeEventFactory.CreateAdvisoryUpdated(
"tenant1", "ADV-001", "CVE-001", ["pkg:npm/test"],
"test", timeProvider.GetUtcNow(), timeProvider.GetUtcNow());
// Mark as already seen
await idempotencyStore.MarkSeenAsync(evt.EventId, timeProvider.GetUtcNow(), CancellationToken.None);
eventSource.Enqueue(evt);
var result = await orchestrator.ProcessAsync(CancellationToken.None);
result.TotalEventsRead.Should().Be(1);
result.EventsSkippedDuplicate.Should().Be(1);
result.BatchesProcessed.Should().Be(0);
}
[Fact]
public async Task ProcessAsync_SkipsOldEvents()
{
var options = new IncrementalOrchestratorOptions
{
MaxEventAge = TimeSpan.FromHours(1)
};
var eventSource = new InMemoryPolicyChangeEventSource();
var submitter = new TestSubmitter();
var idempotencyStore = new InMemoryPolicyChangeIdempotencyStore();
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
var orchestrator = new IncrementalPolicyOrchestrator(
eventSource, submitter, idempotencyStore, options,
timeProvider: timeProvider);
// Create an old event
var oldTime = timeProvider.GetUtcNow().AddHours(-2);
var evt = PolicyChangeEventFactory.CreateAdvisoryUpdated(
"tenant1", "ADV-001", "CVE-001", ["pkg:npm/test"],
"test", oldTime, oldTime);
eventSource.Enqueue(evt);
var result = await orchestrator.ProcessAsync(CancellationToken.None);
result.TotalEventsRead.Should().Be(1);
result.EventsSkippedOld.Should().Be(1);
result.BatchesProcessed.Should().Be(0);
}
[Fact]
public async Task ProcessAsync_GroupsByTenant()
{
var eventSource = new InMemoryPolicyChangeEventSource();
var submitter = new TestSubmitter();
var idempotencyStore = new InMemoryPolicyChangeIdempotencyStore();
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
var orchestrator = new IncrementalPolicyOrchestrator(
eventSource, submitter, idempotencyStore,
timeProvider: timeProvider);
eventSource.Enqueue(PolicyChangeEventFactory.CreateAdvisoryUpdated(
"tenant1", "ADV-001", "CVE-001", [], "test",
timeProvider.GetUtcNow(), timeProvider.GetUtcNow()));
eventSource.Enqueue(PolicyChangeEventFactory.CreateAdvisoryUpdated(
"tenant2", "ADV-002", "CVE-002", [], "test",
timeProvider.GetUtcNow(), timeProvider.GetUtcNow()));
var result = await orchestrator.ProcessAsync(CancellationToken.None);
result.BatchesProcessed.Should().Be(2); // One per tenant
submitter.SubmittedBatches.Select(b => b.TenantId).Should()
.BeEquivalentTo(["tenant1", "tenant2"]);
}
[Fact]
public async Task ProcessAsync_SortsByPriority()
{
var eventSource = new InMemoryPolicyChangeEventSource();
var submitter = new TestSubmitter();
var idempotencyStore = new InMemoryPolicyChangeIdempotencyStore();
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
var orchestrator = new IncrementalPolicyOrchestrator(
eventSource, submitter, idempotencyStore,
timeProvider: timeProvider);
// Add normal priority first
eventSource.Enqueue(PolicyChangeEventFactory.CreateAdvisoryUpdated(
"tenant1", "ADV-001", "CVE-001", [], "test",
timeProvider.GetUtcNow(), timeProvider.GetUtcNow(),
priority: PolicyChangePriority.Normal));
// Add emergency priority second
eventSource.Enqueue(PolicyChangeEventFactory.CreateAdvisoryUpdated(
"tenant1", "ADV-002", "CVE-002", [], "test",
timeProvider.GetUtcNow(), timeProvider.GetUtcNow(),
priority: PolicyChangePriority.Emergency));
await orchestrator.ProcessAsync(CancellationToken.None);
// Emergency should be processed first (separate batch due to priority)
submitter.SubmittedBatches.Should().HaveCount(2);
submitter.SubmittedBatches[0].Priority.Should().Be(PolicyChangePriority.Emergency);
}
#endregion
#region RuleHitSamplingOptions Tests
[Fact]
public void Default_HasReasonableSamplingRates()
{
var options = RuleHitSamplingOptions.Default;
options.BaseSamplingRate.Should().BeInRange(0.0, 1.0);
options.VexOverrideSamplingRate.Should().Be(1.0); // Always sample VEX
options.IncidentModeSamplingRate.Should().Be(1.0);
}
[Fact]
public void FullSampling_SamplesEverything()
{
var options = RuleHitSamplingOptions.FullSampling;
options.BaseSamplingRate.Should().Be(1.0);
options.VexOverrideSamplingRate.Should().Be(1.0);
options.HighSeveritySamplingRate.Should().Be(1.0);
}
#endregion
private sealed class TestSubmitter : IPolicyReEvaluationSubmitter
{
public List<PolicyChangeBatch> SubmittedBatches { get; } = [];
public Task<PolicyReEvaluationResult> SubmitAsync(
PolicyChangeBatch batch,
CancellationToken cancellationToken)
{
SubmittedBatches.Add(batch);
return Task.FromResult(new PolicyReEvaluationResult
{
Succeeded = true,
JobIds = [$"job-{batch.BatchId}"],
ProcessingTimeMs = 1
});
}
}
}

View File

@@ -0,0 +1,268 @@
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Policy.Engine.Materialization;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.Materialization;
public sealed class MaterializationTests
{
#region EffectiveFinding.CreateId Tests
[Fact]
public void CreateId_IsDeterministic()
{
var id1 = EffectiveFinding.CreateId("tenant1", "policy-1", "pkg:npm/lodash@4.17.21", "CVE-2021-12345");
var id2 = EffectiveFinding.CreateId("tenant1", "policy-1", "pkg:npm/lodash@4.17.21", "CVE-2021-12345");
id1.Should().Be(id2);
id1.Should().StartWith("sha256:");
}
[Fact]
public void CreateId_NormalizesTenant()
{
var id1 = EffectiveFinding.CreateId("TENANT1", "policy-1", "pkg:npm/lodash", "CVE-2021-12345");
var id2 = EffectiveFinding.CreateId("tenant1", "policy-1", "pkg:npm/lodash", "CVE-2021-12345");
id1.Should().Be(id2);
}
[Fact]
public void CreateId_NormalizesPurl()
{
var id1 = EffectiveFinding.CreateId("tenant1", "policy-1", "PKG:NPM/LODASH", "CVE-2021-12345");
var id2 = EffectiveFinding.CreateId("tenant1", "policy-1", "pkg:npm/lodash", "CVE-2021-12345");
id1.Should().Be(id2);
}
[Fact]
public void CreateId_DiffersForDifferentInput()
{
var id1 = EffectiveFinding.CreateId("tenant1", "policy-1", "pkg:npm/lodash", "CVE-2021-12345");
var id2 = EffectiveFinding.CreateId("tenant1", "policy-1", "pkg:npm/lodash", "CVE-2021-99999");
id1.Should().NotBe(id2);
}
[Fact]
public void CreateId_HandlesNullValues()
{
var id = EffectiveFinding.CreateId(null!, "policy", "purl", "advisory");
id.Should().StartWith("sha256:");
}
#endregion
#region EffectiveFinding.ComputeContentHash Tests
[Fact]
public void ComputeContentHash_IsDeterministic()
{
var hash1 = EffectiveFinding.ComputeContentHash("affected", "High", "severity-rule", "not_affected", null);
var hash2 = EffectiveFinding.ComputeContentHash("affected", "High", "severity-rule", "not_affected", null);
hash1.Should().Be(hash2);
}
[Fact]
public void ComputeContentHash_DiffersForDifferentStatus()
{
var hash1 = EffectiveFinding.ComputeContentHash("affected", "High", null, null, null);
var hash2 = EffectiveFinding.ComputeContentHash("suppressed", "High", null, null, null);
hash1.Should().NotBe(hash2);
}
[Fact]
public void ComputeContentHash_DiffersForDifferentSeverity()
{
var hash1 = EffectiveFinding.ComputeContentHash("affected", "High", null, null, null);
var hash2 = EffectiveFinding.ComputeContentHash("affected", "Critical", null, null, null);
hash1.Should().NotBe(hash2);
}
[Fact]
public void ComputeContentHash_IncludesAnnotations()
{
var annotations = new Dictionary<string, string> { ["key"] = "value" };
var hash1 = EffectiveFinding.ComputeContentHash("affected", "High", null, null, annotations);
var hash2 = EffectiveFinding.ComputeContentHash("affected", "High", null, null, null);
hash1.Should().NotBe(hash2);
}
[Fact]
public void ComputeContentHash_SortsAnnotationsDeterministically()
{
var annotations1 = new Dictionary<string, string> { ["a"] = "1", ["b"] = "2" };
var annotations2 = new Dictionary<string, string> { ["b"] = "2", ["a"] = "1" };
var hash1 = EffectiveFinding.ComputeContentHash("affected", null, null, null, annotations1);
var hash2 = EffectiveFinding.ComputeContentHash("affected", null, null, null, annotations2);
hash1.Should().Be(hash2);
}
#endregion
#region EffectiveFindingHistoryEntry Tests
[Fact]
public void HistoryEntry_CreateId_IsDeterministic()
{
var id1 = EffectiveFindingHistoryEntry.CreateId("finding-1", 5);
var id2 = EffectiveFindingHistoryEntry.CreateId("finding-1", 5);
id1.Should().Be(id2);
id1.Should().Be("finding-1:v5");
}
[Fact]
public void HistoryEntry_CreateId_DiffersForDifferentVersion()
{
var id1 = EffectiveFindingHistoryEntry.CreateId("finding-1", 1);
var id2 = EffectiveFindingHistoryEntry.CreateId("finding-1", 2);
id1.Should().NotBe(id2);
}
#endregion
#region MaterializeFindingInput Tests
[Fact]
public void MaterializeFindingInput_CanBeCreated()
{
var input = new MaterializeFindingInput
{
TenantId = "tenant-1",
PolicyId = "policy-1",
PolicyVersion = 1,
ComponentPurl = "pkg:npm/lodash@4.17.21",
ComponentName = "lodash",
ComponentVersion = "4.17.21",
AdvisoryId = "CVE-2021-12345",
AdvisorySource = "nvd",
Status = "affected",
Severity = "High",
RuleName = "severity-rule",
VexStatus = "not_affected",
VexJustification = "vulnerable_code_not_in_execute_path",
Annotations = ImmutableDictionary<string, string>.Empty.Add("key", "value"),
PolicyRunId = "run-123",
TraceId = "trace-abc",
SpanId = "span-def"
};
input.TenantId.Should().Be("tenant-1");
input.PolicyId.Should().Be("policy-1");
input.PolicyVersion.Should().Be(1);
input.ComponentPurl.Should().Be("pkg:npm/lodash@4.17.21");
input.Status.Should().Be("affected");
input.VexStatus.Should().Be("not_affected");
}
#endregion
#region MaterializeFindingResult Tests
[Fact]
public void MaterializeFindingResult_TracksCreation()
{
var result = new MaterializeFindingResult
{
FindingId = "sha256:abc123",
WasCreated = true,
WasUpdated = false,
HistoryVersion = 1,
ChangeType = EffectiveFindingChangeType.Created
};
result.WasCreated.Should().BeTrue();
result.WasUpdated.Should().BeFalse();
result.ChangeType.Should().Be(EffectiveFindingChangeType.Created);
}
[Fact]
public void MaterializeFindingResult_TracksUpdate()
{
var result = new MaterializeFindingResult
{
FindingId = "sha256:abc123",
WasCreated = false,
WasUpdated = true,
HistoryVersion = 2,
ChangeType = EffectiveFindingChangeType.StatusChanged
};
result.WasCreated.Should().BeFalse();
result.WasUpdated.Should().BeTrue();
result.ChangeType.Should().Be(EffectiveFindingChangeType.StatusChanged);
}
#endregion
#region MaterializeBatchResult Tests
[Fact]
public void MaterializeBatchResult_AggregatesCorrectly()
{
var results = ImmutableArray.Create(
new MaterializeFindingResult
{
FindingId = "id1",
WasCreated = true,
WasUpdated = false,
HistoryVersion = 1,
ChangeType = EffectiveFindingChangeType.Created
},
new MaterializeFindingResult
{
FindingId = "id2",
WasCreated = false,
WasUpdated = true,
HistoryVersion = 2,
ChangeType = EffectiveFindingChangeType.StatusChanged
}
);
var batchResult = new MaterializeBatchResult
{
TotalInputs = 3,
Created = 1,
Updated = 1,
Unchanged = 1,
Errors = 0,
ProcessingTimeMs = 100,
Results = results
};
batchResult.TotalInputs.Should().Be(3);
batchResult.Created.Should().Be(1);
batchResult.Updated.Should().Be(1);
batchResult.Unchanged.Should().Be(1);
batchResult.Results.Should().HaveCount(2);
}
#endregion
#region EffectiveFindingChangeType Tests
[Theory]
[InlineData(EffectiveFindingChangeType.Created, "Created")]
[InlineData(EffectiveFindingChangeType.StatusChanged, "StatusChanged")]
[InlineData(EffectiveFindingChangeType.SeverityChanged, "SeverityChanged")]
[InlineData(EffectiveFindingChangeType.VexApplied, "VexApplied")]
[InlineData(EffectiveFindingChangeType.AnnotationsChanged, "AnnotationsChanged")]
[InlineData(EffectiveFindingChangeType.PolicyVersionChanged, "PolicyVersionChanged")]
public void EffectiveFindingChangeType_HasExpectedValues(EffectiveFindingChangeType changeType, string expectedName)
{
changeType.ToString().Should().Be(expectedName);
}
#endregion
}

View File

@@ -128,7 +128,8 @@ public sealed class PolicyBundleServiceTests
var compiler = new PolicyCompiler();
var complexity = new PolicyComplexityAnalyzer();
var options = Microsoft.Extensions.Options.Options.Create(new PolicyEngineOptions());
var compilationService = new PolicyCompilationService(compiler, complexity, new StaticOptionsMonitor(options.Value), TimeProvider.System);
var metadataExtractor = new PolicyMetadataExtractor();
var compilationService = new PolicyCompilationService(compiler, complexity, metadataExtractor, new StaticOptionsMonitor(options.Value), TimeProvider.System);
var repo = new InMemoryPolicyPackRepository();
return new ServiceHarness(
new PolicyBundleService(compilationService, repo, TimeProvider.System),

View File

@@ -84,7 +84,8 @@ public sealed class PolicyCompilationServiceTests
options.Compilation.MaxDurationMilliseconds = maxDurationMilliseconds;
var optionsMonitor = new StaticOptionsMonitor<PolicyEngineOptions>(options);
var timeProvider = new FakeTimeProvider(simulatedDurationMilliseconds);
return new PolicyCompilationService(compiler, analyzer, optionsMonitor, timeProvider);
var metadataExtractor = new PolicyMetadataExtractor();
return new PolicyCompilationService(compiler, analyzer, metadataExtractor, optionsMonitor, timeProvider);
}
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>

View File

@@ -157,8 +157,8 @@ public sealed class PolicyRuntimeEvaluationServiceTests
var responses = await harness.Service.EvaluateBatchAsync(requests, CancellationToken.None);
Assert.Equal(2, responses.Count);
Assert.True(responses.Any(r => r.Cached));
Assert.True(responses.Any(r => !r.Cached));
Assert.Contains(responses, r => r.Cached);
Assert.Contains(responses, r => !r.Cached);
}
[Fact]
@@ -231,7 +231,8 @@ public sealed class PolicyRuntimeEvaluationServiceTests
var analyzer = new PolicyComplexityAnalyzer();
var options = new PolicyEngineOptions();
var optionsMonitor = new StaticOptionsMonitor(options);
return new PolicyCompilationService(compiler, analyzer, optionsMonitor, TimeProvider.System);
var metadataExtractor = new PolicyMetadataExtractor();
return new PolicyCompilationService(compiler, analyzer, metadataExtractor, optionsMonitor, TimeProvider.System);
}
private sealed record TestHarness(

View File

@@ -0,0 +1,380 @@
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Policy.Engine.SelectionJoin;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.SelectionJoin;
public sealed class SelectionJoinTests
{
#region PurlEquivalence Tests
[Theory]
[InlineData("pkg:npm/lodash@4.17.21", "pkg:npm/lodash")]
[InlineData("pkg:maven/org.apache.commons/commons-lang3@3.12.0", "pkg:maven/org.apache.commons/commons-lang3")]
[InlineData("pkg:pypi/requests@2.28.0", "pkg:pypi/requests")]
[InlineData("pkg:gem/rails@7.0.0", "pkg:gem/rails")]
[InlineData("pkg:nuget/Newtonsoft.Json@13.0.1", "pkg:nuget/Newtonsoft.Json")]
public void ExtractPackageKey_RemovesVersion(string purl, string expectedKey)
{
var key = PurlEquivalence.ExtractPackageKey(purl);
key.Should().Be(expectedKey);
}
[Fact]
public void ExtractPackageKey_HandlesNoVersion()
{
var purl = "pkg:npm/lodash";
var key = PurlEquivalence.ExtractPackageKey(purl);
key.Should().Be("pkg:npm/lodash");
}
[Fact]
public void ExtractPackageKey_HandlesScopedPackages()
{
var purl = "pkg:npm/@scope/package@1.0.0";
var key = PurlEquivalence.ExtractPackageKey(purl);
key.Should().Be("pkg:npm/@scope/package");
}
[Theory]
[InlineData("pkg:npm/lodash@4.17.21", "npm")]
[InlineData("pkg:maven/org.apache/commons@1.0", "maven")]
[InlineData("pkg:pypi/requests@2.28", "pypi")]
public void ExtractEcosystem_ReturnsCorrectEcosystem(string purl, string expected)
{
var ecosystem = PurlEquivalence.ExtractEcosystem(purl);
ecosystem.Should().Be(expected);
}
[Fact]
public void ComputeMatchConfidence_ExactMatch_Returns1()
{
var confidence = PurlEquivalence.ComputeMatchConfidence(
"pkg:npm/lodash@4.17.21",
"pkg:npm/lodash@4.17.21");
confidence.Should().Be(1.0);
}
[Fact]
public void ComputeMatchConfidence_PackageKeyMatch_Returns08()
{
var confidence = PurlEquivalence.ComputeMatchConfidence(
"pkg:npm/lodash@4.17.21",
"pkg:npm/lodash@4.17.20");
confidence.Should().Be(0.8);
}
#endregion
#region PurlEquivalenceTable Tests
[Fact]
public void FromGroups_CreatesEquivalentMappings()
{
var groups = new[]
{
new[] { "pkg:npm/lodash", "pkg:npm/lodash-es" }
};
var table = PurlEquivalenceTable.FromGroups(groups);
table.AreEquivalent("pkg:npm/lodash", "pkg:npm/lodash-es").Should().BeTrue();
table.GroupCount.Should().Be(1);
}
[Fact]
public void GetCanonical_ReturnsFirstLexicographically()
{
var groups = new[]
{
new[] { "pkg:npm/b-package", "pkg:npm/a-package" }
};
var table = PurlEquivalenceTable.FromGroups(groups);
// "a-package" is lexicographically first
table.GetCanonical("pkg:npm/b-package").Should().Be("pkg:npm/a-package");
}
[Fact]
public void GetEquivalents_ReturnsAllEquivalentPurls()
{
var groups = new[]
{
new[] { "pkg:npm/a", "pkg:npm/b", "pkg:npm/c" }
};
var table = PurlEquivalenceTable.FromGroups(groups);
var equivalents = table.GetEquivalents("pkg:npm/b");
equivalents.Should().HaveCount(3);
equivalents.Should().Contain("pkg:npm/a");
equivalents.Should().Contain("pkg:npm/b");
equivalents.Should().Contain("pkg:npm/c");
}
[Fact]
public void Empty_HasNoMappings()
{
var table = PurlEquivalenceTable.Empty;
table.GroupCount.Should().Be(0);
table.TotalEntries.Should().Be(0);
table.AreEquivalent("pkg:npm/a", "pkg:npm/b").Should().BeFalse();
}
#endregion
#region SelectionJoinService Tests
[Fact]
public void ResolveTuples_MatchesByExactPurl()
{
var service = new SelectionJoinService();
var input = new SelectionJoinBatchInput(
TenantId: "test-tenant",
BatchId: "batch-1",
Components: [
new SbomComponentInput(
Purl: "pkg:npm/lodash@4.17.21",
Name: "lodash",
Version: "4.17.21",
Ecosystem: "npm",
Metadata: ImmutableDictionary<string, string>.Empty)
],
Advisories: [
new AdvisoryLinksetInput(
AdvisoryId: "GHSA-test-001",
Source: "github",
Purls: ["pkg:npm/lodash@4.17.21"],
Cpes: ImmutableArray<string>.Empty,
Aliases: ["CVE-2021-12345"],
Confidence: 1.0)
],
VexLinksets: ImmutableArray<VexLinksetInput>.Empty,
EquivalenceTable: null,
Options: new SelectionJoinOptions());
var result = service.ResolveTuples(input);
result.Tuples.Should().ContainSingle();
result.Tuples[0].MatchType.Should().Be(SelectionMatchType.ExactPurl);
result.Tuples[0].Component.Purl.Should().Be("pkg:npm/lodash@4.17.21");
result.Statistics.ExactPurlMatches.Should().Be(1);
}
[Fact]
public void ResolveTuples_MatchesByPackageKey()
{
var service = new SelectionJoinService();
var input = new SelectionJoinBatchInput(
TenantId: "test-tenant",
BatchId: "batch-1",
Components: [
new SbomComponentInput("pkg:npm/lodash@4.17.21", "lodash", "4.17.21", "npm",
ImmutableDictionary<string, string>.Empty)
],
Advisories: [
new AdvisoryLinksetInput("GHSA-test-001", "github",
Purls: ["pkg:npm/lodash@4.17.20"], // Different version
Cpes: ImmutableArray<string>.Empty,
Aliases: ["CVE-2021-12345"],
Confidence: 1.0)
],
VexLinksets: ImmutableArray<VexLinksetInput>.Empty,
EquivalenceTable: null,
Options: new SelectionJoinOptions());
var result = service.ResolveTuples(input);
result.Tuples.Should().ContainSingle();
result.Tuples[0].MatchType.Should().Be(SelectionMatchType.PackageKeyMatch);
}
[Fact]
public void ResolveTuples_AppliesVexOverlay()
{
var service = new SelectionJoinService();
var input = new SelectionJoinBatchInput(
TenantId: "test-tenant",
BatchId: "batch-1",
Components: [
new SbomComponentInput("pkg:npm/lodash@4.17.21", "lodash", "4.17.21", "npm",
ImmutableDictionary<string, string>.Empty)
],
Advisories: [
new AdvisoryLinksetInput("GHSA-test-001", "github",
Purls: ["pkg:npm/lodash@4.17.21"],
Cpes: ImmutableArray<string>.Empty,
Aliases: ["CVE-2021-12345"],
Confidence: 1.0)
],
VexLinksets: [
new VexLinksetInput("vex-1", "CVE-2021-12345", "pkg:npm/lodash@4.17.21",
"not_affected", "vulnerable_code_not_in_execute_path", VexConfidenceLevel.High)
],
EquivalenceTable: null,
Options: new SelectionJoinOptions());
var result = service.ResolveTuples(input);
result.Tuples.Should().ContainSingle();
result.Tuples[0].Vex.Should().NotBeNull();
result.Tuples[0].Vex!.Status.Should().Be("not_affected");
result.Statistics.VexOverlays.Should().Be(1);
}
[Fact]
public void ResolveTuples_ProducesDeterministicOrdering()
{
var service = new SelectionJoinService();
var input = new SelectionJoinBatchInput(
TenantId: "test-tenant",
BatchId: "batch-1",
Components: [
new SbomComponentInput("pkg:npm/z-package@1.0.0", "z", "1.0.0", "npm",
ImmutableDictionary<string, string>.Empty),
new SbomComponentInput("pkg:npm/a-package@1.0.0", "a", "1.0.0", "npm",
ImmutableDictionary<string, string>.Empty),
new SbomComponentInput("pkg:npm/m-package@1.0.0", "m", "1.0.0", "npm",
ImmutableDictionary<string, string>.Empty)
],
Advisories: [
new AdvisoryLinksetInput("ADV-001", "test",
Purls: ["pkg:npm/z-package", "pkg:npm/a-package", "pkg:npm/m-package"],
Cpes: ImmutableArray<string>.Empty,
Aliases: ["CVE-2021-001"],
Confidence: 1.0)
],
VexLinksets: ImmutableArray<VexLinksetInput>.Empty,
EquivalenceTable: null,
Options: new SelectionJoinOptions());
var result = service.ResolveTuples(input);
// Should be sorted by component PURL
result.Tuples.Should().HaveCount(3);
result.Tuples[0].Component.Purl.Should().Be("pkg:npm/a-package@1.0.0");
result.Tuples[1].Component.Purl.Should().Be("pkg:npm/m-package@1.0.0");
result.Tuples[2].Component.Purl.Should().Be("pkg:npm/z-package@1.0.0");
}
[Fact]
public void ResolveTuples_HandlesMultipleAdvisories()
{
var service = new SelectionJoinService();
var input = new SelectionJoinBatchInput(
TenantId: "test-tenant",
BatchId: "batch-1",
Components: [
new SbomComponentInput("pkg:npm/lodash@4.17.21", "lodash", "4.17.21", "npm",
ImmutableDictionary<string, string>.Empty)
],
Advisories: [
new AdvisoryLinksetInput("ADV-001", "test",
Purls: ["pkg:npm/lodash@4.17.21"],
Cpes: ImmutableArray<string>.Empty,
Aliases: ["CVE-2021-001"],
Confidence: 1.0),
new AdvisoryLinksetInput("ADV-002", "test",
Purls: ["pkg:npm/lodash@4.17.21"],
Cpes: ImmutableArray<string>.Empty,
Aliases: ["CVE-2021-002"],
Confidence: 1.0)
],
VexLinksets: ImmutableArray<VexLinksetInput>.Empty,
EquivalenceTable: null,
Options: new SelectionJoinOptions());
var result = service.ResolveTuples(input);
result.Tuples.Should().HaveCount(2);
result.Tuples.Should().Contain(t => t.Advisory.AdvisoryId == "ADV-001");
result.Tuples.Should().Contain(t => t.Advisory.AdvisoryId == "ADV-002");
}
[Fact]
public void ResolveTuples_ReturnsStatistics()
{
var service = new SelectionJoinService();
var input = new SelectionJoinBatchInput(
TenantId: "test-tenant",
BatchId: "batch-1",
Components: [
new SbomComponentInput("pkg:npm/a@1.0.0", "a", "1.0.0", "npm",
ImmutableDictionary<string, string>.Empty),
new SbomComponentInput("pkg:npm/b@1.0.0", "b", "1.0.0", "npm",
ImmutableDictionary<string, string>.Empty)
],
Advisories: [
new AdvisoryLinksetInput("ADV-001", "test",
Purls: ["pkg:npm/a"],
Cpes: ImmutableArray<string>.Empty,
Aliases: ["CVE-001"],
Confidence: 1.0)
],
VexLinksets: ImmutableArray<VexLinksetInput>.Empty,
EquivalenceTable: null,
Options: new SelectionJoinOptions());
var result = service.ResolveTuples(input);
result.Statistics.TotalComponents.Should().Be(2);
result.Statistics.TotalAdvisories.Should().Be(1);
result.Statistics.MatchedTuples.Should().Be(1);
result.UnmatchedComponents.Should().ContainSingle(c => c.Purl == "pkg:npm/b@1.0.0");
}
[Fact]
public void ResolveTuples_HandlesEmptyInput()
{
var service = new SelectionJoinService();
var input = new SelectionJoinBatchInput(
TenantId: "test-tenant",
BatchId: "batch-1",
Components: ImmutableArray<SbomComponentInput>.Empty,
Advisories: ImmutableArray<AdvisoryLinksetInput>.Empty,
VexLinksets: ImmutableArray<VexLinksetInput>.Empty,
EquivalenceTable: null,
Options: new SelectionJoinOptions());
var result = service.ResolveTuples(input);
result.Tuples.Should().BeEmpty();
result.Statistics.TotalComponents.Should().Be(0);
}
#endregion
#region SelectionJoinTuple Tests
[Fact]
public void CreateTupleId_IsDeterministic()
{
var id1 = SelectionJoinTuple.CreateTupleId("tenant1", "pkg:npm/lodash@4.17.21", "CVE-2021-12345");
var id2 = SelectionJoinTuple.CreateTupleId("tenant1", "pkg:npm/lodash@4.17.21", "CVE-2021-12345");
id1.Should().Be(id2);
id1.Should().StartWith("tuple:sha256:");
}
[Fact]
public void CreateTupleId_NormalizesInput()
{
var id1 = SelectionJoinTuple.CreateTupleId("TENANT1", "PKG:NPM/LODASH@4.17.21", "CVE-2021-12345");
var id2 = SelectionJoinTuple.CreateTupleId("tenant1", "pkg:npm/lodash@4.17.21", "CVE-2021-12345");
id1.Should().Be(id2);
}
#endregion
}

View File

@@ -0,0 +1,414 @@
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Policy.Engine.Simulation;
using StellaOps.Policy.Engine.Telemetry;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.Simulation;
public sealed class SimulationAnalyticsServiceTests
{
private readonly SimulationAnalyticsService _service = new();
[Fact]
public void ComputeRuleFiringCounts_EmptyTraces_ReturnsEmptyCounts()
{
// Arrange
var traces = Array.Empty<RuleHitTrace>();
// Act
var result = _service.ComputeRuleFiringCounts(traces, 10);
// Assert
result.TotalEvaluations.Should().Be(10);
result.TotalRulesFired.Should().Be(0);
result.RulesByName.Should().BeEmpty();
result.RulesByPriority.Should().BeEmpty();
result.RulesByOutcome.Should().BeEmpty();
result.TopRules.Should().BeEmpty();
}
[Fact]
public void ComputeRuleFiringCounts_WithFiredRules_CountsCorrectly()
{
// Arrange
var traces = new[]
{
CreateTrace("rule_a", 1, "block", expressionResult: true),
CreateTrace("rule_a", 1, "block", expressionResult: true),
CreateTrace("rule_b", 2, "allow", expressionResult: true),
CreateTrace("rule_c", 3, "warn", expressionResult: false), // Not fired
};
// Act
var result = _service.ComputeRuleFiringCounts(traces, 10);
// Assert
result.TotalRulesFired.Should().Be(3);
result.RulesByName.Should().HaveCount(2);
result.RulesByName["rule_a"].FireCount.Should().Be(2);
result.RulesByName["rule_b"].FireCount.Should().Be(1);
result.RulesByPriority[1].Should().Be(2);
result.RulesByPriority[2].Should().Be(1);
result.RulesByOutcome["block"].Should().Be(2);
result.RulesByOutcome["allow"].Should().Be(1);
}
[Fact]
public void ComputeRuleFiringCounts_TopRules_OrderedByFireCount()
{
// Arrange
var traces = new List<RuleHitTrace>();
for (var i = 0; i < 15; i++)
{
traces.Add(CreateTrace("frequently_fired", 1, "block", expressionResult: true));
}
for (var i = 0; i < 5; i++)
{
traces.Add(CreateTrace("sometimes_fired", 2, "warn", expressionResult: true));
}
traces.Add(CreateTrace("rarely_fired", 3, "allow", expressionResult: true));
// Act
var result = _service.ComputeRuleFiringCounts(traces, 100);
// Assert
result.TopRules.Should().HaveCount(3);
result.TopRules[0].RuleName.Should().Be("frequently_fired");
result.TopRules[0].FireCount.Should().Be(15);
result.TopRules[1].RuleName.Should().Be("sometimes_fired");
result.TopRules[1].FireCount.Should().Be(5);
result.TopRules[2].RuleName.Should().Be("rarely_fired");
result.TopRules[2].FireCount.Should().Be(1);
}
[Fact]
public void ComputeRuleFiringCounts_VexOverrides_CountedCorrectly()
{
// Arrange
var traces = new[]
{
CreateTrace("rule_a", 1, "allow", expressionResult: true, isVexOverride: true, vexVendor: "vendor_a", vexStatus: "not_affected"),
CreateTrace("rule_a", 1, "allow", expressionResult: true, isVexOverride: true, vexVendor: "vendor_a", vexStatus: "fixed"),
CreateTrace("rule_b", 2, "allow", expressionResult: true, isVexOverride: true, vexVendor: "vendor_b", vexStatus: "not_affected"),
CreateTrace("rule_c", 3, "block", expressionResult: true),
};
// Act
var result = _service.ComputeRuleFiringCounts(traces, 10);
// Assert
result.VexOverrides.TotalOverrides.Should().Be(3);
result.VexOverrides.ByVendor["vendor_a"].Should().Be(2);
result.VexOverrides.ByVendor["vendor_b"].Should().Be(1);
result.VexOverrides.ByStatus["not_affected"].Should().Be(2);
result.VexOverrides.ByStatus["fixed"].Should().Be(1);
}
[Fact]
public void ComputeHeatmap_RuleSeverityMatrix_BuildsCorrectly()
{
// Arrange
var traces = new[]
{
CreateTrace("rule_a", 1, "block", expressionResult: true, severity: "critical"),
CreateTrace("rule_a", 1, "block", expressionResult: true, severity: "critical"),
CreateTrace("rule_a", 1, "block", expressionResult: true, severity: "high"),
CreateTrace("rule_b", 2, "warn", expressionResult: true, severity: "medium"),
};
var findings = CreateFindings(4);
// Act
var result = _service.ComputeHeatmap(traces, findings, SimulationAnalyticsOptions.Default);
// Assert
result.RuleSeverityMatrix.Should().NotBeEmpty();
var criticalCell = result.RuleSeverityMatrix.FirstOrDefault(c => c.X == "rule_a" && c.Y == "critical");
criticalCell.Should().NotBeNull();
criticalCell!.Value.Should().Be(2);
}
[Fact]
public void ComputeHeatmap_FindingRuleCoverage_CalculatesCorrectly()
{
// Arrange
var traces = new[]
{
CreateTrace("rule_a", 1, "block", expressionResult: true, componentPurl: "pkg:npm/lodash@4.0.0"),
CreateTrace("rule_b", 2, "allow", expressionResult: true, componentPurl: "pkg:npm/lodash@4.0.0"),
CreateTrace("rule_a", 1, "block", expressionResult: false, componentPurl: "pkg:npm/express@5.0.0"),
};
var findings = new[]
{
new SimulationFinding("f1", "pkg:npm/lodash@4.0.0", "GHSA-123", new Dictionary<string, object?>()),
new SimulationFinding("f2", "pkg:npm/express@5.0.0", "GHSA-456", new Dictionary<string, object?>()),
new SimulationFinding("f3", "pkg:npm/axios@1.0.0", "GHSA-789", new Dictionary<string, object?>()),
};
// Act
var result = _service.ComputeHeatmap(traces, findings, SimulationAnalyticsOptions.Default);
// Assert
result.FindingRuleCoverage.TotalFindings.Should().Be(3);
result.FindingRuleCoverage.FindingsMatched.Should().Be(1);
result.FindingRuleCoverage.CoveragePercentage.Should().BeApproximately(33.33, 0.1);
}
[Fact]
public void ComputeSampledTraces_DeterministicOrdering_OrdersByFindingId()
{
// Arrange
var traces = new[]
{
CreateTrace("rule_a", 1, "block", expressionResult: true, componentPurl: "pkg:npm/z-package@1.0.0"),
CreateTrace("rule_a", 1, "allow", expressionResult: true, componentPurl: "pkg:npm/a-package@1.0.0"),
CreateTrace("rule_b", 2, "warn", expressionResult: true, componentPurl: "pkg:npm/m-package@1.0.0"),
};
var findings = new[]
{
new SimulationFinding("finding-z", "pkg:npm/z-package@1.0.0", null, new Dictionary<string, object?>()),
new SimulationFinding("finding-a", "pkg:npm/a-package@1.0.0", null, new Dictionary<string, object?>()),
new SimulationFinding("finding-m", "pkg:npm/m-package@1.0.0", null, new Dictionary<string, object?>()),
};
var options = new SimulationAnalyticsOptions { TraceSampleRate = 1.0, MaxSampledTraces = 100 };
// Act
var result = _service.ComputeSampledTraces(traces, findings, options);
// Assert
result.Ordering.PrimaryKey.Should().Be("finding_id");
result.Ordering.Direction.Should().Be("ascending");
}
[Fact]
public void ComputeSampledTraces_DeterminismHash_ConsistentForSameInput()
{
// Arrange
var traces = new[]
{
CreateTrace("rule_a", 1, "block", expressionResult: true, componentPurl: "pkg:npm/lodash@4.0.0"),
};
var findings = new[]
{
new SimulationFinding("f1", "pkg:npm/lodash@4.0.0", "GHSA-123", new Dictionary<string, object?>()),
};
var options = new SimulationAnalyticsOptions { TraceSampleRate = 1.0 };
// Act
var result1 = _service.ComputeSampledTraces(traces, findings, options);
var result2 = _service.ComputeSampledTraces(traces, findings, options);
// Assert
result1.DeterminismHash.Should().Be(result2.DeterminismHash);
}
[Fact]
public void ComputeSampledTraces_HighSeverity_AlwaysSampled()
{
// Arrange
var traces = new[]
{
CreateTrace("rule_a", 1, "block", expressionResult: true, componentPurl: "pkg:npm/critical@1.0.0", severity: "critical"),
};
var findings = new[]
{
new SimulationFinding("f1", "pkg:npm/critical@1.0.0", null, new Dictionary<string, object?>()),
};
var options = new SimulationAnalyticsOptions { TraceSampleRate = 0.0 }; // Zero base rate
// Act
var result = _service.ComputeSampledTraces(traces, findings, options);
// Assert
result.SampledCount.Should().BeGreaterThan(0);
result.Traces.Should().Contain(t => t.SampleReason == "high_severity");
}
[Fact]
public void ComputeDeltaSummary_OutcomeChanges_CalculatesCorrectly()
{
// Arrange
var baseResults = new[]
{
new SimulationFindingResult("f1", "pkg:a", null, "block", "critical", new[] { "rule_a" }),
new SimulationFindingResult("f2", "pkg:b", null, "warn", "medium", new[] { "rule_b" }),
new SimulationFindingResult("f3", "pkg:c", null, "allow", "low", new[] { "rule_c" }),
};
var candidateResults = new[]
{
new SimulationFindingResult("f1", "pkg:a", null, "warn", "high", new[] { "rule_a" }), // Improved
new SimulationFindingResult("f2", "pkg:b", null, "block", "critical", new[] { "rule_b" }), // Regressed
new SimulationFindingResult("f3", "pkg:c", null, "allow", "low", new[] { "rule_c" }), // Unchanged
};
// Act
var result = _service.ComputeDeltaSummary("v1", "v2", baseResults, candidateResults);
// Assert
result.OutcomeChanges.Unchanged.Should().Be(1);
result.OutcomeChanges.Improved.Should().Be(1);
result.OutcomeChanges.Regressed.Should().Be(1);
result.OutcomeChanges.Transitions.Should().HaveCount(2);
}
[Fact]
public void ComputeDeltaSummary_SeverityChanges_TracksEscalationAndDeescalation()
{
// Arrange
var baseResults = new[]
{
new SimulationFindingResult("f1", "pkg:a", null, "block", "medium", Array.Empty<string>()),
new SimulationFindingResult("f2", "pkg:b", null, "block", "high", Array.Empty<string>()),
new SimulationFindingResult("f3", "pkg:c", null, "warn", "low", Array.Empty<string>()),
};
var candidateResults = new[]
{
new SimulationFindingResult("f1", "pkg:a", null, "block", "critical", Array.Empty<string>()), // Escalated
new SimulationFindingResult("f2", "pkg:b", null, "block", "medium", Array.Empty<string>()), // Deescalated
new SimulationFindingResult("f3", "pkg:c", null, "warn", "low", Array.Empty<string>()), // Unchanged
};
// Act
var result = _service.ComputeDeltaSummary("v1", "v2", baseResults, candidateResults);
// Assert
result.SeverityChanges.Unchanged.Should().Be(1);
result.SeverityChanges.Escalated.Should().Be(1);
result.SeverityChanges.Deescalated.Should().Be(1);
}
[Fact]
public void ComputeDeltaSummary_RuleChanges_DetectsAddedAndRemovedRules()
{
// Arrange
var baseResults = new[]
{
new SimulationFindingResult("f1", "pkg:a", null, "block", "high", new[] { "rule_old", "rule_common" }),
};
var candidateResults = new[]
{
new SimulationFindingResult("f1", "pkg:a", null, "block", "high", new[] { "rule_new", "rule_common" }),
};
// Act
var result = _service.ComputeDeltaSummary("v1", "v2", baseResults, candidateResults);
// Assert
result.RuleChanges.RulesAdded.Should().Contain("rule_new");
result.RuleChanges.RulesRemoved.Should().Contain("rule_old");
}
[Fact]
public void ComputeDeltaSummary_HighImpactFindings_IdentifiedCorrectly()
{
// Arrange
var baseResults = new[]
{
new SimulationFindingResult("f1", "pkg:critical", "CVE-2024-001", "allow", "low", Array.Empty<string>()),
};
var candidateResults = new[]
{
new SimulationFindingResult("f1", "pkg:critical", "CVE-2024-001", "block", "critical", Array.Empty<string>()),
};
// Act
var result = _service.ComputeDeltaSummary("v1", "v2", baseResults, candidateResults);
// Assert
result.HighImpactFindings.Should().NotBeEmpty();
result.HighImpactFindings[0].FindingId.Should().Be("f1");
result.HighImpactFindings[0].ImpactScore.Should().BeGreaterThan(0.5);
}
[Fact]
public void ComputeDeltaSummary_DeterminismHash_ConsistentForSameInput()
{
// Arrange
var baseResults = new[]
{
new SimulationFindingResult("f1", "pkg:a", null, "block", "high", Array.Empty<string>()),
};
var candidateResults = new[]
{
new SimulationFindingResult("f1", "pkg:a", null, "warn", "medium", Array.Empty<string>()),
};
// Act
var result1 = _service.ComputeDeltaSummary("v1", "v2", baseResults, candidateResults);
var result2 = _service.ComputeDeltaSummary("v1", "v2", baseResults, candidateResults);
// Assert
result1.DeterminismHash.Should().Be(result2.DeterminismHash);
}
[Fact]
public void ComputeAnalytics_FullAnalysis_ReturnsAllComponents()
{
// Arrange
var traces = new[]
{
CreateTrace("rule_a", 1, "block", expressionResult: true, componentPurl: "pkg:npm/lodash@4.0.0", severity: "high"),
CreateTrace("rule_b", 2, "allow", expressionResult: true, componentPurl: "pkg:npm/express@5.0.0", severity: "low"),
};
var findings = new[]
{
new SimulationFinding("f1", "pkg:npm/lodash@4.0.0", "GHSA-123", new Dictionary<string, object?>()),
new SimulationFinding("f2", "pkg:npm/express@5.0.0", "GHSA-456", new Dictionary<string, object?>()),
};
// Act
var result = _service.ComputeAnalytics("policy-v1", traces, findings);
// Assert
result.RuleFiringCounts.Should().NotBeNull();
result.Heatmap.Should().NotBeNull();
result.SampledTraces.Should().NotBeNull();
result.DeltaSummary.Should().BeNull(); // No delta for single policy analysis
}
private static RuleHitTrace CreateTrace(
string ruleName,
int priority,
string outcome,
bool expressionResult,
string? severity = null,
bool isVexOverride = false,
string? vexVendor = null,
string? vexStatus = null,
string? componentPurl = null)
{
return new RuleHitTrace
{
TraceId = Guid.NewGuid().ToString(),
SpanId = Guid.NewGuid().ToString("N")[..16],
TenantId = "test-tenant",
PolicyId = "test-policy",
RunId = "test-run",
RuleName = ruleName,
RulePriority = priority,
Outcome = outcome,
AssignedSeverity = severity,
ComponentPurl = componentPurl,
ExpressionResult = expressionResult,
EvaluationTimestamp = DateTimeOffset.UtcNow,
RecordedAt = DateTimeOffset.UtcNow,
EvaluationMicroseconds = 100,
IsVexOverride = isVexOverride,
VexVendor = vexVendor,
VexStatus = vexStatus,
IsSampled = true,
Attributes = ImmutableDictionary<string, string>.Empty
};
}
private static SimulationFinding[] CreateFindings(int count)
{
return Enumerable.Range(1, count)
.Select(i => new SimulationFinding(
$"finding-{i}",
$"pkg:npm/package-{i}@1.0.0",
$"GHSA-{i:D3}",
new Dictionary<string, object?>()))
.ToArray();
}
}

View File

@@ -0,0 +1,301 @@
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Policy.Engine.Telemetry;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.Telemetry;
public sealed class TelemetryTests
{
#region RuleHitTrace Tests
[Fact]
public void RuleHitTrace_GetOrCreateTraceId_ReturnsValidId()
{
var traceId = RuleHitTrace.GetOrCreateTraceId();
traceId.Should().NotBeNullOrEmpty();
traceId.Should().HaveLength(32); // 16 bytes = 32 hex chars
}
[Fact]
public void RuleHitTrace_GetOrCreateSpanId_ReturnsValidId()
{
var spanId = RuleHitTrace.GetOrCreateSpanId();
spanId.Should().NotBeNullOrEmpty();
spanId.Should().HaveLength(16); // 8 bytes = 16 hex chars
}
[Fact]
public void RuleHitTrace_GetOrCreateTraceId_GeneratesUniqueIds()
{
var ids = Enumerable.Range(0, 100)
.Select(_ => RuleHitTrace.GetOrCreateTraceId())
.ToList();
ids.Distinct().Should().HaveCount(100);
}
#endregion
#region RuleHitTraceFactory Tests
[Fact]
public void Create_ProducesValidTrace()
{
var timestamp = DateTimeOffset.UtcNow;
var timeProvider = new FakeTimeProvider(timestamp);
var trace = RuleHitTraceFactory.Create(
tenantId: "TENANT-1",
policyId: "policy-1",
policyVersion: 2,
runId: "run-123",
ruleName: "block-critical",
rulePriority: 10,
outcome: "deny",
evaluationTimestamp: timestamp,
timeProvider: timeProvider,
ruleCategory: "severity",
assignedSeverity: "Critical",
componentPurl: "pkg:npm/lodash@4.17.21",
advisoryId: "GHSA-test-001",
vulnerabilityId: "CVE-2021-12345");
trace.TenantId.Should().Be("tenant-1"); // Normalized to lowercase
trace.PolicyId.Should().Be("policy-1");
trace.PolicyVersion.Should().Be(2);
trace.RunId.Should().Be("run-123");
trace.RuleName.Should().Be("block-critical");
trace.RulePriority.Should().Be(10);
trace.Outcome.Should().Be("deny");
trace.RuleCategory.Should().Be("severity");
trace.AssignedSeverity.Should().Be("Critical");
trace.ComponentPurl.Should().Be("pkg:npm/lodash@4.17.21");
trace.EvaluationTimestamp.Should().Be(timestamp);
trace.RecordedAt.Should().Be(timestamp);
trace.TraceId.Should().NotBeNullOrEmpty();
trace.SpanId.Should().NotBeNullOrEmpty();
}
[Fact]
public void Create_TracksVexOverride()
{
var timestamp = DateTimeOffset.UtcNow;
var trace = RuleHitTraceFactory.Create(
tenantId: "tenant-1",
policyId: "policy-1",
policyVersion: 1,
runId: "run-123",
ruleName: "vex-override",
rulePriority: 1,
outcome: "suppress",
evaluationTimestamp: timestamp,
vexStatus: "not_affected",
vexJustification: "vulnerable_code_not_in_execute_path",
vexVendor: "vendor-1",
isVexOverride: true);
trace.VexStatus.Should().Be("not_affected");
trace.VexJustification.Should().Be("vulnerable_code_not_in_execute_path");
trace.VexVendor.Should().Be("vendor-1");
trace.IsVexOverride.Should().BeTrue();
}
[Fact]
public void Create_TracksReachability()
{
var timestamp = DateTimeOffset.UtcNow;
var trace = RuleHitTraceFactory.Create(
tenantId: "tenant-1",
policyId: "policy-1",
policyVersion: 1,
runId: "run-123",
ruleName: "reachability-rule",
rulePriority: 5,
outcome: "allow",
evaluationTimestamp: timestamp,
reachabilityState: "reachable",
reachabilityConfidence: 0.95);
trace.ReachabilityState.Should().Be("reachable");
trace.ReachabilityConfidence.Should().Be(0.95);
}
[Fact]
public void Create_IncludesCustomAttributes()
{
var timestamp = DateTimeOffset.UtcNow;
var attributes = ImmutableDictionary<string, string>.Empty
.Add("custom_key", "custom_value")
.Add("another_key", "another_value");
var trace = RuleHitTraceFactory.Create(
tenantId: "tenant-1",
policyId: "policy-1",
policyVersion: 1,
runId: "run-123",
ruleName: "test-rule",
rulePriority: 1,
outcome: "allow",
evaluationTimestamp: timestamp,
attributes: attributes);
trace.Attributes.Should().ContainKey("custom_key");
trace.Attributes["custom_key"].Should().Be("custom_value");
}
[Fact]
public void ToJson_ProducesValidJson()
{
var trace = RuleHitTraceFactory.Create(
tenantId: "tenant-1",
policyId: "policy-1",
policyVersion: 1,
runId: "run-123",
ruleName: "test-rule",
rulePriority: 1,
outcome: "allow",
evaluationTimestamp: DateTimeOffset.UtcNow);
var json = RuleHitTraceFactory.ToJson(trace);
json.Should().Contain("\"tenant_id\":\"tenant-1\"");
json.Should().Contain("\"policy_id\":\"policy-1\"");
json.Should().Contain("\"rule_name\":\"test-rule\"");
json.Should().NotContain("\n"); // Single line
}
[Fact]
public void ToNdjson_ProducesMultipleLines()
{
var timestamp = DateTimeOffset.UtcNow;
var traces = new[]
{
RuleHitTraceFactory.Create("tenant-1", "policy-1", 1, "run-1", "rule-1", 1, "allow", timestamp),
RuleHitTraceFactory.Create("tenant-1", "policy-1", 1, "run-1", "rule-2", 2, "deny", timestamp),
RuleHitTraceFactory.Create("tenant-1", "policy-1", 1, "run-1", "rule-3", 3, "suppress", timestamp)
};
var ndjson = RuleHitTraceFactory.ToNdjson(traces);
var lines = ndjson.Split('\n', StringSplitOptions.RemoveEmptyEntries);
lines.Should().HaveCount(3);
lines[0].Should().Contain("rule-1");
lines[1].Should().Contain("rule-2");
lines[2].Should().Contain("rule-3");
}
#endregion
#region RuleHitStatistics Tests
[Fact]
public void CreateStatistics_AggregatesCorrectly()
{
var timestamp = DateTimeOffset.UtcNow;
var traces = new[]
{
RuleHitTraceFactory.Create("tenant-1", "policy-1", 1, "run-1", "rule-1", 1, "allow", timestamp,
ruleCategory: "severity"),
RuleHitTraceFactory.Create("tenant-1", "policy-1", 1, "run-1", "rule-2", 2, "deny", timestamp,
ruleCategory: "severity"),
RuleHitTraceFactory.Create("tenant-1", "policy-1", 1, "run-1", "rule-3", 3, "suppress", timestamp,
ruleCategory: "vex", isVexOverride: true, vexVendor: "vendor-1", vexStatus: "not_affected"),
RuleHitTraceFactory.Create("tenant-1", "policy-1", 1, "run-1", "rule-4", 4, "suppress", timestamp,
ruleCategory: "vex", isVexOverride: true, vexVendor: "vendor-2", vexStatus: "fixed")
};
var stats = RuleHitTraceFactory.CreateStatistics(
runId: "run-1",
policyId: "policy-1",
traces: traces,
totalRulesEvaluated: 10,
totalEvaluationMs: 50);
stats.RunId.Should().Be("run-1");
stats.PolicyId.Should().Be("policy-1");
stats.TotalRulesEvaluated.Should().Be(10);
stats.TotalRulesFired.Should().Be(4);
stats.TotalVexOverrides.Should().Be(2);
stats.RulesFiredByCategory.Should().ContainKey("severity");
stats.RulesFiredByCategory["severity"].Should().Be(2);
stats.RulesFiredByCategory["vex"].Should().Be(2);
stats.RulesFiredByOutcome.Should().ContainKey("allow");
stats.RulesFiredByOutcome["allow"].Should().Be(1);
stats.RulesFiredByOutcome["deny"].Should().Be(1);
stats.RulesFiredByOutcome["suppress"].Should().Be(2);
stats.VexOverridesByVendor.Should().HaveCount(2);
stats.VexOverridesByStatus.Should().ContainKey("not_affected");
stats.VexOverridesByStatus.Should().ContainKey("fixed");
}
[Fact]
public void CreateStatistics_ComputesAverageEvaluationTime()
{
var traces = Array.Empty<RuleHitTrace>();
var stats = RuleHitTraceFactory.CreateStatistics(
runId: "run-1",
policyId: "policy-1",
traces: traces,
totalRulesEvaluated: 100,
totalEvaluationMs: 50);
stats.TotalEvaluationMs.Should().Be(50);
stats.AverageRuleEvaluationMicroseconds.Should().Be(500); // 50ms * 1000 / 100 rules
}
[Fact]
public void CreateStatistics_HandlesZeroRules()
{
var traces = Array.Empty<RuleHitTrace>();
var stats = RuleHitTraceFactory.CreateStatistics(
runId: "run-1",
policyId: "policy-1",
traces: traces,
totalRulesEvaluated: 0,
totalEvaluationMs: 0);
stats.TotalRulesEvaluated.Should().Be(0);
stats.AverageRuleEvaluationMicroseconds.Should().Be(0);
}
[Fact]
public void CreateStatistics_GeneratesTopRules()
{
var timestamp = DateTimeOffset.UtcNow;
var traces = Enumerable.Range(0, 20)
.SelectMany(i => Enumerable.Range(0, i + 1).Select(_ =>
RuleHitTraceFactory.Create("tenant-1", "policy-1", 1, "run-1", $"rule-{i}", i, "allow", timestamp)))
.ToArray();
var stats = RuleHitTraceFactory.CreateStatistics("run-1", "policy-1", traces, 100, 50);
stats.TopRulesByHitCount.Should().HaveCount(10);
stats.TopRulesByHitCount[0].RuleName.Should().Be("rule-19"); // Highest count
stats.TopRulesByHitCount[0].HitCount.Should().Be(20);
}
#endregion
#region RuleHitCount Tests
[Fact]
public void RuleHitCount_RecordWorks()
{
var hitCount = new RuleHitCount("severity-rule", 42, "deny");
hitCount.RuleName.Should().Be("severity-rule");
hitCount.HitCount.Should().Be(42);
hitCount.Outcome.Should().Be("deny");
}
#endregion
}

View File

@@ -0,0 +1,423 @@
using System.Collections.Frozen;
using System.Collections.Immutable;
using System.Text.RegularExpressions;
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.VirtualFileSystem;
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.Framework;
/// <summary>
/// Detects framework usage hints from Python source code.
/// </summary>
internal sealed partial class PythonFrameworkDetector
{
// File patterns that strongly indicate frameworks
private static readonly FrozenDictionary<string, (PythonFrameworkKind Kind, PythonFrameworkConfidence Confidence)> FilePatterns =
new Dictionary<string, (PythonFrameworkKind, PythonFrameworkConfidence)>(StringComparer.OrdinalIgnoreCase)
{
// Django
["manage.py"] = (PythonFrameworkKind.Django, PythonFrameworkConfidence.High),
["settings.py"] = (PythonFrameworkKind.Django, PythonFrameworkConfidence.Medium),
["urls.py"] = (PythonFrameworkKind.Django, PythonFrameworkConfidence.Medium),
["wsgi.py"] = (PythonFrameworkKind.Django, PythonFrameworkConfidence.Medium),
["asgi.py"] = (PythonFrameworkKind.Django, PythonFrameworkConfidence.Medium),
// Celery
["celery.py"] = (PythonFrameworkKind.Celery, PythonFrameworkConfidence.High),
["tasks.py"] = (PythonFrameworkKind.Celery, PythonFrameworkConfidence.Low),
// Gunicorn
["gunicorn.conf.py"] = (PythonFrameworkKind.Gunicorn, PythonFrameworkConfidence.Definitive),
["gunicorn_config.py"] = (PythonFrameworkKind.Gunicorn, PythonFrameworkConfidence.Definitive),
// uWSGI
["uwsgi.ini"] = (PythonFrameworkKind.Uwsgi, PythonFrameworkConfidence.Definitive),
// Pytest
["conftest.py"] = (PythonFrameworkKind.Pytest, PythonFrameworkConfidence.High),
["pytest.ini"] = (PythonFrameworkKind.Pytest, PythonFrameworkConfidence.Definitive),
// Jupyter
["*.ipynb"] = (PythonFrameworkKind.Jupyter, PythonFrameworkConfidence.Definitive),
}.ToFrozenDictionary();
// Import patterns that indicate frameworks
private static readonly FrozenDictionary<string, (PythonFrameworkKind Kind, PythonFrameworkConfidence Confidence)> ImportPatterns =
new Dictionary<string, (PythonFrameworkKind, PythonFrameworkConfidence)>(StringComparer.Ordinal)
{
// Django
["django"] = (PythonFrameworkKind.Django, PythonFrameworkConfidence.High),
["django.conf"] = (PythonFrameworkKind.Django, PythonFrameworkConfidence.High),
["django.urls"] = (PythonFrameworkKind.Django, PythonFrameworkConfidence.High),
["django.views"] = (PythonFrameworkKind.Django, PythonFrameworkConfidence.High),
["django.db"] = (PythonFrameworkKind.Django, PythonFrameworkConfidence.High),
// Flask
["flask"] = (PythonFrameworkKind.Flask, PythonFrameworkConfidence.High),
["flask_restful"] = (PythonFrameworkKind.Flask, PythonFrameworkConfidence.High),
["flask_sqlalchemy"] = (PythonFrameworkKind.Flask, PythonFrameworkConfidence.High),
// FastAPI
["fastapi"] = (PythonFrameworkKind.FastAPI, PythonFrameworkConfidence.High),
["starlette"] = (PythonFrameworkKind.Starlette, PythonFrameworkConfidence.High),
// Celery
["celery"] = (PythonFrameworkKind.Celery, PythonFrameworkConfidence.High),
// RQ
["rq"] = (PythonFrameworkKind.RQ, PythonFrameworkConfidence.High),
// Click
["click"] = (PythonFrameworkKind.Click, PythonFrameworkConfidence.High),
// Typer
["typer"] = (PythonFrameworkKind.Typer, PythonFrameworkConfidence.High),
// Pytest
["pytest"] = (PythonFrameworkKind.Pytest, PythonFrameworkConfidence.High),
// Streamlit
["streamlit"] = (PythonFrameworkKind.Streamlit, PythonFrameworkConfidence.Definitive),
// Gradio
["gradio"] = (PythonFrameworkKind.Gradio, PythonFrameworkConfidence.Definitive),
// Pydantic Settings
["pydantic_settings"] = (PythonFrameworkKind.PydanticSettings, PythonFrameworkConfidence.Definitive),
}.ToFrozenDictionary();
// Django patterns
[GeneratedRegex(@"INSTALLED_APPS\s*=\s*\[", RegexOptions.Compiled)]
private static partial Regex DjangoInstalledAppsPattern();
[GeneratedRegex(@"MIDDLEWARE\s*=\s*\[", RegexOptions.Compiled)]
private static partial Regex DjangoMiddlewarePattern();
[GeneratedRegex(@"ROOT_URLCONF\s*=", RegexOptions.Compiled)]
private static partial Regex DjangoRootUrlConfPattern();
[GeneratedRegex(@"os\.environ\.setdefault\s*\(\s*[""']DJANGO_SETTINGS_MODULE[""']", RegexOptions.Compiled)]
private static partial Regex DjangoSettingsModulePattern();
// Flask patterns
[GeneratedRegex(@"Flask\s*\(\s*__name__", RegexOptions.Compiled)]
private static partial Regex FlaskAppPattern();
[GeneratedRegex(@"Blueprint\s*\(", RegexOptions.Compiled)]
private static partial Regex FlaskBlueprintPattern();
// FastAPI patterns
[GeneratedRegex(@"FastAPI\s*\(", RegexOptions.Compiled)]
private static partial Regex FastAPIAppPattern();
[GeneratedRegex(@"APIRouter\s*\(", RegexOptions.Compiled)]
private static partial Regex FastAPIRouterPattern();
// Celery patterns
[GeneratedRegex(@"Celery\s*\(", RegexOptions.Compiled)]
private static partial Regex CeleryAppPattern();
[GeneratedRegex(@"@\s*(?:app\.task|celery\.task|shared_task)", RegexOptions.Compiled)]
private static partial Regex CeleryTaskPattern();
// AWS Lambda patterns
[GeneratedRegex(@"def\s+(lambda_handler|handler)\s*\(\s*event\s*,\s*context\s*\)", RegexOptions.Compiled)]
private static partial Regex LambdaHandlerPattern();
[GeneratedRegex(@"def\s+\w+\s*\(\s*event\s*:\s*dict\s*,\s*context\s*:\s*LambdaContext", RegexOptions.Compiled)]
private static partial Regex LambdaTypedHandlerPattern();
// Click patterns
[GeneratedRegex(@"@\s*click\.command", RegexOptions.Compiled)]
private static partial Regex ClickCommandPattern();
[GeneratedRegex(@"@\s*click\.group", RegexOptions.Compiled)]
private static partial Regex ClickGroupPattern();
// Typer patterns
[GeneratedRegex(@"typer\.Typer\s*\(", RegexOptions.Compiled)]
private static partial Regex TyperAppPattern();
[GeneratedRegex(@"@\s*app\.command", RegexOptions.Compiled)]
private static partial Regex TyperCommandPattern();
// Logging patterns
[GeneratedRegex(@"logging\.config\.dictConfig", RegexOptions.Compiled)]
private static partial Regex LoggingDictConfigPattern();
[GeneratedRegex(@"logging\.config\.fileConfig", RegexOptions.Compiled)]
private static partial Regex LoggingFileConfigPattern();
[GeneratedRegex(@"LOGGING\s*=\s*\{", RegexOptions.Compiled)]
private static partial Regex DjangoLoggingPattern();
// Gunicorn patterns
[GeneratedRegex(@"bind\s*=\s*[""']", RegexOptions.Compiled)]
private static partial Regex GunicornBindPattern();
[GeneratedRegex(@"workers\s*=", RegexOptions.Compiled)]
private static partial Regex GunicornWorkersPattern();
/// <summary>
/// Detects framework hints from Python source code.
/// </summary>
public async Task<ImmutableArray<PythonFrameworkHint>> DetectAsync(
PythonVirtualFileSystem vfs,
CancellationToken cancellationToken = default)
{
var hints = new List<PythonFrameworkHint>();
// First pass: check file patterns
foreach (var file in vfs.Files)
{
cancellationToken.ThrowIfCancellationRequested();
var fileName = Path.GetFileName(file.VirtualPath);
if (FilePatterns.TryGetValue(fileName, out var fileHint))
{
hints.Add(new PythonFrameworkHint(
Kind: fileHint.Kind,
SourceFile: file.VirtualPath,
LineNumber: null,
Evidence: $"file pattern: {fileName}",
Confidence: fileHint.Confidence));
}
// Special case for Jupyter notebooks
if (file.VirtualPath.EndsWith(".ipynb", StringComparison.OrdinalIgnoreCase))
{
hints.Add(new PythonFrameworkHint(
Kind: PythonFrameworkKind.Jupyter,
SourceFile: file.VirtualPath,
LineNumber: null,
Evidence: "Jupyter notebook file",
Confidence: PythonFrameworkConfidence.Definitive));
}
}
// Second pass: scan Python files for patterns
var pythonFiles = vfs.Files
.Where(f => f.VirtualPath.EndsWith(".py", StringComparison.OrdinalIgnoreCase))
.ToList();
foreach (var file in pythonFiles)
{
cancellationToken.ThrowIfCancellationRequested();
var fileHints = await DetectInFileAsync(vfs, file, cancellationToken).ConfigureAwait(false);
hints.AddRange(fileHints);
}
// Deduplicate and prioritize by confidence
return hints
.GroupBy(h => (h.Kind, h.SourceFile))
.Select(g => g.OrderByDescending(h => h.Confidence).First())
.ToImmutableArray();
}
private async Task<IEnumerable<PythonFrameworkHint>> DetectInFileAsync(
PythonVirtualFileSystem vfs,
PythonVirtualFile file,
CancellationToken cancellationToken)
{
var hints = new List<PythonFrameworkHint>();
try
{
using var stream = await vfs.OpenReadAsync(file.VirtualPath, cancellationToken).ConfigureAwait(false);
if (stream is null)
{
return hints;
}
using var reader = new StreamReader(stream);
var content = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
var lines = content.Split('\n');
for (var lineNum = 0; lineNum < lines.Length; lineNum++)
{
cancellationToken.ThrowIfCancellationRequested();
var line = lines[lineNum];
var trimmed = line.TrimStart();
// Skip comments
if (trimmed.StartsWith('#'))
{
continue;
}
// Check for imports
if (trimmed.StartsWith("import ", StringComparison.Ordinal) ||
trimmed.StartsWith("from ", StringComparison.Ordinal))
{
var importHints = DetectImportPatterns(trimmed, file.VirtualPath, lineNum + 1);
hints.AddRange(importHints);
}
// Django patterns
if (DjangoInstalledAppsPattern().IsMatch(line))
{
hints.Add(CreateHint(PythonFrameworkKind.Django, file.VirtualPath, lineNum + 1,
"INSTALLED_APPS configuration", PythonFrameworkConfidence.Definitive));
}
if (DjangoSettingsModulePattern().IsMatch(line))
{
hints.Add(CreateHint(PythonFrameworkKind.Django, file.VirtualPath, lineNum + 1,
"DJANGO_SETTINGS_MODULE", PythonFrameworkConfidence.Definitive));
}
// Flask patterns
if (FlaskAppPattern().IsMatch(line))
{
hints.Add(CreateHint(PythonFrameworkKind.Flask, file.VirtualPath, lineNum + 1,
"Flask(__name__)", PythonFrameworkConfidence.Definitive));
}
if (FlaskBlueprintPattern().IsMatch(line))
{
hints.Add(CreateHint(PythonFrameworkKind.Flask, file.VirtualPath, lineNum + 1,
"Blueprint()", PythonFrameworkConfidence.High));
}
// FastAPI patterns
if (FastAPIAppPattern().IsMatch(line))
{
hints.Add(CreateHint(PythonFrameworkKind.FastAPI, file.VirtualPath, lineNum + 1,
"FastAPI()", PythonFrameworkConfidence.Definitive));
}
if (FastAPIRouterPattern().IsMatch(line))
{
hints.Add(CreateHint(PythonFrameworkKind.FastAPI, file.VirtualPath, lineNum + 1,
"APIRouter()", PythonFrameworkConfidence.High));
}
// Celery patterns
if (CeleryAppPattern().IsMatch(line))
{
hints.Add(CreateHint(PythonFrameworkKind.Celery, file.VirtualPath, lineNum + 1,
"Celery()", PythonFrameworkConfidence.Definitive));
}
if (CeleryTaskPattern().IsMatch(line))
{
hints.Add(CreateHint(PythonFrameworkKind.Celery, file.VirtualPath, lineNum + 1,
"@app.task decorator", PythonFrameworkConfidence.High));
}
// AWS Lambda patterns
if (LambdaHandlerPattern().IsMatch(line) || LambdaTypedHandlerPattern().IsMatch(line))
{
hints.Add(CreateHint(PythonFrameworkKind.AwsLambda, file.VirtualPath, lineNum + 1,
"Lambda handler function", PythonFrameworkConfidence.High));
}
// Click patterns
if (ClickCommandPattern().IsMatch(line) || ClickGroupPattern().IsMatch(line))
{
hints.Add(CreateHint(PythonFrameworkKind.Click, file.VirtualPath, lineNum + 1,
"@click.command/group", PythonFrameworkConfidence.High));
}
// Typer patterns
if (TyperAppPattern().IsMatch(line))
{
hints.Add(CreateHint(PythonFrameworkKind.Typer, file.VirtualPath, lineNum + 1,
"typer.Typer()", PythonFrameworkConfidence.Definitive));
}
if (TyperCommandPattern().IsMatch(line))
{
hints.Add(CreateHint(PythonFrameworkKind.Typer, file.VirtualPath, lineNum + 1,
"@app.command", PythonFrameworkConfidence.High));
}
// Logging patterns
if (LoggingDictConfigPattern().IsMatch(line) || LoggingFileConfigPattern().IsMatch(line))
{
hints.Add(CreateHint(PythonFrameworkKind.LoggingConfig, file.VirtualPath, lineNum + 1,
"logging.config", PythonFrameworkConfidence.High));
}
if (DjangoLoggingPattern().IsMatch(line))
{
hints.Add(CreateHint(PythonFrameworkKind.LoggingConfig, file.VirtualPath, lineNum + 1,
"Django LOGGING dict", PythonFrameworkConfidence.High));
}
// Gunicorn patterns (in config files)
if (file.VirtualPath.Contains("gunicorn", StringComparison.OrdinalIgnoreCase))
{
if (GunicornBindPattern().IsMatch(line) || GunicornWorkersPattern().IsMatch(line))
{
hints.Add(CreateHint(PythonFrameworkKind.Gunicorn, file.VirtualPath, lineNum + 1,
"Gunicorn configuration", PythonFrameworkConfidence.Definitive));
}
}
}
}
catch (IOException)
{
// Skip unreadable files
}
return hints;
}
private static IEnumerable<PythonFrameworkHint> DetectImportPatterns(string line, string sourceFile, int lineNumber)
{
var trimmed = line.Trim();
string? moduleName = null;
if (trimmed.StartsWith("import ", StringComparison.Ordinal))
{
var parts = trimmed[7..].Split(',');
foreach (var part in parts)
{
moduleName = part.Trim().Split(new[] { " as ", " " }, StringSplitOptions.RemoveEmptyEntries)[0];
if (ImportPatterns.TryGetValue(moduleName, out var hint))
{
yield return CreateHint(hint.Kind, sourceFile, lineNumber,
$"import {moduleName}", hint.Confidence);
}
}
}
else if (trimmed.StartsWith("from ", StringComparison.Ordinal))
{
var parts = trimmed[5..].Split(new[] { " import " }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length > 0)
{
moduleName = parts[0].Trim();
// Check base module
var baseModule = moduleName.Split('.')[0];
if (ImportPatterns.TryGetValue(baseModule, out var hint))
{
yield return CreateHint(hint.Kind, sourceFile, lineNumber,
$"from {moduleName}", hint.Confidence);
}
// Check full module path
if (ImportPatterns.TryGetValue(moduleName, out hint))
{
yield return CreateHint(hint.Kind, sourceFile, lineNumber,
$"from {moduleName}", hint.Confidence);
}
}
}
}
private static PythonFrameworkHint CreateHint(
PythonFrameworkKind kind,
string sourceFile,
int lineNumber,
string evidence,
PythonFrameworkConfidence confidence)
{
return new PythonFrameworkHint(
Kind: kind,
SourceFile: sourceFile,
LineNumber: lineNumber,
Evidence: evidence,
Confidence: confidence);
}
}

View File

@@ -0,0 +1,151 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.Framework;
/// <summary>
/// Represents a detected framework or configuration hint in a Python project.
/// These are hints/suggestions, not definitive detections.
/// </summary>
/// <param name="Kind">The type of framework or configuration.</param>
/// <param name="SourceFile">The file where this hint was detected.</param>
/// <param name="LineNumber">The line number (if available).</param>
/// <param name="Evidence">The code pattern that indicated this hint.</param>
/// <param name="Confidence">Confidence level for this detection.</param>
/// <param name="Metadata">Additional metadata about the detection.</param>
internal sealed record PythonFrameworkHint(
PythonFrameworkKind Kind,
string SourceFile,
int? LineNumber,
string Evidence,
PythonFrameworkConfidence Confidence,
ImmutableDictionary<string, string>? Metadata = null)
{
/// <summary>
/// Gets whether this is a web framework.
/// </summary>
public bool IsWebFramework => Kind is
PythonFrameworkKind.Django or
PythonFrameworkKind.Flask or
PythonFrameworkKind.FastAPI or
PythonFrameworkKind.Starlette or
PythonFrameworkKind.Tornado or
PythonFrameworkKind.Bottle or
PythonFrameworkKind.Pyramid;
/// <summary>
/// Gets whether this is a task queue.
/// </summary>
public bool IsTaskQueue => Kind is
PythonFrameworkKind.Celery or
PythonFrameworkKind.RQ or
PythonFrameworkKind.Huey or
PythonFrameworkKind.Dramatiq;
/// <summary>
/// Gets whether this is a serverless runtime.
/// </summary>
public bool IsServerless => Kind is
PythonFrameworkKind.AwsLambda or
PythonFrameworkKind.AzureFunctions or
PythonFrameworkKind.GoogleCloudFunctions;
/// <summary>
/// Gets whether this is a CLI framework.
/// </summary>
public bool IsCliFramework => Kind is
PythonFrameworkKind.Click or
PythonFrameworkKind.Typer or
PythonFrameworkKind.Argparse;
/// <summary>
/// Generates metadata entries for this hint.
/// </summary>
public IEnumerable<KeyValuePair<string, string?>> ToMetadata(string prefix)
{
yield return new($"{prefix}.kind", Kind.ToString());
yield return new($"{prefix}.file", SourceFile);
if (LineNumber.HasValue)
{
yield return new($"{prefix}.line", LineNumber.Value.ToString());
}
yield return new($"{prefix}.evidence", Evidence);
yield return new($"{prefix}.confidence", Confidence.ToString());
if (IsWebFramework)
{
yield return new($"{prefix}.category", "WebFramework");
}
else if (IsTaskQueue)
{
yield return new($"{prefix}.category", "TaskQueue");
}
else if (IsServerless)
{
yield return new($"{prefix}.category", "Serverless");
}
else if (IsCliFramework)
{
yield return new($"{prefix}.category", "CLI");
}
if (Metadata is not null)
{
foreach (var (key, value) in Metadata)
{
yield return new($"{prefix}.{key}", value);
}
}
}
}
/// <summary>
/// Represents an AWS Lambda handler configuration.
/// </summary>
/// <param name="HandlerPath">The handler path (module.function).</param>
/// <param name="ModulePath">The module file path.</param>
/// <param name="FunctionName">The handler function name.</param>
/// <param name="Runtime">The detected Python runtime (if available).</param>
internal sealed record PythonLambdaHandler(
string HandlerPath,
string ModulePath,
string FunctionName,
string? Runtime = null);
/// <summary>
/// Represents a Django project configuration.
/// </summary>
/// <param name="SettingsModule">The settings module path.</param>
/// <param name="InstalledApps">List of installed apps.</param>
/// <param name="Middlewares">List of middleware classes.</param>
/// <param name="RootUrlConf">The root URL configuration module.</param>
internal sealed record PythonDjangoConfig(
string SettingsModule,
ImmutableArray<string> InstalledApps,
ImmutableArray<string> Middlewares,
string? RootUrlConf = null);
/// <summary>
/// Represents a Flask application configuration.
/// </summary>
/// <param name="AppVariable">The Flask app variable name.</param>
/// <param name="ModulePath">The module containing the app.</param>
/// <param name="Blueprints">Registered blueprints.</param>
internal sealed record PythonFlaskConfig(
string AppVariable,
string ModulePath,
ImmutableArray<string> Blueprints);
/// <summary>
/// Represents a Celery configuration.
/// </summary>
/// <param name="AppVariable">The Celery app variable name.</param>
/// <param name="ModulePath">The module containing the app.</param>
/// <param name="BrokerUrl">The broker URL pattern (if detected).</param>
/// <param name="Tasks">Discovered task modules.</param>
internal sealed record PythonCeleryConfig(
string AppVariable,
string ModulePath,
string? BrokerUrl,
ImmutableArray<string> Tasks);

View File

@@ -0,0 +1,186 @@
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.Framework;
/// <summary>
/// Types of Python frameworks and configurations detected.
/// </summary>
internal enum PythonFrameworkKind
{
/// <summary>
/// Unknown framework.
/// </summary>
Unknown,
// Web Frameworks
/// <summary>
/// Django web framework.
/// </summary>
Django,
/// <summary>
/// Flask web framework.
/// </summary>
Flask,
/// <summary>
/// FastAPI async web framework.
/// </summary>
FastAPI,
/// <summary>
/// Starlette ASGI framework.
/// </summary>
Starlette,
/// <summary>
/// Tornado async web framework.
/// </summary>
Tornado,
/// <summary>
/// Bottle micro framework.
/// </summary>
Bottle,
/// <summary>
/// Pyramid web framework.
/// </summary>
Pyramid,
// Task Queues
/// <summary>
/// Celery distributed task queue.
/// </summary>
Celery,
/// <summary>
/// RQ (Redis Queue) task queue.
/// </summary>
RQ,
/// <summary>
/// Huey task queue.
/// </summary>
Huey,
/// <summary>
/// Dramatiq task queue.
/// </summary>
Dramatiq,
// Serverless
/// <summary>
/// AWS Lambda handler.
/// </summary>
AwsLambda,
/// <summary>
/// Azure Functions.
/// </summary>
AzureFunctions,
/// <summary>
/// Google Cloud Functions.
/// </summary>
GoogleCloudFunctions,
// Application Servers
/// <summary>
/// Gunicorn WSGI server.
/// </summary>
Gunicorn,
/// <summary>
/// uWSGI server.
/// </summary>
Uwsgi,
/// <summary>
/// Uvicorn ASGI server.
/// </summary>
Uvicorn,
/// <summary>
/// Hypercorn ASGI server.
/// </summary>
Hypercorn,
// CLI Frameworks
/// <summary>
/// Click CLI framework.
/// </summary>
Click,
/// <summary>
/// Typer CLI framework (Click-based).
/// </summary>
Typer,
/// <summary>
/// Argparse standard library CLI.
/// </summary>
Argparse,
// Testing Frameworks
/// <summary>
/// Pytest testing framework.
/// </summary>
Pytest,
/// <summary>
/// Unittest standard library.
/// </summary>
Unittest,
// Data/ML Frameworks
/// <summary>
/// Jupyter notebook.
/// </summary>
Jupyter,
/// <summary>
/// Streamlit data app.
/// </summary>
Streamlit,
/// <summary>
/// Gradio ML demo.
/// </summary>
Gradio,
// Configuration
/// <summary>
/// Python logging configuration.
/// </summary>
LoggingConfig,
/// <summary>
/// Pydantic settings configuration.
/// </summary>
PydanticSettings
}
/// <summary>
/// Confidence level for framework detection.
/// </summary>
internal enum PythonFrameworkConfidence
{
/// <summary>
/// Low confidence - heuristic match based on file patterns.
/// </summary>
Low = 0,
/// <summary>
/// Medium confidence - import detected but usage unclear.
/// </summary>
Medium = 1,
/// <summary>
/// High confidence - clear usage pattern detected.
/// </summary>
High = 2,
/// <summary>
/// Definitive - explicit configuration or initialization found.
/// </summary>
Definitive = 3
}

View File

@@ -0,0 +1,327 @@
using System.Collections.Immutable;
using System.Text.RegularExpressions;
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.VirtualFileSystem;
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.Framework;
/// <summary>
/// Parses Python project configuration files (pyproject.toml, setup.cfg, setup.py).
/// </summary>
internal sealed partial class PythonProjectConfigParser
{
// pyproject.toml patterns
[GeneratedRegex(@"^\[project\]", RegexOptions.Compiled | RegexOptions.Multiline)]
private static partial Regex PyprojectProjectSection();
[GeneratedRegex(@"^\[project\.optional-dependencies\]", RegexOptions.Compiled | RegexOptions.Multiline)]
private static partial Regex PyprojectOptionalDepsSection();
[GeneratedRegex(@"^\[tool\.poetry\.extras\]", RegexOptions.Compiled | RegexOptions.Multiline)]
private static partial Regex PoetryExtrasSection();
[GeneratedRegex(@"^\[tool\.poetry\.group\.(\w+)\.dependencies\]", RegexOptions.Compiled | RegexOptions.Multiline)]
private static partial Regex PoetryGroupSection();
// Pattern to extract key = value or key = [...] lines
[GeneratedRegex(@"^(\w+)\s*=\s*\[(.*?)\]", RegexOptions.Compiled | RegexOptions.Singleline)]
private static partial Regex ArrayValuePattern();
[GeneratedRegex(@"^name\s*=\s*[""']([^""']+)[""']", RegexOptions.Compiled | RegexOptions.Multiline)]
private static partial Regex ProjectNamePattern();
[GeneratedRegex(@"^version\s*=\s*[""']([^""']+)[""']", RegexOptions.Compiled | RegexOptions.Multiline)]
private static partial Regex ProjectVersionPattern();
/// <summary>
/// Parses pyproject.toml and extracts optional dependencies.
/// </summary>
public async Task<PythonProjectConfig?> ParsePyprojectAsync(
PythonVirtualFileSystem vfs,
string pyprojectPath,
CancellationToken cancellationToken = default)
{
using var stream = await vfs.OpenReadAsync(pyprojectPath, cancellationToken).ConfigureAwait(false);
if (stream is null)
{
return null;
}
using var reader = new StreamReader(stream);
var content = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
return ParsePyprojectContent(content, pyprojectPath);
}
private static PythonProjectConfig ParsePyprojectContent(string content, string filePath)
{
string? projectName = null;
string? projectVersion = null;
var optionalDependencies = new Dictionary<string, ImmutableArray<string>>();
var scripts = new Dictionary<string, string>();
var extras = new List<string>();
// Extract project name and version
var nameMatch = ProjectNamePattern().Match(content);
if (nameMatch.Success)
{
projectName = nameMatch.Groups[1].Value;
}
var versionMatch = ProjectVersionPattern().Match(content);
if (versionMatch.Success)
{
projectVersion = versionMatch.Groups[1].Value;
}
// Parse optional dependencies section
var optDepsMatch = PyprojectOptionalDepsSection().Match(content);
if (optDepsMatch.Success)
{
var sectionStart = optDepsMatch.Index + optDepsMatch.Length;
var sectionContent = ExtractSectionContent(content, sectionStart);
optionalDependencies = ParseOptionalDependencies(sectionContent);
extras.AddRange(optionalDependencies.Keys);
}
// Parse Poetry extras section
var poetryExtrasMatch = PoetryExtrasSection().Match(content);
if (poetryExtrasMatch.Success)
{
var sectionStart = poetryExtrasMatch.Index + poetryExtrasMatch.Length;
var sectionContent = ExtractSectionContent(content, sectionStart);
var poetryExtras = ParseOptionalDependencies(sectionContent);
foreach (var (key, value) in poetryExtras)
{
if (!optionalDependencies.ContainsKey(key))
{
optionalDependencies[key] = value;
extras.Add(key);
}
}
}
// Parse Poetry group dependencies
foreach (Match groupMatch in PoetryGroupSection().Matches(content))
{
var groupName = groupMatch.Groups[1].Value;
if (!extras.Contains(groupName))
{
extras.Add(groupName);
}
}
// Parse scripts section
scripts = ParseScriptsSection(content);
return new PythonProjectConfig(
FilePath: filePath,
ProjectName: projectName,
ProjectVersion: projectVersion,
OptionalDependencies: optionalDependencies.ToImmutableDictionary(),
Extras: extras.Distinct().ToImmutableArray(),
Scripts: scripts.ToImmutableDictionary());
}
private static string ExtractSectionContent(string content, int startIndex)
{
// Find the next section header or end of file
var nextSection = content.IndexOf("\n[", startIndex, StringComparison.Ordinal);
if (nextSection < 0)
{
return content[startIndex..];
}
return content[startIndex..nextSection];
}
private static Dictionary<string, ImmutableArray<string>> ParseOptionalDependencies(string sectionContent)
{
var result = new Dictionary<string, ImmutableArray<string>>();
var lines = sectionContent.Split('\n');
string? currentKey = null;
var currentValues = new List<string>();
var inArray = false;
foreach (var line in lines)
{
var trimmed = line.Trim();
if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith('#'))
{
continue;
}
// Check for new key
if (!inArray && trimmed.Contains('='))
{
// Save previous key
if (currentKey is not null)
{
result[currentKey] = currentValues.ToImmutableArray();
currentValues = [];
}
var parts = trimmed.Split('=', 2);
currentKey = parts[0].Trim();
var value = parts.Length > 1 ? parts[1].Trim() : "";
if (value.StartsWith('['))
{
if (value.EndsWith(']'))
{
// Single-line array
currentValues = ParseArrayValues(value);
}
else
{
// Multi-line array
inArray = true;
currentValues = ParseArrayValues(value);
}
}
}
else if (inArray)
{
if (trimmed.EndsWith(']'))
{
currentValues.AddRange(ParseArrayValues(trimmed));
inArray = false;
}
else
{
currentValues.AddRange(ParseArrayValues(trimmed));
}
}
}
// Save last key
if (currentKey is not null)
{
result[currentKey] = currentValues.ToImmutableArray();
}
return result;
}
private static List<string> ParseArrayValues(string value)
{
var result = new List<string>();
var cleaned = value.Trim('[', ']', ' ', '\t');
if (string.IsNullOrEmpty(cleaned))
{
return result;
}
// Split by comma, handling quoted strings
var parts = cleaned.Split(',');
foreach (var part in parts)
{
var trimmed = part.Trim().Trim('"', '\'', ' ');
if (!string.IsNullOrEmpty(trimmed))
{
result.Add(trimmed);
}
}
return result;
}
private static Dictionary<string, string> ParseScriptsSection(string content)
{
var result = new Dictionary<string, string>();
// Look for [project.scripts] or [tool.poetry.scripts]
var scriptsPatterns = new[]
{
@"\[project\.scripts\]",
@"\[tool\.poetry\.scripts\]"
};
foreach (var pattern in scriptsPatterns)
{
var match = Regex.Match(content, pattern);
if (match.Success)
{
var sectionStart = match.Index + match.Length;
var sectionContent = ExtractSectionContent(content, sectionStart);
var scripts = ParseKeyValueSection(sectionContent);
foreach (var (key, value) in scripts)
{
result[key] = value;
}
}
}
return result;
}
private static Dictionary<string, string> ParseKeyValueSection(string sectionContent)
{
var result = new Dictionary<string, string>();
var lines = sectionContent.Split('\n');
foreach (var line in lines)
{
var trimmed = line.Trim();
if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith('#'))
{
continue;
}
if (trimmed.Contains('='))
{
var parts = trimmed.Split('=', 2);
var key = parts[0].Trim();
var value = parts.Length > 1 ? parts[1].Trim().Trim('"', '\'') : "";
result[key] = value;
}
}
return result;
}
}
/// <summary>
/// Represents parsed Python project configuration.
/// </summary>
/// <param name="FilePath">Path to the configuration file.</param>
/// <param name="ProjectName">The project name.</param>
/// <param name="ProjectVersion">The project version.</param>
/// <param name="OptionalDependencies">Optional dependencies by group name.</param>
/// <param name="Extras">List of available extras.</param>
/// <param name="Scripts">Entry point scripts.</param>
internal sealed record PythonProjectConfig(
string FilePath,
string? ProjectName,
string? ProjectVersion,
ImmutableDictionary<string, ImmutableArray<string>> OptionalDependencies,
ImmutableArray<string> Extras,
ImmutableDictionary<string, string> Scripts)
{
/// <summary>
/// Generates metadata entries for this configuration.
/// </summary>
public IEnumerable<KeyValuePair<string, string?>> ToMetadata(string prefix)
{
yield return new($"{prefix}.path", FilePath);
if (ProjectName is not null)
{
yield return new($"{prefix}.name", ProjectName);
}
if (ProjectVersion is not null)
{
yield return new($"{prefix}.version", ProjectVersion);
}
if (Extras.Length > 0)
{
yield return new($"{prefix}.extras", string.Join(",", Extras));
}
if (Scripts.Count > 0)
{
yield return new($"{prefix}.scripts", string.Join(",", Scripts.Keys));
}
}
}

View File

@@ -0,0 +1,395 @@
using System.Collections.Immutable;
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Capabilities;
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Entrypoints;
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Framework;
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Imports;
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Packaging;
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.VirtualFileSystem;
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.Observations;
/// <summary>
/// Builds AOC-compliant observation documents from analysis results.
/// </summary>
internal sealed class PythonObservationBuilder
{
private const string SchemaVersion = "python-aoc-v1";
private readonly List<PythonObservationPackage> _packages = [];
private readonly List<PythonObservationModule> _modules = [];
private readonly List<PythonObservationEntrypoint> _entrypoints = [];
private readonly List<PythonObservationDependencyEdge> _dependencyEdges = [];
private readonly List<PythonObservationImportEdge> _importEdges = [];
private readonly List<PythonObservationNativeExtension> _nativeExtensions = [];
private readonly List<PythonObservationFrameworkHint> _frameworks = [];
private readonly List<PythonObservationWarning> _warnings = [];
private readonly List<string> _securitySensitiveCapabilities = [];
private PythonObservationEnvironment? _environment;
private PythonObservationRuntimeEvidence? _runtimeEvidence;
private bool _usesProcessExecution;
private bool _usesNetworkAccess;
private bool _usesFileSystem;
private bool _usesCodeExecution;
private bool _usesDeserialization;
private bool _usesNativeCode;
private bool _usesAsyncAwait;
private bool _usesMultiprocessing;
/// <summary>
/// Adds packages from package discovery results.
/// </summary>
public PythonObservationBuilder AddPackages(IEnumerable<PythonPackageInfo> packages)
{
foreach (var pkg in packages)
{
_packages.Add(new PythonObservationPackage(
Name: pkg.Name,
Version: pkg.Version ?? "unknown",
Source: pkg.Kind.ToString(),
Platform: null,
IsDirect: pkg.IsDirectDependency,
InstallerKind: pkg.InstallerTool,
DistInfoPath: pkg.MetadataPath,
Groups: pkg.Extras,
Extras: pkg.Extras));
// Add dependency edges
foreach (var dep in pkg.Dependencies)
{
_dependencyEdges.Add(new PythonObservationDependencyEdge(
FromPackage: pkg.Name,
ToPackage: ExtractPackageName(dep),
VersionConstraint: ExtractVersionConstraint(dep),
Extra: null,
IsOptional: false));
}
}
return this;
}
/// <summary>
/// Adds modules from import graph analysis.
/// </summary>
public PythonObservationBuilder AddModules(
IEnumerable<PythonModuleNode> moduleNodes,
PythonImportGraph? importGraph = null)
{
foreach (var node in moduleNodes)
{
var imports = importGraph?.GetImportsForFile(node.VirtualPath ?? "")
.SelectMany(i => i.ImportedNames)
.ToImmutableArray() ?? ImmutableArray<string>.Empty;
_modules.Add(new PythonObservationModule(
Name: node.ModulePath,
Type: node.IsPackage ? "package" : "module",
FilePath: node.VirtualPath ?? "",
Line: null,
IsNamespacePackage: node.IsNamespacePackage,
ParentPackage: ExtractParentPackage(node.ModulePath),
Imports: imports));
}
return this;
}
/// <summary>
/// Adds import edges from the import graph.
/// </summary>
public PythonObservationBuilder AddImportEdges(IEnumerable<PythonImportEdge> edges)
{
foreach (var edge in edges)
{
_importEdges.Add(new PythonObservationImportEdge(
FromModule: edge.From,
ToModule: edge.To,
Kind: MapImportKind(edge.Import.Kind),
Confidence: MapImportConfidence(edge.Import.Confidence),
ResolvedPath: null,
SourceFile: edge.Import.SourceFile,
Line: edge.Import.LineNumber ?? 0,
ResolverTrace: ImmutableArray<string>.Empty));
}
return this;
}
/// <summary>
/// Adds entrypoints from entrypoint discovery.
/// </summary>
public PythonObservationBuilder AddEntrypoints(IEnumerable<PythonEntrypoint> entrypoints)
{
foreach (var ep in entrypoints)
{
_entrypoints.Add(new PythonObservationEntrypoint(
Path: ep.VirtualPath ?? ep.Target,
Type: ep.Kind.ToString(),
Handler: ep.Callable,
RequiredPackages: ImmutableArray<string>.Empty,
InvocationContext: ep.InvocationContext.InvocationType.ToString()));
}
return this;
}
/// <summary>
/// Adds capabilities from capability detection.
/// </summary>
public PythonObservationBuilder AddCapabilities(IEnumerable<PythonCapability> capabilities)
{
foreach (var cap in capabilities)
{
switch (cap.Kind)
{
case PythonCapabilityKind.ProcessExecution:
_usesProcessExecution = true;
break;
case PythonCapabilityKind.NetworkAccess:
_usesNetworkAccess = true;
break;
case PythonCapabilityKind.FileSystemAccess:
_usesFileSystem = true;
break;
case PythonCapabilityKind.CodeExecution:
_usesCodeExecution = true;
break;
case PythonCapabilityKind.Deserialization:
_usesDeserialization = true;
break;
case PythonCapabilityKind.Ctypes or PythonCapabilityKind.Cffi or PythonCapabilityKind.NativeCodeExecution:
_usesNativeCode = true;
break;
case PythonCapabilityKind.AsyncAwait:
_usesAsyncAwait = true;
break;
case PythonCapabilityKind.Multiprocessing:
_usesMultiprocessing = true;
break;
}
if (cap.IsSecuritySensitive && !_securitySensitiveCapabilities.Contains(cap.Kind.ToString()))
{
_securitySensitiveCapabilities.Add(cap.Kind.ToString());
}
}
return this;
}
/// <summary>
/// Adds native extensions from extension scanning.
/// </summary>
public PythonObservationBuilder AddNativeExtensions(IEnumerable<PythonNativeExtension> extensions)
{
foreach (var ext in extensions)
{
_nativeExtensions.Add(new PythonObservationNativeExtension(
ModuleName: ext.ModuleName,
Path: ext.Path,
Kind: ext.Kind.ToString(),
Platform: ext.Platform,
Architecture: ext.Architecture,
PackageName: ext.PackageName));
_usesNativeCode = true;
}
return this;
}
/// <summary>
/// Adds framework hints from framework detection.
/// </summary>
public PythonObservationBuilder AddFrameworkHints(IEnumerable<PythonFrameworkHint> hints)
{
foreach (var hint in hints)
{
string? category = null;
if (hint.IsWebFramework) category = "WebFramework";
else if (hint.IsTaskQueue) category = "TaskQueue";
else if (hint.IsServerless) category = "Serverless";
else if (hint.IsCliFramework) category = "CLI";
_frameworks.Add(new PythonObservationFrameworkHint(
Kind: hint.Kind.ToString(),
SourceFile: hint.SourceFile,
Line: hint.LineNumber,
Evidence: hint.Evidence,
Confidence: MapConfidence(hint.Confidence),
Category: category));
}
return this;
}
/// <summary>
/// Sets environment information.
/// </summary>
public PythonObservationBuilder SetEnvironment(
string? pythonVersion,
IEnumerable<string>? sitePackagesPaths = null,
IEnumerable<string>? requirementsFiles = null,
IEnumerable<string>? pyprojectFiles = null,
string? virtualenvPath = null,
string? condaPrefix = null,
bool isContainer = false)
{
_environment = new PythonObservationEnvironment(
PythonVersion: pythonVersion,
SitePackagesPaths: sitePackagesPaths?.ToImmutableArray() ?? ImmutableArray<string>.Empty,
VersionSources: ImmutableArray<PythonObservationVersionSource>.Empty,
RequirementsFiles: requirementsFiles?.ToImmutableArray() ?? ImmutableArray<string>.Empty,
PyprojectFiles: pyprojectFiles?.ToImmutableArray() ?? ImmutableArray<string>.Empty,
VirtualenvPath: virtualenvPath,
CondaPrefix: condaPrefix,
IsContainer: isContainer);
return this;
}
/// <summary>
/// Adds a warning.
/// </summary>
public PythonObservationBuilder AddWarning(
string code,
string message,
string? filePath = null,
int? line = null,
string severity = "warning")
{
_warnings.Add(new PythonObservationWarning(
Code: code,
Message: message,
FilePath: filePath,
Line: line,
Severity: severity));
return this;
}
/// <summary>
/// Sets runtime evidence from optional runtime analysis.
/// </summary>
public PythonObservationBuilder SetRuntimeEvidence(PythonObservationRuntimeEvidence evidence)
{
_runtimeEvidence = evidence;
return this;
}
/// <summary>
/// Builds the final observation document.
/// </summary>
public PythonObservationDocument Build()
{
var detectedFrameworks = _frameworks
.Select(f => f.Kind)
.Distinct()
.ToImmutableArray();
return new PythonObservationDocument(
Schema: SchemaVersion,
Packages: _packages.ToImmutableArray(),
Modules: _modules.ToImmutableArray(),
Entrypoints: _entrypoints.ToImmutableArray(),
DependencyEdges: _dependencyEdges.ToImmutableArray(),
ImportEdges: _importEdges.ToImmutableArray(),
NativeExtensions: _nativeExtensions.ToImmutableArray(),
Frameworks: _frameworks.ToImmutableArray(),
Warnings: _warnings.ToImmutableArray(),
Environment: _environment ?? new PythonObservationEnvironment(
PythonVersion: null,
SitePackagesPaths: ImmutableArray<string>.Empty,
VersionSources: ImmutableArray<PythonObservationVersionSource>.Empty,
RequirementsFiles: ImmutableArray<string>.Empty,
PyprojectFiles: ImmutableArray<string>.Empty,
VirtualenvPath: null,
CondaPrefix: null,
IsContainer: false),
Capabilities: new PythonObservationCapabilitySummary(
UsesProcessExecution: _usesProcessExecution,
UsesNetworkAccess: _usesNetworkAccess,
UsesFileSystem: _usesFileSystem,
UsesCodeExecution: _usesCodeExecution,
UsesDeserialization: _usesDeserialization,
UsesNativeCode: _usesNativeCode,
UsesAsyncAwait: _usesAsyncAwait,
UsesMultiprocessing: _usesMultiprocessing,
DetectedFrameworks: detectedFrameworks,
SecuritySensitiveCapabilities: _securitySensitiveCapabilities.ToImmutableArray()),
RuntimeEvidence: _runtimeEvidence);
}
private static PythonObservationImportKind MapImportKind(PythonImportKind kind)
{
return kind switch
{
PythonImportKind.Import => PythonObservationImportKind.Import,
PythonImportKind.FromImport => PythonObservationImportKind.FromImport,
PythonImportKind.RelativeImport => PythonObservationImportKind.RelativeImport,
PythonImportKind.ImportlibImportModule => PythonObservationImportKind.DynamicImport,
PythonImportKind.BuiltinImport => PythonObservationImportKind.DynamicImport,
_ => PythonObservationImportKind.Import
};
}
private static PythonObservationConfidence MapImportConfidence(PythonImportConfidence confidence)
{
return confidence switch
{
PythonImportConfidence.Low => PythonObservationConfidence.Low,
PythonImportConfidence.Medium => PythonObservationConfidence.Medium,
PythonImportConfidence.High => PythonObservationConfidence.High,
PythonImportConfidence.Definitive => PythonObservationConfidence.Definitive,
_ => PythonObservationConfidence.Medium
};
}
private static PythonObservationConfidence MapConfidence(PythonFrameworkConfidence confidence)
{
return confidence switch
{
PythonFrameworkConfidence.Low => PythonObservationConfidence.Low,
PythonFrameworkConfidence.Medium => PythonObservationConfidence.Medium,
PythonFrameworkConfidence.High => PythonObservationConfidence.High,
PythonFrameworkConfidence.Definitive => PythonObservationConfidence.Definitive,
_ => PythonObservationConfidence.Medium
};
}
private static string ExtractPackageName(string dependency)
{
// Extract package name from dependency spec like "requests>=2.0" or "numpy[extra]"
var name = dependency;
var bracketIdx = name.IndexOf('[');
if (bracketIdx > 0) name = name[..bracketIdx];
foreach (var op in new[] { ">=", "<=", "==", "!=", ">", "<", "~=", "^" })
{
var opIdx = name.IndexOf(op, StringComparison.Ordinal);
if (opIdx > 0) name = name[..opIdx];
}
return name.Trim();
}
private static string? ExtractVersionConstraint(string dependency)
{
foreach (var op in new[] { ">=", "<=", "==", "!=", ">", "<", "~=", "^" })
{
var opIdx = dependency.IndexOf(op, StringComparison.Ordinal);
if (opIdx > 0) return dependency[opIdx..].Trim();
}
return null;
}
private static string? ExtractParentPackage(string moduleName)
{
var lastDot = moduleName.LastIndexOf('.');
return lastDot > 0 ? moduleName[..lastDot] : null;
}
}

View File

@@ -0,0 +1,231 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.Observations;
/// <summary>
/// AOC-compliant observation document for Python project analysis.
/// Contains packages, modules, entrypoints, dependency edges, capabilities, and warnings.
/// </summary>
internal sealed record PythonObservationDocument(
string Schema,
ImmutableArray<PythonObservationPackage> Packages,
ImmutableArray<PythonObservationModule> Modules,
ImmutableArray<PythonObservationEntrypoint> Entrypoints,
ImmutableArray<PythonObservationDependencyEdge> DependencyEdges,
ImmutableArray<PythonObservationImportEdge> ImportEdges,
ImmutableArray<PythonObservationNativeExtension> NativeExtensions,
ImmutableArray<PythonObservationFrameworkHint> Frameworks,
ImmutableArray<PythonObservationWarning> Warnings,
PythonObservationEnvironment Environment,
PythonObservationCapabilitySummary Capabilities,
PythonObservationRuntimeEvidence? RuntimeEvidence = null);
/// <summary>
/// Python package detected in the project (from pip, conda, or other package managers).
/// </summary>
internal sealed record PythonObservationPackage(
string Name,
string Version,
string Source,
string? Platform,
bool IsDirect,
string? InstallerKind,
string? DistInfoPath,
ImmutableArray<string> Groups,
ImmutableArray<string> Extras);
/// <summary>
/// Python module or package detected in the project.
/// </summary>
internal sealed record PythonObservationModule(
string Name,
string Type,
string FilePath,
int? Line,
bool IsNamespacePackage,
string? ParentPackage,
ImmutableArray<string> Imports);
/// <summary>
/// Entrypoint detected in the Python project.
/// </summary>
internal sealed record PythonObservationEntrypoint(
string Path,
string Type,
string? Handler,
ImmutableArray<string> RequiredPackages,
string? InvocationContext);
/// <summary>
/// Package dependency edge (declared in requirements or pyproject).
/// </summary>
internal sealed record PythonObservationDependencyEdge(
string FromPackage,
string ToPackage,
string? VersionConstraint,
string? Extra,
bool IsOptional);
/// <summary>
/// Import edge between modules with reason codes and confidence.
/// </summary>
internal sealed record PythonObservationImportEdge(
string FromModule,
string ToModule,
PythonObservationImportKind Kind,
PythonObservationConfidence Confidence,
string? ResolvedPath,
string SourceFile,
int Line,
ImmutableArray<string> ResolverTrace);
/// <summary>
/// Import edge types.
/// </summary>
internal enum PythonObservationImportKind
{
/// <summary>Standard import statement.</summary>
Import,
/// <summary>From X import Y statement.</summary>
FromImport,
/// <summary>Relative import within package.</summary>
RelativeImport,
/// <summary>Dynamic import via importlib.</summary>
DynamicImport,
/// <summary>Namespace package implicit import.</summary>
NamespacePackage,
/// <summary>Native extension load.</summary>
NativeExtension,
/// <summary>Heuristic/hint-based import (not definitively resolved).</summary>
Hint
}
/// <summary>
/// Confidence level for observations.
/// </summary>
internal enum PythonObservationConfidence
{
/// <summary>Low confidence - heuristic match.</summary>
Low = 0,
/// <summary>Medium confidence - likely correct.</summary>
Medium = 1,
/// <summary>High confidence - clear evidence.</summary>
High = 2,
/// <summary>Definitive - direct evidence found.</summary>
Definitive = 3
}
/// <summary>
/// Native extension detected in the project.
/// </summary>
internal sealed record PythonObservationNativeExtension(
string ModuleName,
string Path,
string Kind,
string? Platform,
string? Architecture,
string? PackageName);
/// <summary>
/// Framework hint detected in the project.
/// </summary>
internal sealed record PythonObservationFrameworkHint(
string Kind,
string SourceFile,
int? Line,
string Evidence,
PythonObservationConfidence Confidence,
string? Category);
/// <summary>
/// Analysis warning generated during scanning.
/// </summary>
internal sealed record PythonObservationWarning(
string Code,
string Message,
string? FilePath,
int? Line,
string Severity);
/// <summary>
/// Environment profile with Python version, package manager settings, and paths.
/// </summary>
internal sealed record PythonObservationEnvironment(
string? PythonVersion,
ImmutableArray<string> SitePackagesPaths,
ImmutableArray<PythonObservationVersionSource> VersionSources,
ImmutableArray<string> RequirementsFiles,
ImmutableArray<string> PyprojectFiles,
string? VirtualenvPath,
string? CondaPrefix,
bool IsContainer);
/// <summary>
/// Python version source with provenance.
/// </summary>
internal sealed record PythonObservationVersionSource(
string? Version,
string Source,
string SourceType);
/// <summary>
/// Capability summary for the Python project.
/// </summary>
internal sealed record PythonObservationCapabilitySummary(
bool UsesProcessExecution,
bool UsesNetworkAccess,
bool UsesFileSystem,
bool UsesCodeExecution,
bool UsesDeserialization,
bool UsesNativeCode,
bool UsesAsyncAwait,
bool UsesMultiprocessing,
ImmutableArray<string> DetectedFrameworks,
ImmutableArray<string> SecuritySensitiveCapabilities);
/// <summary>
/// Optional runtime evidence section for Python.
/// </summary>
internal sealed record PythonObservationRuntimeEvidence(
bool HasEvidence,
string? RuntimePythonVersion,
string? RuntimePlatform,
int LoadedModulesCount,
ImmutableArray<string> LoadedPackages,
ImmutableArray<string> LoadedModules,
ImmutableDictionary<string, string> PathHashes,
ImmutableArray<string> RuntimeCapabilities,
ImmutableArray<PythonObservationRuntimeError> Errors)
{
/// <summary>
/// Empty runtime evidence instance.
/// </summary>
public static PythonObservationRuntimeEvidence Empty { get; } = new(
HasEvidence: false,
RuntimePythonVersion: null,
RuntimePlatform: null,
LoadedModulesCount: 0,
LoadedPackages: ImmutableArray<string>.Empty,
LoadedModules: ImmutableArray<string>.Empty,
PathHashes: ImmutableDictionary<string, string>.Empty,
RuntimeCapabilities: ImmutableArray<string>.Empty,
Errors: ImmutableArray<PythonObservationRuntimeError>.Empty);
}
/// <summary>
/// Runtime error captured during execution.
/// </summary>
internal sealed record PythonObservationRuntimeError(
string Timestamp,
string Message,
string? Path,
string? PathSha256);

View File

@@ -0,0 +1,73 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.Observations;
/// <summary>
/// Serializes Python observation documents to JSON.
/// </summary>
internal static class PythonObservationSerializer
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters =
{
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)
}
};
private static readonly JsonSerializerOptions CompactSerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters =
{
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)
}
};
/// <summary>
/// Serializes the observation document to JSON.
/// </summary>
public static string Serialize(PythonObservationDocument document, bool compact = false)
{
var options = compact ? CompactSerializerOptions : SerializerOptions;
return JsonSerializer.Serialize(document, options);
}
/// <summary>
/// Serializes the observation document to a stream.
/// </summary>
public static async Task SerializeAsync(
PythonObservationDocument document,
Stream stream,
bool compact = false,
CancellationToken cancellationToken = default)
{
var options = compact ? CompactSerializerOptions : SerializerOptions;
await JsonSerializer.SerializeAsync(stream, document, options, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Deserializes a JSON string to an observation document.
/// </summary>
public static PythonObservationDocument? Deserialize(string json)
{
return JsonSerializer.Deserialize<PythonObservationDocument>(json, SerializerOptions);
}
/// <summary>
/// Deserializes a stream to an observation document.
/// </summary>
public static async Task<PythonObservationDocument?> DeserializeAsync(
Stream stream,
CancellationToken cancellationToken = default)
{
return await JsonSerializer.DeserializeAsync<PythonObservationDocument>(
stream, SerializerOptions, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,528 @@
using System.IO.Compression;
using System.Text;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal;
/// <summary>
/// Analyzes Python zipapp archives (.pyz, .pyzw) for runtime information,
/// entry points, and startup behavior.
/// </summary>
internal static partial class PythonZipappAdapter
{
private static readonly string[] LayerRootCandidates = { "layers", ".layers", "layer" };
/// <summary>
/// Discovers zipapp files in the workspace and container layers.
/// </summary>
public static IReadOnlyCollection<string> DiscoverZipapps(string rootPath)
{
var discovered = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Search in root path
DiscoverInDirectory(rootPath, discovered);
// Search in container layers
foreach (var layerRoot in EnumerateLayerRoots(rootPath))
{
DiscoverInDirectory(layerRoot, discovered);
// Check common locations within layers
var appDir = Path.Combine(layerRoot, "app");
if (Directory.Exists(appDir))
{
DiscoverInDirectory(appDir, discovered);
}
var optDir = Path.Combine(layerRoot, "opt");
if (Directory.Exists(optDir))
{
DiscoverInDirectory(optDir, discovered);
}
var usrLocalBin = Path.Combine(layerRoot, "usr", "local", "bin");
if (Directory.Exists(usrLocalBin))
{
DiscoverInDirectory(usrLocalBin, discovered);
}
}
return discovered
.OrderBy(static path => path, StringComparer.Ordinal)
.ToArray();
}
/// <summary>
/// Analyzes a zipapp archive for runtime information.
/// </summary>
public static PythonZipappInfo? AnalyzeZipapp(string zipappPath)
{
if (!File.Exists(zipappPath))
{
return null;
}
try
{
var shebang = ExtractShebang(zipappPath);
var pythonVersion = shebang != null ? ParsePythonVersionFromShebang(shebang) : null;
var hasMain = false;
var hasInit = false;
var entryModule = (string?)null;
var warnings = new List<string>();
var dependencies = new List<string>();
// Open as zip archive to inspect contents
using var stream = File.OpenRead(zipappPath);
// Skip shebang if present
var firstByte = stream.ReadByte();
if (firstByte == '#')
{
// Skip to end of shebang line
while (stream.ReadByte() is int b && b != '\n' && b != -1)
{
}
}
else
{
stream.Position = 0;
}
using var archive = new ZipArchive(stream, ZipArchiveMode.Read);
foreach (var entry in archive.Entries)
{
var name = entry.FullName.Replace('\\', '/');
if (string.Equals(name, "__main__.py", StringComparison.OrdinalIgnoreCase))
{
hasMain = true;
entryModule = TryExtractEntryModule(entry);
}
else if (string.Equals(name, "__init__.py", StringComparison.OrdinalIgnoreCase))
{
hasInit = true;
}
else if (name.EndsWith("/__main__.py", StringComparison.OrdinalIgnoreCase))
{
// Package with __main__.py
var package = Path.GetDirectoryName(name)?.Replace('/', '.');
if (!string.IsNullOrEmpty(package))
{
entryModule ??= package;
}
}
else if (name.EndsWith("/requirements.txt", StringComparison.OrdinalIgnoreCase) ||
string.Equals(name, "requirements.txt", StringComparison.OrdinalIgnoreCase))
{
var reqs = ExtractRequirements(entry);
dependencies.AddRange(reqs);
}
}
// Generate warnings
if (!hasMain && !hasInit)
{
warnings.Add("Zipapp missing __main__.py; may not be directly executable");
}
if (shebang != null && shebang.Contains("/env ", StringComparison.OrdinalIgnoreCase))
{
warnings.Add("Zipapp uses /usr/bin/env shebang; Python version may vary by environment");
}
var isWindows = zipappPath.EndsWith(".pyzw", StringComparison.OrdinalIgnoreCase);
if (isWindows)
{
warnings.Add("Zipapp is Windows-specific (.pyzw); uses pythonw.exe without console");
}
return new PythonZipappInfo(
Path: zipappPath,
FileName: Path.GetFileName(zipappPath),
Shebang: shebang,
PythonVersion: pythonVersion,
HasMainPy: hasMain,
EntryModule: entryModule,
IsWindowsApp: isWindows,
EmbeddedDependencies: dependencies,
Warnings: warnings);
}
catch (IOException)
{
return null;
}
catch (InvalidDataException)
{
return null;
}
}
/// <summary>
/// Analyzes all zipapps in the workspace.
/// </summary>
public static PythonZipappAnalysis AnalyzeAll(string rootPath)
{
var zipapps = new List<PythonZipappInfo>();
var allWarnings = new List<string>();
foreach (var zipappPath in DiscoverZipapps(rootPath))
{
var info = AnalyzeZipapp(zipappPath);
if (info != null)
{
zipapps.Add(info);
allWarnings.AddRange(info.Warnings);
}
}
if (zipapps.Count > 1)
{
allWarnings.Add($"Multiple zipapps detected ({zipapps.Count}); entry point resolution may be ambiguous");
}
return new PythonZipappAnalysis(zipapps, allWarnings);
}
private static string? ExtractShebang(string path)
{
try
{
using var stream = File.OpenRead(path);
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true);
var firstLine = reader.ReadLine();
if (firstLine != null && firstLine.StartsWith("#!"))
{
return firstLine[2..].Trim();
}
return null;
}
catch (IOException)
{
return null;
}
}
private static string? ParsePythonVersionFromShebang(string shebang)
{
// Match patterns like:
// /usr/bin/python3.11
// /usr/bin/env python3.10
// python3.9
var match = PythonVersionPattern().Match(shebang);
if (match.Success)
{
return match.Groups["version"].Value;
}
// Check for generic python3 or python
if (shebang.Contains("python3", StringComparison.OrdinalIgnoreCase))
{
return "3";
}
if (shebang.Contains("python", StringComparison.OrdinalIgnoreCase))
{
return null; // Could be Python 2 or 3
}
return null;
}
private static string? TryExtractEntryModule(ZipArchiveEntry entry)
{
try
{
using var stream = entry.Open();
using var reader = new StreamReader(stream);
var content = reader.ReadToEnd();
// Look for common patterns:
// from package.module import main
// import package.main
// runpy.run_module('package')
var runpyMatch = RunpyPattern().Match(content);
if (runpyMatch.Success)
{
return runpyMatch.Groups["module"].Value;
}
var fromImportMatch = FromImportPattern().Match(content);
if (fromImportMatch.Success)
{
return fromImportMatch.Groups["module"].Value;
}
return null;
}
catch (IOException)
{
return null;
}
}
private static List<string> ExtractRequirements(ZipArchiveEntry entry)
{
var results = new List<string>();
try
{
using var stream = entry.Open();
using var reader = new StreamReader(stream);
var content = reader.ReadToEnd();
var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
var trimmed = line.Trim();
if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith('#') || trimmed.StartsWith('-'))
{
continue;
}
// Extract package name (before any version specifier)
var match = PackageNamePattern().Match(trimmed);
if (match.Success)
{
results.Add(match.Groups["name"].Value);
}
}
}
catch (IOException)
{
// Ignore read errors
}
return results;
}
private static void DiscoverInDirectory(string directory, HashSet<string> discovered)
{
if (!Directory.Exists(directory))
{
return;
}
try
{
foreach (var file in Directory.EnumerateFiles(directory, "*.pyz"))
{
discovered.Add(file);
}
foreach (var file in Directory.EnumerateFiles(directory, "*.pyzw"))
{
discovered.Add(file);
}
// Also check in subdirectories (up to 3 levels)
foreach (var subdir in Directory.EnumerateDirectories(directory))
{
DiscoverInSubdirectory(subdir, discovered, 1);
}
}
catch (IOException)
{
// Ignore
}
catch (UnauthorizedAccessException)
{
// Ignore
}
}
private static void DiscoverInSubdirectory(string directory, HashSet<string> discovered, int depth)
{
if (depth > 3)
{
return;
}
try
{
foreach (var file in Directory.EnumerateFiles(directory, "*.pyz"))
{
discovered.Add(file);
}
foreach (var file in Directory.EnumerateFiles(directory, "*.pyzw"))
{
discovered.Add(file);
}
foreach (var subdir in Directory.EnumerateDirectories(directory))
{
var dirName = Path.GetFileName(subdir);
// Skip common non-relevant directories
if (dirName.StartsWith('.') ||
string.Equals(dirName, "node_modules", StringComparison.OrdinalIgnoreCase) ||
string.Equals(dirName, "__pycache__", StringComparison.OrdinalIgnoreCase) ||
string.Equals(dirName, "venv", StringComparison.OrdinalIgnoreCase) ||
string.Equals(dirName, ".venv", StringComparison.OrdinalIgnoreCase))
{
continue;
}
DiscoverInSubdirectory(subdir, discovered, depth + 1);
}
}
catch (IOException)
{
// Ignore
}
catch (UnauthorizedAccessException)
{
// Ignore
}
}
private static IEnumerable<string> EnumerateLayerRoots(string workspaceRoot)
{
foreach (var candidate in LayerRootCandidates)
{
var root = Path.Combine(workspaceRoot, candidate);
if (!Directory.Exists(root))
{
continue;
}
IEnumerable<string>? directories;
try
{
directories = Directory.EnumerateDirectories(root);
}
catch (IOException)
{
continue;
}
catch (UnauthorizedAccessException)
{
continue;
}
foreach (var layerDirectory in directories)
{
var fsDirectory = Path.Combine(layerDirectory, "fs");
yield return Directory.Exists(fsDirectory) ? fsDirectory : layerDirectory;
}
}
}
[GeneratedRegex(@"python(?<version>\d+\.\d+)", RegexOptions.IgnoreCase)]
private static partial Regex PythonVersionPattern();
[GeneratedRegex(@"runpy\.run_module\(['""](?<module>[^'""]+)['""]", RegexOptions.IgnoreCase)]
private static partial Regex RunpyPattern();
[GeneratedRegex(@"from\s+(?<module>[\w.]+)\s+import", RegexOptions.IgnoreCase)]
private static partial Regex FromImportPattern();
[GeneratedRegex(@"^(?<name>[\w\-_.]+)", RegexOptions.IgnoreCase)]
private static partial Regex PackageNamePattern();
}
/// <summary>
/// Information about a Python zipapp archive.
/// </summary>
internal sealed record PythonZipappInfo(
string Path,
string FileName,
string? Shebang,
string? PythonVersion,
bool HasMainPy,
string? EntryModule,
bool IsWindowsApp,
IReadOnlyCollection<string> EmbeddedDependencies,
IReadOnlyCollection<string> Warnings)
{
public IReadOnlyCollection<KeyValuePair<string, string?>> ToMetadata()
{
var entries = new List<KeyValuePair<string, string?>>
{
new("zipapp.path", Path),
new("zipapp.hasMain", HasMainPy.ToString().ToLowerInvariant())
};
if (Shebang != null)
{
entries.Add(new("zipapp.shebang", Shebang));
}
if (PythonVersion != null)
{
entries.Add(new("zipapp.pythonVersion", PythonVersion));
}
if (EntryModule != null)
{
entries.Add(new("zipapp.entryModule", EntryModule));
}
if (IsWindowsApp)
{
entries.Add(new("zipapp.windowsApp", "true"));
}
if (EmbeddedDependencies.Count > 0)
{
entries.Add(new("zipapp.embeddedDeps.count", EmbeddedDependencies.Count.ToString()));
}
return entries;
}
}
/// <summary>
/// Analysis results for all zipapps in a workspace.
/// </summary>
internal sealed class PythonZipappAnalysis
{
public PythonZipappAnalysis(
IReadOnlyCollection<PythonZipappInfo> zipapps,
IReadOnlyCollection<string> warnings)
{
Zipapps = zipapps;
Warnings = warnings;
}
public IReadOnlyCollection<PythonZipappInfo> Zipapps { get; }
public IReadOnlyCollection<string> Warnings { get; }
public bool HasZipapps => Zipapps.Count > 0;
public bool HasWarnings => Warnings.Count > 0;
public IReadOnlyCollection<KeyValuePair<string, string?>> ToMetadata()
{
var entries = new List<KeyValuePair<string, string?>>();
if (Zipapps.Count > 0)
{
entries.Add(new("zipapps.count", Zipapps.Count.ToString()));
var withShebang = Zipapps.Count(z => z.Shebang != null);
if (withShebang > 0)
{
entries.Add(new("zipapps.withShebang", withShebang.ToString()));
}
var windowsApps = Zipapps.Count(z => z.IsWindowsApp);
if (windowsApps > 0)
{
entries.Add(new("zipapps.windowsApps", windowsApps.ToString()));
}
}
for (var i = 0; i < Warnings.Count; i++)
{
entries.Add(new($"zipapps.warning[{i}]", Warnings.ElementAt(i)));
}
return entries;
}
}

View File

@@ -0,0 +1,194 @@
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.RuntimeEvidence;
/// <summary>
/// Provides the Python import hook script for runtime evidence collection.
/// </summary>
internal static class PythonImportHookScript
{
/// <summary>
/// Gets the Python import hook script that captures module load events.
/// This script outputs NDJSON to stdout.
/// </summary>
public static string GetScript() => Script;
/// <summary>
/// Gets the Python import hook script that writes to a file.
/// </summary>
/// <param name="outputPath">The path to write output to.</param>
/// <returns>The modified script.</returns>
public static string GetFileScript(string outputPath)
{
var escapedPath = outputPath.Replace("\\", "\\\\").Replace("'", "\\'");
return Script.Replace(
"_stellaops_output = None",
$"_stellaops_output = open('{escapedPath}', 'w', buffering=1)");
}
// The Python script is stored as a verbatim string to avoid issues with # characters
private const string Script = @"
# StellaOps Python Import Hook
# This script captures module import events for static analysis validation.
# Output format: NDJSON (Newline-Delimited JSON)
import sys
import json
import threading
import os
from datetime import datetime, timezone
_stellaops_output = None
_stellaops_lock = threading.Lock()
_stellaops_seen = set()
def _stellaops_emit(event_type, **kwargs):
""""""Emit an event as JSON.""""""
event = {
'type': event_type,
'timestamp': datetime.now(timezone.utc).isoformat(),
'pid': os.getpid(),
**kwargs
}
with _stellaops_lock:
line = json.dumps(event, default=str)
if _stellaops_output:
_stellaops_output.write(line + '\n')
_stellaops_output.flush()
else:
print(line, flush=True)
def _stellaops_get_module_path(module):
""""""Get the file path for a module if available.""""""
try:
if hasattr(module, '__file__') and module.__file__:
return module.__file__
if hasattr(module, '__spec__') and module.__spec__:
if hasattr(module.__spec__, 'origin') and module.__spec__.origin:
return module.__spec__.origin
except Exception:
pass
return None
class StellaOpsMetaPathFinder:
""""""Meta path finder that logs all import attempts.""""""
def find_module(self, fullname, path=None):
if fullname not in _stellaops_seen:
_stellaops_seen.add(fullname)
_stellaops_emit('import_attempt', module=fullname, path=str(path) if path else None)
return None
def find_spec(self, fullname, path, target=None):
return None
def _stellaops_wrap_import():
""""""Wrap the built-in __import__ to capture all imports.""""""
original_import = __builtins__.__import__ if hasattr(__builtins__, '__import__') else __builtins__['__import__']
def wrapped_import(name, globals=None, locals=None, fromlist=(), level=0):
module = original_import(name, globals, locals, fromlist, level)
if name not in _stellaops_seen:
_stellaops_seen.add(name)
path = _stellaops_get_module_path(module)
is_native = path and (path.endswith('.so') or path.endswith('.pyd'))
event_type = 'native_load' if is_native else 'module_import'
_stellaops_emit(
event_type,
module=name,
path=path,
parent=module.__package__ if hasattr(module, '__package__') else None,
tid=threading.get_ident()
)
return module
if hasattr(__builtins__, '__import__'):
__builtins__.__import__ = wrapped_import
else:
__builtins__['__import__'] = wrapped_import
def _stellaops_wrap_subprocess():
""""""Wrap subprocess module to capture process spawns.""""""
try:
import subprocess
original_popen = subprocess.Popen
class WrappedPopen(original_popen):
def __init__(self, *args, **kwargs):
_stellaops_emit('process_spawn', spawn_type='subprocess', args=str(args[0]) if args else None)
super().__init__(*args, **kwargs)
subprocess.Popen = WrappedPopen
except Exception:
pass
def _stellaops_wrap_multiprocessing():
""""""Wrap multiprocessing module to capture process spawns.""""""
try:
import multiprocessing
original_process = multiprocessing.Process
class WrappedProcess(original_process):
def __init__(self, *args, **kwargs):
_stellaops_emit('process_spawn', spawn_type='multiprocessing', target=str(kwargs.get('target')))
super().__init__(*args, **kwargs)
multiprocessing.Process = WrappedProcess
except Exception:
pass
def stellaops_start_tracing():
""""""Initialize runtime evidence collection.""""""
# Emit interpreter start event
_stellaops_emit(
'interpreter_start',
python_version=sys.version,
platform=sys.platform,
executable=sys.executable
)
# Install import hook
_stellaops_wrap_import()
# Install meta path finder
sys.meta_path.insert(0, StellaOpsMetaPathFinder())
# Wrap subprocess and multiprocessing (optional)
_stellaops_wrap_subprocess()
_stellaops_wrap_multiprocessing()
# Record already-loaded modules
for name, module in list(sys.modules.items()):
if module is not None and name not in _stellaops_seen:
_stellaops_seen.add(name)
path = _stellaops_get_module_path(module)
if path:
is_native = path.endswith('.so') or path.endswith('.pyd')
event_type = 'native_load' if is_native else 'module_import'
_stellaops_emit(
event_type,
module=name,
path=path,
preloaded=True
)
# Auto-start if this module is imported
if __name__ != '__main__':
stellaops_start_tracing()
else:
# If run directly, start tracing and wait
stellaops_start_tracing()
import sys
print('StellaOps import hook active. Press Ctrl+C to stop.', file=sys.stderr)
try:
import time
while True:
time.sleep(1)
except KeyboardInterrupt:
pass
";
}

Some files were not shown because too many files have changed in this diff Show More