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
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:
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
258
src/Cli/StellaOps.Cli/Services/Models/VexModels.cs
Normal file
258
src/Cli/StellaOps.Cli/Services/Models/VexModels.cs
Normal 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);
|
||||
291
src/Cli/StellaOps.Cli/Services/Models/VulnModels.cs
Normal file
291
src/Cli/StellaOps.Cli/Services/Models/VulnModels.cs
Normal 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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 (<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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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"}";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
@@ -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)}";
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user