Add signal contracts for reachability, exploitability, trust, and unknown symbols
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Signals DSSE Sign & Evidence Locker / sign-signals-artifacts (push) Has been cancelled
Signals DSSE Sign & Evidence Locker / verify-signatures (push) Has been cancelled

- Introduced `ReachabilityState`, `RuntimeHit`, `ExploitabilitySignal`, `ReachabilitySignal`, `SignalEnvelope`, `SignalType`, `TrustSignal`, and `UnknownSymbolSignal` records to define various signal types and their properties.
- Implemented JSON serialization attributes for proper data interchange.
- Created project files for the new signal contracts library and corresponding test projects.
- Added deterministic test fixtures for micro-interaction testing.
- Included cryptographic keys for secure operations with cosign.
This commit is contained in:
StellaOps Bot
2025-12-05 00:27:00 +02:00
parent b018949a8d
commit 8768c27f30
192 changed files with 27569 additions and 2552 deletions

View File

@@ -0,0 +1,77 @@
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Xunit;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class GoldenFixturesTests
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
[Fact]
public void SealedBundle_Fixture_HashAndSubjectMatch()
{
var root = FixturePath("sealed");
var manifest = ReadJson(Path.Combine(root, "manifest.json"));
var checksums = ReadJson(Path.Combine(root, "checksums.txt"));
var signature = ReadJson(Path.Combine(root, "signature.json"));
var expected = ReadJson(Path.Combine(root, "expected.json"));
var rootFromChecksums = checksums.GetProperty("root").GetString();
Assert.Equal(expected.GetProperty("merkleRoot").GetString(), rootFromChecksums);
var subject = signature.GetProperty("signatures")[0].GetProperty("subjectMerkleRoot").GetString();
Assert.Equal(rootFromChecksums, subject);
var entries = manifest.GetProperty("entries").EnumerateArray().Select(e => e.GetProperty("canonicalPath").GetString()).ToArray();
var checksumEntries = checksums.GetProperty("entries").EnumerateArray().Select(e => e.GetProperty("canonicalPath").GetString()).ToArray();
Assert.Equal(entries.OrderBy(x => x), checksumEntries.OrderBy(x => x));
// Recompute sha256(checksums.txt) to match DSSE subject binding rule
var checksumJson = File.ReadAllText(Path.Combine(root, "checksums.txt"));
var recomputedSubject = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(checksumJson))).ToLowerInvariant();
Assert.Equal(rootFromChecksums, recomputedSubject);
}
[Fact]
public void PortableBundle_Fixture_RedactionAndSubjectMatch()
{
var root = FixturePath("portable");
var manifest = ReadJson(Path.Combine(root, "manifest.json"));
var checksums = ReadJson(Path.Combine(root, "checksums.txt"));
var expected = ReadJson(Path.Combine(root, "expected.json"));
Assert.True(manifest.GetProperty("redaction").GetProperty("portable").GetBoolean());
Assert.DoesNotContain("tenant", File.ReadAllText(Path.Combine(root, "bundle.json")), StringComparison.OrdinalIgnoreCase);
var rootFromChecksums = checksums.GetProperty("root").GetString();
Assert.Equal(expected.GetProperty("merkleRoot").GetString(), rootFromChecksums);
var checksumJson = File.ReadAllText(Path.Combine(root, "checksums.txt"));
var recomputedSubject = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(checksumJson))).ToLowerInvariant();
Assert.Equal(rootFromChecksums, recomputedSubject);
}
[Fact]
public void ReplayFixture_RecordDigestMatches()
{
var root = FixturePath("replay");
var replayPath = Path.Combine(root, "replay.ndjson");
var replayContent = File.ReadAllBytes(replayPath);
var expected = ReadJson(Path.Combine(root, "expected.json"));
var hash = "sha256:" + Convert.ToHexString(SHA256.HashData(replayContent)).ToLowerInvariant();
Assert.Equal(expected.GetProperty("recordDigest").GetString(), hash);
}
private static string FixturePath(string relative) =>
Path.Combine(AppContext.BaseDirectory, "Fixtures", relative);
private static JsonElement ReadJson(string path)
{
using var doc = JsonDocument.Parse(File.ReadAllText(path), new JsonDocumentOptions { AllowTrailingCommas = true });
return doc.RootElement.Clone();
}
}

View File

@@ -21,6 +21,9 @@
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
<Content Include="../../../tests/EvidenceLocker/Bundles/Golden/**/*.*"
CopyToOutputDirectory="PreserveNewest"
Link="Fixtures/%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,28 @@
namespace StellaOps.Gateway.WebService;
/// <summary>
/// Static configuration for a gateway node.
/// </summary>
public sealed class GatewayNodeConfig
{
/// <summary>
/// Gets the region where this gateway is deployed (e.g., "eu1").
/// Routing decisions use this value; it is never derived from headers or URLs.
/// </summary>
public required string Region { get; init; }
/// <summary>
/// Gets the unique identifier for this gateway node (e.g., "gw-eu1-01").
/// </summary>
public required string NodeId { get; init; }
/// <summary>
/// Gets the environment name (e.g., "prod", "staging", "dev").
/// </summary>
public required string Environment { get; init; }
/// <summary>
/// Gets the neighbor regions for fallback routing, in order of preference.
/// </summary>
public IReadOnlyList<string> NeighborRegions { get; init; } = [];
}

View File

@@ -0,0 +1,13 @@
var builder = WebApplication.CreateBuilder(args);
// Placeholder: Gateway services will be registered here in later sprints
var app = builder.Build();
// Placeholder: Middleware pipeline will be configured here in later sprints
app.MapGet("/health", () => Results.Ok(new { status = "healthy" }));
app.Run();
// Make Program class accessible for integration tests
public partial class Program { }

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Config\StellaOps.Router.Config.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,71 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using StellaOps.Graph.Indexer.Ingestion.Sbom;
namespace StellaOps.Graph.Indexer.Ingestion.Inspector;
public sealed class GraphInspectorProcessor
{
private readonly GraphInspectorTransformer _transformer;
private readonly IGraphDocumentWriter _writer;
private readonly ILogger<GraphInspectorProcessor> _logger;
public GraphInspectorProcessor(
GraphInspectorTransformer transformer,
IGraphDocumentWriter writer,
ILogger<GraphInspectorProcessor> logger)
{
_transformer = transformer ?? throw new ArgumentNullException(nameof(transformer));
_writer = writer ?? throw new ArgumentNullException(nameof(writer));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task ProcessAsync(GraphInspectorSnapshot snapshot, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(snapshot);
cancellationToken.ThrowIfCancellationRequested();
var stopwatch = Stopwatch.StartNew();
GraphBuildBatch batch;
try
{
batch = _transformer.Transform(snapshot);
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogError(
ex,
"graph-indexer: failed to transform graph.inspect snapshot for tenant {Tenant} artifact {ArtifactDigest}",
snapshot.Tenant,
snapshot.ArtifactDigest);
throw;
}
try
{
cancellationToken.ThrowIfCancellationRequested();
await _writer.WriteAsync(batch, cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
_logger.LogInformation(
"graph-indexer: ingested graph.inspect snapshot for tenant {Tenant} artifact {ArtifactDigest} with {NodeCount} nodes and {EdgeCount} edges in {DurationMs:F2} ms",
snapshot.Tenant,
snapshot.ArtifactDigest,
batch.Nodes.Length,
batch.Edges.Length,
stopwatch.Elapsed.TotalMilliseconds);
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogError(
ex,
"graph-indexer: failed to persist graph.inspect snapshot for tenant {Tenant} artifact {ArtifactDigest}",
snapshot.Tenant,
snapshot.ArtifactDigest);
throw;
}
}
}

View File

@@ -56,6 +56,8 @@ public sealed class GraphInspectorTransformer
},
relationship.Provenance);
nodes.Add(targetNode);
var edge = CreateRelationshipEdge(snapshot, componentNode, targetNode, relationship);
edges.Add(edge);
}
@@ -85,16 +87,40 @@ public sealed class GraphInspectorTransformer
}
}
foreach (var node in componentNodes.Values)
{
var id = node["id"]!.GetValue<string>();
if (!nodes.Any(n => n["id"]!.GetValue<string>() == id))
{
nodes.Add(node);
}
}
// Ensure all relationship targets are represented as component nodes even if they were not emitted during primary loops.
var targetPurls = snapshot.Components
.SelectMany(c => c.Relationships ?? Array.Empty<GraphInspectorRelationship>())
.Select(r => r.TargetPurl)
.Where(p => !string.IsNullOrWhiteSpace(p))
.Distinct(StringComparer.OrdinalIgnoreCase);
foreach (var targetPurl in targetPurls)
{
var key = $"{targetPurl.Trim()}|{DefaultSourceType}";
var targetNode = GetOrCreateComponentNode(
snapshot,
componentNodes,
new GraphInspectorComponent { Purl = targetPurl },
provenanceOverride: null);
componentNodes[key] = targetNode;
nodes.Add(targetNode);
}
var orderedNodes = nodes
.Distinct(JsonNodeEqualityComparer.Instance)
.Select(n => (JsonObject)n)
.OrderBy(n => n["kind"]!.GetValue<string>(), StringComparer.Ordinal)
.ThenBy(n => n["id"]!.GetValue<string>(), StringComparer.Ordinal)
.ToImmutableArray();
var orderedEdges = edges
.Distinct(JsonNodeEqualityComparer.Instance)
.Select(e => (JsonObject)e)
.OrderBy(e => e["kind"]!.GetValue<string>(), StringComparer.Ordinal)
.ThenBy(e => e["id"]!.GetValue<string>(), StringComparer.Ordinal)
.ToImmutableArray();
@@ -409,25 +435,4 @@ public sealed class GraphInspectorTransformer
EventOffset: eventOffset);
}
private sealed class JsonNodeEqualityComparer : IEqualityComparer<JsonNode>
{
public static readonly JsonNodeEqualityComparer Instance = new();
public bool Equals(JsonNode? x, JsonNode? y)
{
if (ReferenceEquals(x, y))
{
return true;
}
if (x is null || y is null)
{
return false;
}
return x.ToJsonString() == y.ToJsonString();
}
public int GetHashCode(JsonNode obj) => obj.ToJsonString().GetHashCode(StringComparison.Ordinal);
}
}

View File

@@ -0,0 +1,25 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace StellaOps.Graph.Indexer.Ingestion.Inspector;
public static class InspectorIngestServiceCollectionExtensions
{
public static IServiceCollection AddInspectorIngestPipeline(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.TryAddSingleton<GraphInspectorTransformer>();
services.TryAddSingleton<GraphInspectorProcessor>(provider =>
{
var transformer = provider.GetRequiredService<GraphInspectorTransformer>();
var writer = provider.GetRequiredService<Ingestion.Sbom.IGraphDocumentWriter>();
var logger = provider.GetService<ILogger<GraphInspectorProcessor>>() ?? NullLogger<GraphInspectorProcessor>.Instance;
return new GraphInspectorProcessor(transformer, writer, logger);
});
return services;
}
}

View File

@@ -1,5 +1,7 @@
using System.Text.Json;
using StellaOps.Graph.Indexer.Ingestion.Inspector;
using StellaOps.Graph.Indexer.Schema;
using Xunit.Sdk;
namespace StellaOps.Graph.Indexer.Tests;
@@ -86,21 +88,49 @@ public sealed class GraphInspectorTransformerTests
EventOffset = 5123,
EvidenceHash = "c1"
}
},
new GraphInspectorComponent
{
Purl = "pkg:npm/lodash@4.17.21",
Scopes = Array.Empty<string>(),
Relationships = Array.Empty<GraphInspectorRelationship>(),
Advisories = Array.Empty<GraphInspectorAdvisoryObservation>(),
VexStatements = Array.Empty<GraphInspectorVexStatement>(),
Provenance = new GraphInspectorProvenance
{
Source = "concelier.linkset.v1",
CollectedAt = DateTimeOffset.Parse("2025-12-04T15:29:00Z"),
EventOffset = 6000,
EvidenceHash = "e1"
}
}
}
};
var transformer = new GraphInspectorTransformer();
Assert.NotEmpty(snapshot.Components.First().Relationships);
Assert.Contains("pkg:npm/lodash@4.17.21", snapshot.Components.SelectMany(c => c.Relationships).Select(r => r.TargetPurl));
var batch = transformer.Transform(snapshot);
// Nodes: artifact + source component + target component + advisory + vex
Assert.Equal(5, batch.Nodes.Length);
Assert.Contains(batch.Nodes, n => n["kind"]!.GetValue<string>() == "artifact");
var nodesDebug = string.Join(" | ", batch.Nodes.Select(n => n.ToJsonString()));
Assert.Contains(batch.Nodes, n => n["kind"]!.GetValue<string>() == "component" && n["canonical_key"]!["purl"]!.GetValue<string>() == "pkg:maven/org.example/foo@1.2.3");
Assert.Contains(batch.Nodes, n => n["kind"]!.GetValue<string>() == "component" && n["canonical_key"]!["purl"]!.GetValue<string>() == "pkg:npm/lodash@4.17.21");
Assert.True(
batch.Nodes.Any(n => n["kind"]!.GetValue<string>() == "component" && n["canonical_key"]!["purl"]!.GetValue<string>() == "pkg:npm/lodash@4.17.21"),
$"Missing target component node. Nodes: {nodesDebug}");
Assert.Contains(batch.Nodes, n => n["kind"]!.GetValue<string>() == "advisory");
Assert.Contains(batch.Nodes, n => n["kind"]!.GetValue<string>() == "vex_statement");
if (batch.Nodes.Length != 5)
{
var debug = string.Join(" | ", batch.Nodes.Select(n => n.ToJsonString()));
throw new XunitException($"Expected 5 nodes, got {batch.Nodes.Length}: {debug}");
}
// Edges: depends_on, affected_by, vex_exempts
Assert.Contains(batch.Edges, e => e["kind"]!.GetValue<string>() == "DEPENDS_ON");
@@ -108,8 +138,50 @@ public sealed class GraphInspectorTransformerTests
Assert.Contains(batch.Edges, e => e["kind"]!.GetValue<string>() == "VEX_EXEMPTS");
// Provenance should carry sbom digest and event offset from snapshot/provenance overrides.
var dependsOn = batch.Edges.Single(e => e["kind"]!.GetValue<string>() == "DEPENDS_ON");
var dependsOn = batch.Edges.First(e => e["kind"]!.GetValue<string>() == "DEPENDS_ON");
Assert.Equal("sha256:sbom", dependsOn["provenance"]!["sbom_digest"]!.GetValue<string>());
Assert.Equal(6000, dependsOn["provenance"]!["event_offset"]!.GetValue<long>());
}
[Fact]
public void Transform_AcceptsPublishedSample()
{
var samplePath = LocateRepoFile("docs/modules/graph/contracts/examples/graph.inspect.v1.sample.json");
var json = File.ReadAllText(samplePath);
var snapshot = JsonSerializer.Deserialize<GraphInspectorSnapshot>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
})!;
var transformer = new GraphInspectorTransformer();
var batch = transformer.Transform(snapshot);
Assert.NotEmpty(batch.Nodes);
Assert.NotEmpty(batch.Edges);
Assert.Contains(batch.Nodes, n => n["kind"]!.GetValue<string>() == "advisory");
Assert.Contains(batch.Nodes, n => n["kind"]!.GetValue<string>() == "vex_statement");
}
private static string LocateRepoFile(string relative)
{
var dir = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(dir))
{
var candidate = Path.Combine(dir, relative);
if (File.Exists(candidate))
{
return candidate;
}
var parent = Directory.GetParent(dir);
if (parent is null)
{
break;
}
dir = parent.FullName;
}
throw new FileNotFoundException($"Unable to locate '{relative}' from base directory {AppContext.BaseDirectory}");
}
}

View File

@@ -0,0 +1,25 @@
using System.Text.Json;
using System.Linq;
using Xunit;
namespace StellaOps.Notifier.Tests.Contracts;
public sealed class ArtifactHashesTests
{
private static string RepoRoot => Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../../../"));
[Fact]
public void ArtifactHashesHasNoTbdAndFilesExist()
{
var hashesPath = Path.Combine(RepoRoot, "offline/notifier/artifact-hashes.json");
using var hashes = JsonDocument.Parse(File.ReadAllText(hashesPath));
foreach (var entry in hashes.RootElement.GetProperty("entries").EnumerateArray())
{
var path = Path.Combine(RepoRoot, entry.GetProperty("path").GetString()!);
var digest = entry.GetProperty("digest").GetString()!;
Assert.False(string.Equals(digest, "TBD", StringComparison.OrdinalIgnoreCase));
Assert.True(File.Exists(path), $"artifact path missing: {path}");
}
}
}

View File

@@ -0,0 +1,45 @@
using System.Text.Json;
using System.Linq;
using Xunit;
namespace StellaOps.Notifier.Tests.Contracts;
public sealed class OfflineKitManifestTests
{
private static string RepoRoot => Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../../../"));
[Fact]
public void ManifestDssePayloadMatchesManifest()
{
var manifestPath = Path.Combine(RepoRoot, "offline/notifier/notify-kit.manifest.json");
var dssePath = Path.Combine(RepoRoot, "offline/notifier/notify-kit.manifest.dsse.json");
using var manifest = JsonDocument.Parse(File.ReadAllText(manifestPath));
using var dsse = JsonDocument.Parse(File.ReadAllText(dssePath));
var payloadBytes = Convert.FromBase64String(dsse.RootElement.GetProperty("payload").GetString()!);
using var payload = JsonDocument.Parse(payloadBytes);
Assert.True(payload.RootElement.DeepEquals(manifest.RootElement));
}
[Fact]
public void ManifestArtifactsHaveHashes()
{
var manifestPath = Path.Combine(RepoRoot, "offline/notifier/notify-kit.manifest.json");
var hashesPath = Path.Combine(RepoRoot, "offline/notifier/artifact-hashes.json");
using var manifest = JsonDocument.Parse(File.ReadAllText(manifestPath));
using var hashes = JsonDocument.Parse(File.ReadAllText(hashesPath));
var artifactDigests = hashes.RootElement.GetProperty("entries").EnumerateArray().ToDictionary(e => e.GetProperty("path").GetString()!, e => e.GetProperty("digest").GetString()!);
foreach (var artifact in manifest.RootElement.GetProperty("artifacts").EnumerateArray())
{
var path = artifact.GetProperty("path").GetString()!;
var digest = artifact.GetProperty("digest").GetString()!;
Assert.True(artifactDigests.TryGetValue(path, out var fromList), $"artifact-hashes.json missing {path}");
Assert.Equal(digest, fromList);
Assert.False(string.Equals(digest, "TBD", StringComparison.OrdinalIgnoreCase));
}
}
}

View File

@@ -0,0 +1,44 @@
using System.Text.Json;
using Xunit;
namespace StellaOps.Notifier.Tests.Contracts;
public sealed class PolicyDocsCompletenessTests
{
private static string RepoRoot => Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../../../"));
[Theory]
[InlineData("docs/notifications/security/tenant-approvals.md")]
[InlineData("docs/notifications/security/webhook-ack-hardening.md")]
[InlineData("docs/notifications/security/redaction-catalog.md")]
[InlineData("docs/notifications/operations/quotas.md")]
[InlineData("docs/notifications/operations/retries.md")]
public void PolicyDocsHaveNoPlaceholders(string relativePath)
{
var path = Path.Combine(RepoRoot, relativePath);
var text = File.ReadAllText(path);
Assert.DoesNotContain("TBD", text, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("TODO", text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void AlertYamlParses()
{
var path = Path.Combine(RepoRoot, "docs/notifications/operations/alerts/notify-slo-alerts.yaml");
var text = File.ReadAllText(path);
Assert.Contains("alert: NotifyDeliverySuccessSLO", text);
Assert.Contains("notify_backlog_depth", text);
}
[Fact]
public void SimulationIndexHasEntries()
{
var path = Path.Combine(RepoRoot, "docs/notifications/simulations/index.ndjson");
var lines = File.ReadAllLines(path).Where(l => !string.IsNullOrWhiteSpace(l)).ToList();
Assert.NotEmpty(lines);
foreach (var line in lines)
{
Assert.DoesNotContain("TBD", line, StringComparison.OrdinalIgnoreCase);
}
}
}

View File

@@ -0,0 +1,29 @@
using System.Text.Json;
using System.Linq;
using Xunit;
namespace StellaOps.Notifier.Tests.Contracts;
public sealed class RenderingDeterminismTests
{
private static string RepoRoot => Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../../../"));
[Fact]
public void RenderingIndexMatchesTemplates()
{
var indexPath = Path.Combine(RepoRoot, "docs/notifications/fixtures/rendering/index.ndjson");
foreach (var line in File.ReadAllLines(indexPath).Where(l => !string.IsNullOrWhiteSpace(l)))
{
using var entry = JsonDocument.Parse(line);
var root = entry.RootElement;
var templatePath = Path.Combine(RepoRoot, "docs/notifications/fixtures/rendering", root.GetProperty("body_sample_path").GetString()!);
using var template = JsonDocument.Parse(File.ReadAllText(templatePath));
var expectedHash = root.GetProperty("expected_hash").GetString()!;
Assert.False(string.Equals(expectedHash, "TBD", StringComparison.OrdinalIgnoreCase));
var previewHash = template.RootElement.GetProperty("preview_hash").GetString()!;
Assert.Equal(expectedHash, previewHash);
}
}
}

View File

@@ -0,0 +1,58 @@
using System.Text.Json;
using System.Linq;
using Xunit;
namespace StellaOps.Notifier.Tests.Contracts;
public sealed class SchemaCatalogTests
{
private static string RepoRoot => Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../../../"));
[Fact]
public void CatalogMatchesDssePayload()
{
var catalogPath = Path.Combine(RepoRoot, "docs/notifications/schemas/notify-schemas-catalog.json");
var dssePath = Path.Combine(RepoRoot, "docs/notifications/schemas/notify-schemas-catalog.dsse.json");
var catalogJson = File.ReadAllText(catalogPath);
var dsseJson = File.ReadAllText(dssePath);
using var catalog = JsonDocument.Parse(catalogJson);
using var dsse = JsonDocument.Parse(dsseJson);
var payloadBase64 = dsse.RootElement.GetProperty("payload").GetString()!;
var payloadBytes = Convert.FromBase64String(payloadBase64);
using var payload = JsonDocument.Parse(payloadBytes);
Assert.True(payload.RootElement.DeepEquals(catalog.RootElement));
}
[Fact]
public void CatalogHasNoTbdDigests()
{
var catalogPath = Path.Combine(RepoRoot, "docs/notifications/schemas/notify-schemas-catalog.json");
var text = File.ReadAllText(catalogPath);
Assert.DoesNotContain("TBD", text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void InputsLockAlignsWithCatalog()
{
var catalogPath = Path.Combine(RepoRoot, "docs/notifications/schemas/notify-schemas-catalog.json");
var lockPath = Path.Combine(RepoRoot, "docs/notifications/schemas/inputs.lock");
using var catalog = JsonDocument.Parse(File.ReadAllText(catalogPath));
using var lockDoc = JsonDocument.Parse(File.ReadAllText(lockPath));
var catalogSchemas = catalog.RootElement.GetProperty("schemas").EnumerateArray().ToDictionary(e => e.GetProperty("file").GetString()!, e => e.GetProperty("digest").GetString()!);
var lockEntries = lockDoc.RootElement.GetProperty("entries").EnumerateArray().ToDictionary(e => e.GetProperty("file").GetString()!, e => e.GetProperty("digest").GetString()!);
Assert.Equal(catalogSchemas.Count, lockEntries.Count);
foreach (var kvp in catalogSchemas)
{
Assert.True(lockEntries.TryGetValue(kvp.Key, out var digest), $"inputs.lock missing {kvp.Key}");
Assert.Equal(kvp.Value, digest);
Assert.NotEqual("TBD", kvp.Value, StringComparison.OrdinalIgnoreCase);
}
}
}

View File

@@ -13,4 +13,4 @@
| NOTIFY-RISK-66-001 | DONE (2025-11-24) | Notifications Service Guild · Risk Engine Guild | Added risk-events endpoint + templates/rules for severity change notifications. |
| NOTIFY-RISK-67-001 | DONE (2025-11-24) | Notifications Service Guild · Policy Guild | Added routing/templates for risk profile publish/deprecate/threshold change. |
| NOTIFY-RISK-68-001 | DONE (2025-11-24) | Notifications Service Guild | Default routing seeds with throttles/locales for risk alerts. |
| NOTIFY-GAPS-171-014 | DOING (2025-12-04) | Notifications Service Guild | NR1NR10 scoped; schemas/catalog/fixtures/offline kit scaffolded; fill BLAKE3 digests, DSSE signatures, and tests next. |
| NOTIFY-GAPS-171-014 | BLOCKED (2025-12-04) | Notifications Service Guild | Await production signing key to re-sign DSSE envelopes (currently dev-signed). |

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Policy.Engine.ConsoleSurface;
using StellaOps.Policy.Engine.Options;
namespace StellaOps.Policy.Engine.Endpoints;
@@ -8,6 +9,7 @@ internal static class ConsoleSimulationEndpoint
public static IEndpointRouteBuilder MapConsoleSimulationDiff(this IEndpointRouteBuilder routes)
{
routes.MapPost("/policy/console/simulations/diff", HandleAsync)
.RequireRateLimiting(PolicyEngineRateLimitOptions.PolicyName)
.WithName("PolicyEngine.ConsoleSimulationDiff")
.Produces<ConsoleSimulationDiffResponse>(StatusCodes.Status200OK)
.ProducesValidationProblem();

View File

@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Overlay;
namespace StellaOps.Policy.Engine.Endpoints;
@@ -8,6 +9,7 @@ public static class OverlaySimulationEndpoint
public static IEndpointRouteBuilder MapOverlaySimulation(this IEndpointRouteBuilder routes)
{
routes.MapPost("/simulation/overlay", HandleAsync)
.RequireRateLimiting(PolicyEngineRateLimitOptions.PolicyName)
.WithName("PolicyEngine.OverlaySimulation");
return routes;

View File

@@ -2,6 +2,7 @@ using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Streaming;
using StellaOps.Policy.Engine.Overlay;
@@ -12,6 +13,7 @@ public static class PathScopeSimulationEndpoint
public static IEndpointRouteBuilder MapPathScopeSimulation(this IEndpointRouteBuilder routes)
{
routes.MapPost("/simulation/path-scope", HandleAsync)
.RequireRateLimiting(PolicyEngineRateLimitOptions.PolicyName)
.WithName("PolicyEngine.PathScopeSimulation");
return routes;

View File

@@ -82,6 +82,12 @@ internal static class RiskProfileEndpoints
.Produces<RiskProfileHashResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapGet("/{profileId}/metadata", GetProfileMetadata)
.WithName("GetRiskProfileMetadata")
.WithSummary("Export risk profile metadata for notification enrichment (POLICY-RISK-40-002).")
.Produces<RiskProfileMetadataExportResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
return endpoints;
}
@@ -461,6 +467,53 @@ internal static class RiskProfileEndpoints
return Results.Ok(new RiskProfileHashResponse(profile.Id, profile.Version, hash, contentOnly));
}
private static IResult GetProfileMetadata(
HttpContext context,
[FromRoute] string profileId,
RiskProfileConfigurationService profileService,
RiskProfileLifecycleService lifecycleService)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
if (scopeResult is not null)
{
return scopeResult;
}
var profile = profileService.GetProfile(profileId);
if (profile == null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Profile not found",
Detail = $"Risk profile '{profileId}' was not found.",
Status = StatusCodes.Status404NotFound
});
}
var versions = lifecycleService.GetAllVersions(profileId);
var activeVersion = versions.FirstOrDefault(v => v.Status == RiskProfileLifecycleStatus.Active);
var hash = profileService.ComputeHash(profile);
// Extract signal names and severity thresholds for notification context
var signalNames = profile.Signals.Select(s => s.Name).ToList();
var severityThresholds = profile.Overrides.Severity
.Select(s => new SeverityThresholdInfo(s.Set.ToString(), s.When))
.ToList();
return Results.Ok(new RiskProfileMetadataExportResponse(
ProfileId: profile.Id,
Version: profile.Version,
Description: profile.Description,
Hash: hash,
Status: activeVersion?.Status.ToString() ?? "unknown",
SignalNames: signalNames,
SeverityThresholds: severityThresholds,
CustomMetadata: profile.Metadata,
ExtendsProfile: profile.Extends,
ExportedAt: DateTime.UtcNow
));
}
private static string? ResolveActorId(HttpContext context)
{
var user = context.User;
@@ -521,4 +574,26 @@ internal sealed record CompareRiskProfilesRequest(
string ToProfileId,
string ToVersion);
/// <summary>
/// Metadata export response for notification enrichment (POLICY-RISK-40-002).
/// </summary>
internal sealed record RiskProfileMetadataExportResponse(
string ProfileId,
string Version,
string? Description,
string Hash,
string Status,
IReadOnlyList<string> SignalNames,
IReadOnlyList<SeverityThresholdInfo> SeverityThresholds,
Dictionary<string, object?>? CustomMetadata,
string? ExtendsProfile,
DateTime ExportedAt);
/// <summary>
/// Severity threshold information for notification context.
/// </summary>
internal sealed record SeverityThresholdInfo(
string TargetSeverity,
Dictionary<string, object> WhenConditions);
#endregion

View File

@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Simulation;
@@ -12,6 +13,7 @@ internal static class RiskSimulationEndpoints
{
var group = endpoints.MapGroup("/api/risk/simulation")
.RequireAuthorization()
.RequireRateLimiting(PolicyEngineRateLimitOptions.PolicyName)
.WithTags("Risk Simulation");
group.MapPost("/", RunSimulation)

View File

@@ -0,0 +1,48 @@
namespace StellaOps.Policy.Engine.Options;
/// <summary>
/// Rate limiting configuration for Policy Engine simulation endpoints.
/// </summary>
public sealed class PolicyEngineRateLimitOptions
{
/// <summary>
/// Configuration section name for binding.
/// </summary>
public const string SectionName = "RateLimiting";
/// <summary>
/// Maximum number of permits per window for simulation endpoints.
/// Default: 100 requests per window.
/// </summary>
public int SimulationPermitLimit { get; set; } = 100;
/// <summary>
/// Window duration in seconds for rate limiting.
/// Default: 60 seconds.
/// </summary>
public int WindowSeconds { get; set; } = 60;
/// <summary>
/// Maximum number of requests that can be queued when the limit is reached.
/// Default: 10 requests.
/// </summary>
public int QueueLimit { get; set; } = 10;
/// <summary>
/// Whether to partition rate limits by tenant ID.
/// When enabled, each tenant gets their own rate limit bucket.
/// Default: true.
/// </summary>
public bool TenantPartitioning { get; set; } = true;
/// <summary>
/// Whether rate limiting is enabled.
/// Default: true.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Custom policy name for the simulation rate limiter.
/// </summary>
public const string PolicyName = "policy-simulation";
}

View File

@@ -1,27 +1,29 @@
using System.IO;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
using NetEscapades.Configuration.Yaml;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Configuration;
using StellaOps.Policy.Engine.Hosting;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.Policy.Engine.Endpoints;
using StellaOps.Policy.Engine.BatchEvaluation;
using StellaOps.Policy.Engine.DependencyInjection;
using StellaOps.PolicyDsl;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Workers;
using StellaOps.Policy.Engine.Streaming;
using StellaOps.Policy.Engine.Telemetry;
using StellaOps.Policy.Engine.ConsoleSurface;
using StellaOps.AirGap.Policy;
using StellaOps.Policy.Engine.Orchestration;
using StellaOps.Policy.Engine.ReachabilityFacts;
using StellaOps.Policy.Engine.Storage.InMemory;
using StellaOps.Policy.Engine.Storage.Mongo.Repositories;
using StellaOps.Policy.Engine.Hosting;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.Policy.Engine.Endpoints;
using StellaOps.Policy.Engine.BatchEvaluation;
using StellaOps.Policy.Engine.DependencyInjection;
using StellaOps.PolicyDsl;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Workers;
using StellaOps.Policy.Engine.Streaming;
using StellaOps.Policy.Engine.Telemetry;
using StellaOps.Policy.Engine.ConsoleSurface;
using StellaOps.AirGap.Policy;
using StellaOps.Policy.Engine.Orchestration;
using StellaOps.Policy.Engine.ReachabilityFacts;
using StellaOps.Policy.Engine.Storage.InMemory;
using StellaOps.Policy.Engine.Storage.Mongo.Repositories;
var builder = WebApplication.CreateBuilder(args);
@@ -113,10 +115,10 @@ builder.Services.AddOptions<PolicyEngineOptions>()
})
.ValidateOnStart();
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<PolicyEngineOptions>>().Value);
builder.Services.AddSingleton(sp => sp.GetRequiredService<PolicyEngineOptions>().ExceptionLifecycle);
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<PolicyEngineStartupDiagnostics>();
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<PolicyEngineOptions>>().Value);
builder.Services.AddSingleton(sp => sp.GetRequiredService<PolicyEngineOptions>().ExceptionLifecycle);
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<PolicyEngineStartupDiagnostics>();
builder.Services.AddSingleton<PolicyTimelineEvents>();
builder.Services.AddSingleton<EvidenceBundleService>();
builder.Services.AddSingleton<PolicyEvaluationAttestationService>();
@@ -125,63 +127,102 @@ builder.Services.AddSingleton<RiskProfileConfigurationService>();
builder.Services.AddSingleton<StellaOps.Policy.RiskProfile.Lifecycle.RiskProfileLifecycleService>();
builder.Services.AddSingleton<StellaOps.Policy.RiskProfile.Scope.ScopeAttachmentService>();
builder.Services.AddSingleton<StellaOps.Policy.RiskProfile.Overrides.OverrideService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Scoring.IRiskScoringJobStore, StellaOps.Policy.Engine.Scoring.InMemoryRiskScoringJobStore>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Scoring.RiskScoringTriggerService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Simulation.RiskSimulationService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Signals.Entropy.EntropyPenaltyCalculator>();
builder.Services.AddSingleton<StellaOps.Policy.RiskProfile.Export.ProfileExportService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Events.ProfileEventPublisher>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Events.IExceptionEventPublisher>(sp =>
new StellaOps.Policy.Engine.Events.LoggingExceptionEventPublisher(
sp.GetService<StellaOps.Policy.Engine.ExceptionCache.IExceptionEffectiveCache>(),
sp.GetRequiredService<ILogger<StellaOps.Policy.Engine.Events.LoggingExceptionEventPublisher>>()));
builder.Services.AddSingleton<ExceptionLifecycleService>();
builder.Services.AddHostedService<ExceptionLifecycleWorker>();
builder.Services.AddHostedService<IncidentModeExpirationWorker>();
builder.Services.AddHostedService<PolicyEngineBootstrapWorker>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Simulation.SimulationAnalyticsService>();
builder.Services.AddSingleton<ConsoleSimulationDiffService>();
builder.Services.AddSingleton<StellaOps.PolicyDsl.PolicyCompiler>();
builder.Services.AddSingleton<PolicyCompilationService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PathScopeMetrics>();
builder.Services.AddSingleton<PolicyEvaluationService>();
builder.Services.AddPolicyEngineCore();
builder.Services.AddSingleton<PathScopeSimulationService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.OverlayProjectionService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.IOverlayEventSink, StellaOps.Policy.Engine.Overlay.LoggingOverlayEventSink>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.OverlayChangeEventPublisher>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.PathScopeSimulationBridgeService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Scoring.IRiskScoringJobStore, StellaOps.Policy.Engine.Scoring.InMemoryRiskScoringJobStore>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Scoring.RiskScoringTriggerService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Simulation.RiskSimulationService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Signals.Entropy.EntropyPenaltyCalculator>();
builder.Services.AddSingleton<StellaOps.Policy.RiskProfile.Export.ProfileExportService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Events.ProfileEventPublisher>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Events.IExceptionEventPublisher>(sp =>
new StellaOps.Policy.Engine.Events.LoggingExceptionEventPublisher(
sp.GetService<StellaOps.Policy.Engine.ExceptionCache.IExceptionEffectiveCache>(),
sp.GetRequiredService<ILogger<StellaOps.Policy.Engine.Events.LoggingExceptionEventPublisher>>()));
builder.Services.AddSingleton<ExceptionLifecycleService>();
builder.Services.AddHostedService<ExceptionLifecycleWorker>();
builder.Services.AddHostedService<IncidentModeExpirationWorker>();
builder.Services.AddHostedService<PolicyEngineBootstrapWorker>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Simulation.SimulationAnalyticsService>();
builder.Services.AddSingleton<ConsoleSimulationDiffService>();
builder.Services.AddSingleton<StellaOps.PolicyDsl.PolicyCompiler>();
builder.Services.AddSingleton<PolicyCompilationService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PathScopeMetrics>();
builder.Services.AddSingleton<PolicyEvaluationService>();
builder.Services.AddPolicyEngineCore();
builder.Services.AddSingleton<PathScopeSimulationService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.OverlayProjectionService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.IOverlayEventSink, StellaOps.Policy.Engine.Overlay.LoggingOverlayEventSink>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.OverlayChangeEventPublisher>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.PathScopeSimulationBridgeService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.TrustWeighting.TrustWeightingService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.AdvisoryAI.AdvisoryAiKnobsService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.BatchContext.BatchContextService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.EvidenceSummaryService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PolicyBundleService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PolicyRuntimeEvaluator>();
builder.Services.AddSingleton<IPolicyPackRepository, InMemoryPolicyPackRepository>();
builder.Services.AddSingleton<IOrchestratorJobStore, InMemoryOrchestratorJobStore>();
builder.Services.AddSingleton<OrchestratorJobService>();
builder.Services.AddSingleton<IWorkerResultStore, InMemoryWorkerResultStore>();
builder.Services.AddSingleton<PolicyWorkerService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Ledger.ILedgerExportStore, StellaOps.Policy.Engine.Ledger.InMemoryLedgerExportStore>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Ledger.LedgerExportService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Snapshots.ISnapshotStore, StellaOps.Policy.Engine.Snapshots.InMemorySnapshotStore>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Snapshots.SnapshotService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.IViolationEventStore, StellaOps.Policy.Engine.Violations.InMemoryViolationEventStore>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.ViolationEventService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.SeverityFusionService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.ConflictHandlingService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PolicyDecisionService>();
builder.Services.AddSingleton<IExceptionRepository, InMemoryExceptionRepository>();
builder.Services.AddSingleton<IReachabilityFactsStore, InMemoryReachabilityFactsStore>();
builder.Services.AddSingleton<IReachabilityFactsOverlayCache, InMemoryReachabilityFactsOverlayCache>();
builder.Services.AddSingleton<ReachabilityFactsJoiningService>();
builder.Services.AddSingleton<IRuntimeEvaluationExecutor, RuntimeEvaluationExecutor>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PolicyRuntimeEvaluator>();
builder.Services.AddSingleton<IPolicyPackRepository, InMemoryPolicyPackRepository>();
builder.Services.AddSingleton<IOrchestratorJobStore, InMemoryOrchestratorJobStore>();
builder.Services.AddSingleton<OrchestratorJobService>();
builder.Services.AddSingleton<IWorkerResultStore, InMemoryWorkerResultStore>();
builder.Services.AddSingleton<PolicyWorkerService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Ledger.ILedgerExportStore, StellaOps.Policy.Engine.Ledger.InMemoryLedgerExportStore>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Ledger.LedgerExportService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Snapshots.ISnapshotStore, StellaOps.Policy.Engine.Snapshots.InMemorySnapshotStore>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Snapshots.SnapshotService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.IViolationEventStore, StellaOps.Policy.Engine.Violations.InMemoryViolationEventStore>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.ViolationEventService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.SeverityFusionService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.ConflictHandlingService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PolicyDecisionService>();
builder.Services.AddSingleton<IExceptionRepository, InMemoryExceptionRepository>();
builder.Services.AddSingleton<IReachabilityFactsStore, InMemoryReachabilityFactsStore>();
builder.Services.AddSingleton<IReachabilityFactsOverlayCache, InMemoryReachabilityFactsOverlayCache>();
builder.Services.AddSingleton<ReachabilityFactsJoiningService>();
builder.Services.AddSingleton<IRuntimeEvaluationExecutor, RuntimeEvaluationExecutor>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddRouting(options => options.LowercaseUrls = true);
builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks();
// Rate limiting configuration for simulation endpoints
var rateLimitOptions = builder.Configuration
.GetSection(PolicyEngineRateLimitOptions.SectionName)
.Get<PolicyEngineRateLimitOptions>() ?? new PolicyEngineRateLimitOptions();
if (rateLimitOptions.Enabled)
{
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddTokenBucketLimiter(PolicyEngineRateLimitOptions.PolicyName, limiterOptions =>
{
limiterOptions.TokenLimit = rateLimitOptions.SimulationPermitLimit;
limiterOptions.ReplenishmentPeriod = TimeSpan.FromSeconds(rateLimitOptions.WindowSeconds);
limiterOptions.TokensPerPeriod = rateLimitOptions.SimulationPermitLimit;
limiterOptions.QueueLimit = rateLimitOptions.QueueLimit;
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
});
options.OnRejected = async (context, cancellationToken) =>
{
var tenant = context.HttpContext.User.FindFirst("tenant_id")?.Value;
var endpoint = context.HttpContext.Request.Path.Value;
PolicyEngineTelemetry.RecordRateLimitExceeded(tenant, endpoint);
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
context.HttpContext.Response.Headers.RetryAfter = rateLimitOptions.WindowSeconds.ToString();
await context.HttpContext.Response.WriteAsJsonAsync(new
{
error = "ERR_POL_007",
message = "Rate limit exceeded. Please retry after the reset window.",
retryAfterSeconds = rateLimitOptions.WindowSeconds
}, cancellationToken);
};
});
}
builder.Services.AddAuthentication();
builder.Services.AddAuthorization();
builder.Services.AddStellaOpsScopeHandler();
@@ -211,6 +252,11 @@ var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
if (rateLimitOptions.Enabled)
{
app.UseRateLimiter();
}
app.MapHealthChecks("/healthz");
app.MapGet("/readyz", (PolicyEngineStartupDiagnostics diagnostics) =>
diagnostics.IsReady
@@ -220,16 +266,16 @@ app.MapGet("/readyz", (PolicyEngineStartupDiagnostics diagnostics) =>
app.MapGet("/", () => Results.Redirect("/healthz"));
app.MapPolicyCompilation();
app.MapPolicyPacks();
app.MapPathScopeSimulation();
app.MapOverlaySimulation();
app.MapEvidenceSummaries();
app.MapBatchEvaluation();
app.MapConsoleSimulationDiff();
app.MapTrustWeighting();
app.MapAdvisoryAiKnobs();
app.MapBatchContext();
app.MapPolicyCompilation();
app.MapPolicyPacks();
app.MapPathScopeSimulation();
app.MapOverlaySimulation();
app.MapEvidenceSummaries();
app.MapBatchEvaluation();
app.MapConsoleSimulationDiff();
app.MapTrustWeighting();
app.MapAdvisoryAiKnobs();
app.MapBatchContext();
app.MapOrchestratorJobs();
app.MapPolicyWorker();
app.MapLedgerExport();

View File

@@ -55,12 +55,12 @@ public static class PolicyEngineTelemetry
unit: "overrides",
description: "Total number of VEX overrides applied during policy evaluation.");
// Counter: policy_compilation_total{outcome}
private static readonly Counter<long> PolicyCompilationCounter =
Meter.CreateCounter<long>(
"policy_compilation_total",
unit: "compilations",
description: "Total number of policy compilations attempted.");
// Counter: policy_compilation_total{outcome}
private static readonly Counter<long> PolicyCompilationCounter =
Meter.CreateCounter<long>(
"policy_compilation_total",
unit: "compilations",
description: "Total number of policy compilations attempted.");
// Histogram: policy_compilation_seconds
private static readonly Histogram<double> PolicyCompilationSecondsHistogram =
@@ -70,73 +70,95 @@ public static class PolicyEngineTelemetry
description: "Duration of policy compilation.");
// Counter: policy_simulation_total{tenant,outcome}
private static readonly Counter<long> PolicySimulationCounter =
Meter.CreateCounter<long>(
"policy_simulation_total",
unit: "simulations",
description: "Total number of policy simulations executed.");
#region Entropy Metrics
// Counter: policy_entropy_penalty_total{outcome}
private static readonly Counter<long> EntropyPenaltyCounter =
Meter.CreateCounter<long>(
"policy_entropy_penalty_total",
unit: "penalties",
description: "Total entropy penalties computed from scanner evidence.");
// Histogram: policy_entropy_penalty_value{outcome}
private static readonly Histogram<double> EntropyPenaltyHistogram =
Meter.CreateHistogram<double>(
"policy_entropy_penalty_value",
unit: "ratio",
description: "Entropy penalty values (after cap).");
// Histogram: policy_entropy_image_opaque_ratio{outcome}
private static readonly Histogram<double> EntropyImageOpaqueRatioHistogram =
Meter.CreateHistogram<double>(
"policy_entropy_image_opaque_ratio",
unit: "ratio",
description: "Image opaque ratios observed in layer summaries.");
// Histogram: policy_entropy_top_file_ratio{outcome}
private static readonly Histogram<double> EntropyTopFileRatioHistogram =
Meter.CreateHistogram<double>(
"policy_entropy_top_file_ratio",
unit: "ratio",
description: "Opaque ratio of the top offending file when present.");
/// <summary>
/// Records an entropy penalty computation.
/// </summary>
public static void RecordEntropyPenalty(
double penalty,
string outcome,
double imageOpaqueRatio,
double? topFileOpaqueRatio = null)
{
var tags = new TagList
{
{ "outcome", NormalizeTag(outcome) },
};
EntropyPenaltyCounter.Add(1, tags);
EntropyPenaltyHistogram.Record(penalty, tags);
EntropyImageOpaqueRatioHistogram.Record(imageOpaqueRatio, tags);
if (topFileOpaqueRatio.HasValue)
{
EntropyTopFileRatioHistogram.Record(topFileOpaqueRatio.Value, tags);
}
}
#endregion
#region Golden Signals - Latency
// Histogram: policy_api_latency_seconds{endpoint,method,status}
private static readonly Histogram<double> ApiLatencyHistogram =
Meter.CreateHistogram<double>(
private static readonly Counter<long> PolicySimulationCounter =
Meter.CreateCounter<long>(
"policy_simulation_total",
unit: "simulations",
description: "Total number of policy simulations executed.");
// Counter: policy_rate_limit_exceeded_total{tenant,endpoint}
private static readonly Counter<long> RateLimitExceededCounter =
Meter.CreateCounter<long>(
"policy_rate_limit_exceeded_total",
unit: "requests",
description: "Total requests rejected due to rate limiting.");
/// <summary>
/// Records a rate limit exceeded event.
/// </summary>
/// <param name="tenant">The tenant ID (or "anonymous" if not available).</param>
/// <param name="endpoint">The endpoint that was rate limited.</param>
public static void RecordRateLimitExceeded(string? tenant = null, string? endpoint = null)
{
var tags = new TagList
{
{ "tenant", NormalizeTag(tenant ?? "anonymous") },
{ "endpoint", NormalizeTag(endpoint ?? "simulation") },
};
RateLimitExceededCounter.Add(1, tags);
}
#region Entropy Metrics
// Counter: policy_entropy_penalty_total{outcome}
private static readonly Counter<long> EntropyPenaltyCounter =
Meter.CreateCounter<long>(
"policy_entropy_penalty_total",
unit: "penalties",
description: "Total entropy penalties computed from scanner evidence.");
// Histogram: policy_entropy_penalty_value{outcome}
private static readonly Histogram<double> EntropyPenaltyHistogram =
Meter.CreateHistogram<double>(
"policy_entropy_penalty_value",
unit: "ratio",
description: "Entropy penalty values (after cap).");
// Histogram: policy_entropy_image_opaque_ratio{outcome}
private static readonly Histogram<double> EntropyImageOpaqueRatioHistogram =
Meter.CreateHistogram<double>(
"policy_entropy_image_opaque_ratio",
unit: "ratio",
description: "Image opaque ratios observed in layer summaries.");
// Histogram: policy_entropy_top_file_ratio{outcome}
private static readonly Histogram<double> EntropyTopFileRatioHistogram =
Meter.CreateHistogram<double>(
"policy_entropy_top_file_ratio",
unit: "ratio",
description: "Opaque ratio of the top offending file when present.");
/// <summary>
/// Records an entropy penalty computation.
/// </summary>
public static void RecordEntropyPenalty(
double penalty,
string outcome,
double imageOpaqueRatio,
double? topFileOpaqueRatio = null)
{
var tags = new TagList
{
{ "outcome", NormalizeTag(outcome) },
};
EntropyPenaltyCounter.Add(1, tags);
EntropyPenaltyHistogram.Record(penalty, tags);
EntropyImageOpaqueRatioHistogram.Record(imageOpaqueRatio, tags);
if (topFileOpaqueRatio.HasValue)
{
EntropyTopFileRatioHistogram.Record(topFileOpaqueRatio.Value, tags);
}
}
#endregion
#region Golden Signals - Latency
// Histogram: policy_api_latency_seconds{endpoint,method,status}
private static readonly Histogram<double> ApiLatencyHistogram =
Meter.CreateHistogram<double>(
"policy_api_latency_seconds",
unit: "s",
description: "API request latency by endpoint.");
@@ -419,33 +441,33 @@ public static class PolicyEngineTelemetry
/// </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).");
// Counter: policy_exception_applications_total{tenant,effect}
private static readonly Counter<long> ExceptionApplicationsCounter =
Meter.CreateCounter<long>(
"policy_exception_applications_total",
unit: "applications",
description: "Total applied exceptions during evaluation by effect type.");
// Histogram: policy_exception_application_latency_seconds{tenant,effect}
private static readonly Histogram<double> ExceptionApplicationLatencyHistogram =
Meter.CreateHistogram<double>(
"policy_exception_application_latency_seconds",
unit: "s",
description: "Latency impact of exception application during evaluation.");
// Counter: policy_exception_lifecycle_total{tenant,event}
private static readonly Counter<long> ExceptionLifecycleCounter =
Meter.CreateCounter<long>(
"policy_exception_lifecycle_total",
unit: "events",
description: "Lifecycle events for exceptions (activated, expired, revoked).");
// 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).");
// Counter: policy_exception_applications_total{tenant,effect}
private static readonly Counter<long> ExceptionApplicationsCounter =
Meter.CreateCounter<long>(
"policy_exception_applications_total",
unit: "applications",
description: "Total applied exceptions during evaluation by effect type.");
// Histogram: policy_exception_application_latency_seconds{tenant,effect}
private static readonly Histogram<double> ExceptionApplicationLatencyHistogram =
Meter.CreateHistogram<double>(
"policy_exception_application_latency_seconds",
unit: "s",
description: "Latency impact of exception application during evaluation.");
// Counter: policy_exception_lifecycle_total{tenant,event}
private static readonly Counter<long> ExceptionLifecycleCounter =
Meter.CreateCounter<long>(
"policy_exception_lifecycle_total",
unit: "events",
description: "Lifecycle events for exceptions (activated, expired, revoked).");
/// <summary>
/// Counter for exception cache operations.
@@ -688,58 +710,58 @@ public static class PolicyEngineTelemetry
/// </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);
}
/// <summary>
/// Records that an exception was applied during evaluation.
/// </summary>
public static void RecordExceptionApplication(string tenant, string effectType)
{
var tags = new TagList
{
{ "tenant", NormalizeTenant(tenant) },
{ "effect", NormalizeTag(effectType) },
};
ExceptionApplicationsCounter.Add(1, tags);
}
/// <summary>
/// Records latency attributed to exception application during evaluation.
/// </summary>
public static void RecordExceptionApplicationLatency(double seconds, string tenant, string effectType)
{
var tags = new TagList
{
{ "tenant", NormalizeTenant(tenant) },
{ "effect", NormalizeTag(effectType) },
};
ExceptionApplicationLatencyHistogram.Record(seconds, tags);
}
/// <summary>
/// Records an exception lifecycle event (activated, expired, revoked).
/// </summary>
public static void RecordExceptionLifecycle(string tenant, string eventType)
{
var tags = new TagList
{
{ "tenant", NormalizeTenant(tenant) },
{ "event", NormalizeTag(eventType) },
};
ExceptionLifecycleCounter.Add(1, tags);
}
public static void RecordExceptionCacheOperation(string tenant, string operation)
{
var tags = new TagList
{
{ "tenant", NormalizeTenant(tenant) },
{ "operation", NormalizeTag(operation) },
};
ExceptionCacheOperationsCounter.Add(1, tags);
}
/// <summary>
/// Records that an exception was applied during evaluation.
/// </summary>
public static void RecordExceptionApplication(string tenant, string effectType)
{
var tags = new TagList
{
{ "tenant", NormalizeTenant(tenant) },
{ "effect", NormalizeTag(effectType) },
};
ExceptionApplicationsCounter.Add(1, tags);
}
/// <summary>
/// Records latency attributed to exception application during evaluation.
/// </summary>
public static void RecordExceptionApplicationLatency(double seconds, string tenant, string effectType)
{
var tags = new TagList
{
{ "tenant", NormalizeTenant(tenant) },
{ "effect", NormalizeTag(effectType) },
};
ExceptionApplicationLatencyHistogram.Record(seconds, tags);
}
/// <summary>
/// Records an exception lifecycle event (activated, expired, revoked).
/// </summary>
public static void RecordExceptionLifecycle(string tenant, string eventType)
{
var tags = new TagList
{
{ "tenant", NormalizeTenant(tenant) },
{ "event", NormalizeTag(eventType) },
};
ExceptionLifecycleCounter.Add(1, tags);
}
#region Golden Signals - Recording Methods

View File

@@ -0,0 +1,119 @@
-- Policy Schema Migration 003: Snapshots, Violations, Conflicts, Ledger Exports
-- Adds tables for policy snapshots, violation events, conflict handling, and ledger exports
-- Snapshots table (immutable policy configuration snapshots)
CREATE TABLE IF NOT EXISTS policy.snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
policy_id UUID NOT NULL,
version INT NOT NULL,
content_digest TEXT NOT NULL,
content JSONB NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}',
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, policy_id, version)
);
CREATE INDEX idx_snapshots_tenant ON policy.snapshots(tenant_id);
CREATE INDEX idx_snapshots_policy ON policy.snapshots(tenant_id, policy_id);
CREATE INDEX idx_snapshots_digest ON policy.snapshots(content_digest);
-- Violation events table (append-only)
CREATE TABLE IF NOT EXISTS policy.violation_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
policy_id UUID NOT NULL,
rule_id TEXT NOT NULL,
severity TEXT NOT NULL CHECK (severity IN ('critical', 'high', 'medium', 'low', 'info')),
subject_purl TEXT,
subject_cve TEXT,
details JSONB NOT NULL DEFAULT '{}',
remediation TEXT,
correlation_id TEXT,
occurred_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Append-only: no UPDATE trigger, only INSERTs allowed
CREATE INDEX idx_violation_events_tenant ON policy.violation_events(tenant_id);
CREATE INDEX idx_violation_events_policy ON policy.violation_events(tenant_id, policy_id);
CREATE INDEX idx_violation_events_rule ON policy.violation_events(rule_id);
CREATE INDEX idx_violation_events_severity ON policy.violation_events(severity);
CREATE INDEX idx_violation_events_purl ON policy.violation_events(subject_purl) WHERE subject_purl IS NOT NULL;
CREATE INDEX idx_violation_events_occurred ON policy.violation_events(tenant_id, occurred_at);
-- Conflicts table (for conflict detection and resolution)
CREATE TABLE IF NOT EXISTS policy.conflicts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
conflict_type TEXT NOT NULL CHECK (conflict_type IN ('rule_overlap', 'scope_collision', 'version_mismatch', 'precedence', 'other')),
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'dismissed')),
severity TEXT NOT NULL DEFAULT 'medium' CHECK (severity IN ('critical', 'high', 'medium', 'low')),
left_rule_id TEXT,
right_rule_id TEXT,
affected_scope TEXT,
description TEXT NOT NULL,
resolution TEXT,
resolved_by TEXT,
resolved_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT
);
CREATE INDEX idx_conflicts_tenant ON policy.conflicts(tenant_id);
CREATE INDEX idx_conflicts_status ON policy.conflicts(tenant_id, status);
CREATE INDEX idx_conflicts_type ON policy.conflicts(conflict_type);
-- Ledger exports table (for tracking ledger exports)
CREATE TABLE IF NOT EXISTS policy.ledger_exports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
export_type TEXT NOT NULL CHECK (export_type IN ('full', 'incremental', 'snapshot')),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'completed', 'failed')),
format TEXT NOT NULL DEFAULT 'ndjson' CHECK (format IN ('ndjson', 'json', 'parquet', 'csv')),
content_digest TEXT,
record_count INT,
byte_size BIGINT,
storage_path TEXT,
start_time TIMESTAMPTZ,
end_time TIMESTAMPTZ,
error_message TEXT,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT
);
CREATE INDEX idx_ledger_exports_tenant ON policy.ledger_exports(tenant_id);
CREATE INDEX idx_ledger_exports_status ON policy.ledger_exports(status);
CREATE INDEX idx_ledger_exports_digest ON policy.ledger_exports(content_digest) WHERE content_digest IS NOT NULL;
CREATE INDEX idx_ledger_exports_created ON policy.ledger_exports(tenant_id, created_at);
-- Worker results table (for background job tracking)
CREATE TABLE IF NOT EXISTS policy.worker_results (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
job_type TEXT NOT NULL,
job_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'completed', 'failed', 'cancelled')),
input_hash TEXT,
output_hash TEXT,
progress INT DEFAULT 0 CHECK (progress >= 0 AND progress <= 100),
result JSONB,
error_message TEXT,
retry_count INT NOT NULL DEFAULT 0,
max_retries INT NOT NULL DEFAULT 3,
scheduled_at TIMESTAMPTZ,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT,
UNIQUE(tenant_id, job_type, job_id)
);
CREATE INDEX idx_worker_results_tenant ON policy.worker_results(tenant_id);
CREATE INDEX idx_worker_results_status ON policy.worker_results(status);
CREATE INDEX idx_worker_results_job_type ON policy.worker_results(job_type);
CREATE INDEX idx_worker_results_scheduled ON policy.worker_results(scheduled_at) WHERE status = 'pending';

View File

@@ -0,0 +1,23 @@
namespace StellaOps.Policy.Storage.Postgres.Models;
/// <summary>
/// Entity representing a policy conflict for resolution.
/// </summary>
public sealed record ConflictEntity
{
public Guid Id { get; init; }
public required string TenantId { get; init; }
public required string ConflictType { get; init; }
public string Status { get; init; } = "open";
public string Severity { get; init; } = "medium";
public string? LeftRuleId { get; init; }
public string? RightRuleId { get; init; }
public string? AffectedScope { get; init; }
public required string Description { get; init; }
public string? Resolution { get; init; }
public string? ResolvedBy { get; init; }
public DateTimeOffset? ResolvedAt { get; init; }
public string Metadata { get; init; } = "{}";
public DateTimeOffset CreatedAt { get; init; }
public string? CreatedBy { get; init; }
}

View File

@@ -0,0 +1,23 @@
namespace StellaOps.Policy.Storage.Postgres.Models;
/// <summary>
/// Entity representing a ledger export operation.
/// </summary>
public sealed record LedgerExportEntity
{
public Guid Id { get; init; }
public required string TenantId { get; init; }
public required string ExportType { get; init; }
public string Status { get; init; } = "pending";
public string Format { get; init; } = "ndjson";
public string? ContentDigest { get; init; }
public int? RecordCount { get; init; }
public long? ByteSize { get; init; }
public string? StoragePath { get; init; }
public DateTimeOffset? StartTime { get; init; }
public DateTimeOffset? EndTime { get; init; }
public string? ErrorMessage { get; init; }
public string Metadata { get; init; } = "{}";
public DateTimeOffset CreatedAt { get; init; }
public string? CreatedBy { get; init; }
}

View File

@@ -0,0 +1,17 @@
namespace StellaOps.Policy.Storage.Postgres.Models;
/// <summary>
/// Entity representing an immutable policy configuration snapshot.
/// </summary>
public sealed record SnapshotEntity
{
public Guid Id { get; init; }
public required string TenantId { get; init; }
public Guid PolicyId { get; init; }
public int Version { get; init; }
public required string ContentDigest { get; init; }
public required string Content { get; init; }
public string Metadata { get; init; } = "{}";
public required string CreatedBy { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,20 @@
namespace StellaOps.Policy.Storage.Postgres.Models;
/// <summary>
/// Entity representing an append-only violation event.
/// </summary>
public sealed record ViolationEventEntity
{
public Guid Id { get; init; }
public required string TenantId { get; init; }
public Guid PolicyId { get; init; }
public required string RuleId { get; init; }
public required string Severity { get; init; }
public string? SubjectPurl { get; init; }
public string? SubjectCve { get; init; }
public string Details { get; init; } = "{}";
public string? Remediation { get; init; }
public string? CorrelationId { get; init; }
public DateTimeOffset OccurredAt { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,26 @@
namespace StellaOps.Policy.Storage.Postgres.Models;
/// <summary>
/// Entity representing a background worker job result.
/// </summary>
public sealed record WorkerResultEntity
{
public Guid Id { get; init; }
public required string TenantId { get; init; }
public required string JobType { get; init; }
public required string JobId { get; init; }
public string Status { get; init; } = "pending";
public string? InputHash { get; init; }
public string? OutputHash { get; init; }
public int Progress { get; init; }
public string? Result { get; init; }
public string? ErrorMessage { get; init; }
public int RetryCount { get; init; }
public int MaxRetries { get; init; } = 3;
public DateTimeOffset? ScheduledAt { get; init; }
public DateTimeOffset? StartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public string Metadata { get; init; } = "{}";
public DateTimeOffset CreatedAt { get; init; }
public string? CreatedBy { get; init; }
}

View File

@@ -0,0 +1,258 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for conflict detection and resolution operations.
/// </summary>
public sealed class ConflictRepository : RepositoryBase<PolicyDataSource>, IConflictRepository
{
/// <summary>
/// Creates a new conflict repository.
/// </summary>
public ConflictRepository(PolicyDataSource dataSource, ILogger<ConflictRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<ConflictEntity> CreateAsync(ConflictEntity conflict, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.conflicts (
id, tenant_id, conflict_type, severity, status, left_rule_id,
right_rule_id, affected_scope, description, metadata, created_by
)
VALUES (
@id, @tenant_id, @conflict_type, @severity, @status, @left_rule_id,
@right_rule_id, @affected_scope, @description, @metadata::jsonb, @created_by
)
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(conflict.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddConflictParameters(command, conflict);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapConflict(reader);
}
/// <inheritdoc />
public async Task<ConflictEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.conflicts WHERE tenant_id = @tenant_id AND id = @id";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapConflict,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ConflictEntity>> GetOpenAsync(
string tenantId,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.conflicts
WHERE tenant_id = @tenant_id AND status = 'open'
ORDER BY
CASE severity
WHEN 'critical' THEN 1
WHEN 'high' THEN 2
WHEN 'medium' THEN 3
WHEN 'low' THEN 4
END,
created_at DESC
LIMIT @limit OFFSET @offset
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapConflict,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ConflictEntity>> GetByTypeAsync(
string tenantId,
string conflictType,
string? status = null,
int limit = 100,
CancellationToken cancellationToken = default)
{
var sql = """
SELECT * FROM policy.conflicts
WHERE tenant_id = @tenant_id AND conflict_type = @conflict_type
""";
if (!string.IsNullOrEmpty(status))
{
sql += " AND status = @status";
}
sql += " ORDER BY created_at DESC LIMIT @limit";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "conflict_type", conflictType);
AddParameter(cmd, "limit", limit);
if (!string.IsNullOrEmpty(status))
{
AddParameter(cmd, "status", status);
}
},
MapConflict,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> ResolveAsync(
string tenantId,
Guid id,
string resolution,
string resolvedBy,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.conflicts
SET status = 'resolved', resolution = @resolution, resolved_by = @resolved_by, resolved_at = NOW()
WHERE tenant_id = @tenant_id AND id = @id AND status = 'open'
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "resolution", resolution);
AddParameter(cmd, "resolved_by", resolvedBy);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> DismissAsync(
string tenantId,
Guid id,
string dismissedBy,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.conflicts
SET status = 'dismissed', resolved_by = @dismissed_by, resolved_at = NOW()
WHERE tenant_id = @tenant_id AND id = @id AND status = 'open'
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "dismissed_by", dismissedBy);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<Dictionary<string, int>> CountOpenBySeverityAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT severity, COUNT(*)::int as count
FROM policy.conflicts
WHERE tenant_id = @tenant_id AND status = 'open'
GROUP BY severity
""";
var results = new Dictionary<string, int>();
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", tenantId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
var severity = reader.GetString(reader.GetOrdinal("severity"));
var count = reader.GetInt32(reader.GetOrdinal("count"));
results[severity] = count;
}
return results;
}
private static void AddConflictParameters(NpgsqlCommand command, ConflictEntity conflict)
{
AddParameter(command, "id", conflict.Id);
AddParameter(command, "tenant_id", conflict.TenantId);
AddParameter(command, "conflict_type", conflict.ConflictType);
AddParameter(command, "severity", conflict.Severity);
AddParameter(command, "status", conflict.Status);
AddParameter(command, "left_rule_id", conflict.LeftRuleId as object ?? DBNull.Value);
AddParameter(command, "right_rule_id", conflict.RightRuleId as object ?? DBNull.Value);
AddParameter(command, "affected_scope", conflict.AffectedScope as object ?? DBNull.Value);
AddParameter(command, "description", conflict.Description);
AddJsonbParameter(command, "metadata", conflict.Metadata);
AddParameter(command, "created_by", conflict.CreatedBy as object ?? DBNull.Value);
}
private static ConflictEntity MapConflict(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
ConflictType = reader.GetString(reader.GetOrdinal("conflict_type")),
Severity = reader.GetString(reader.GetOrdinal("severity")),
Status = reader.GetString(reader.GetOrdinal("status")),
LeftRuleId = GetNullableString(reader, reader.GetOrdinal("left_rule_id")),
RightRuleId = GetNullableString(reader, reader.GetOrdinal("right_rule_id")),
AffectedScope = GetNullableString(reader, reader.GetOrdinal("affected_scope")),
Description = reader.GetString(reader.GetOrdinal("description")),
Resolution = GetNullableString(reader, reader.GetOrdinal("resolution")),
ResolvedBy = GetNullableString(reader, reader.GetOrdinal("resolved_by")),
ResolvedAt = reader.IsDBNull(reader.GetOrdinal("resolved_at"))
? null
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("resolved_at")),
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
};
}

View File

@@ -0,0 +1,64 @@
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for conflict detection and resolution operations.
/// </summary>
public interface IConflictRepository
{
/// <summary>
/// Creates a new conflict.
/// </summary>
Task<ConflictEntity> CreateAsync(ConflictEntity conflict, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a conflict by ID.
/// </summary>
Task<ConflictEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all open conflicts for a tenant.
/// </summary>
Task<IReadOnlyList<ConflictEntity>> GetOpenAsync(
string tenantId,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets conflicts by type.
/// </summary>
Task<IReadOnlyList<ConflictEntity>> GetByTypeAsync(
string tenantId,
string conflictType,
string? status = null,
int limit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Resolves a conflict.
/// </summary>
Task<bool> ResolveAsync(
string tenantId,
Guid id,
string resolution,
string resolvedBy,
CancellationToken cancellationToken = default);
/// <summary>
/// Dismisses a conflict.
/// </summary>
Task<bool> DismissAsync(
string tenantId,
Guid id,
string dismissedBy,
CancellationToken cancellationToken = default);
/// <summary>
/// Counts open conflicts by severity.
/// </summary>
Task<Dictionary<string, int>> CountOpenBySeverityAsync(
string tenantId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,64 @@
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for ledger export operations.
/// </summary>
public interface ILedgerExportRepository
{
/// <summary>
/// Creates a new ledger export.
/// </summary>
Task<LedgerExportEntity> CreateAsync(LedgerExportEntity export, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a ledger export by ID.
/// </summary>
Task<LedgerExportEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a ledger export by content digest.
/// </summary>
Task<LedgerExportEntity?> GetByDigestAsync(string contentDigest, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all ledger exports for a tenant.
/// </summary>
Task<IReadOnlyList<LedgerExportEntity>> GetAllAsync(
string tenantId,
string? status = null,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates the status of a ledger export.
/// </summary>
Task<bool> UpdateStatusAsync(
string tenantId,
Guid id,
string status,
string? errorMessage = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Completes a ledger export with results.
/// </summary>
Task<bool> CompleteAsync(
string tenantId,
Guid id,
string contentDigest,
int recordCount,
long byteSize,
string? storagePath,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the latest completed export for a tenant.
/// </summary>
Task<LedgerExportEntity?> GetLatestCompletedAsync(
string tenantId,
string? exportType = null,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,44 @@
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for policy snapshot operations.
/// </summary>
public interface ISnapshotRepository
{
/// <summary>
/// Creates a new snapshot.
/// </summary>
Task<SnapshotEntity> CreateAsync(SnapshotEntity snapshot, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a snapshot by ID.
/// </summary>
Task<SnapshotEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the latest snapshot for a policy.
/// </summary>
Task<SnapshotEntity?> GetLatestByPolicyAsync(string tenantId, Guid policyId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a snapshot by content digest.
/// </summary>
Task<SnapshotEntity?> GetByDigestAsync(string contentDigest, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all snapshots for a policy.
/// </summary>
Task<IReadOnlyList<SnapshotEntity>> GetByPolicyAsync(
string tenantId,
Guid policyId,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a snapshot.
/// </summary>
Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,63 @@
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for append-only violation event operations.
/// </summary>
public interface IViolationEventRepository
{
/// <summary>
/// Appends a new violation event (immutable).
/// </summary>
Task<ViolationEventEntity> AppendAsync(ViolationEventEntity violationEvent, CancellationToken cancellationToken = default);
/// <summary>
/// Appends multiple violation events (immutable).
/// </summary>
Task<int> AppendBatchAsync(IEnumerable<ViolationEventEntity> events, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a violation event by ID.
/// </summary>
Task<ViolationEventEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets violation events for a policy.
/// </summary>
Task<IReadOnlyList<ViolationEventEntity>> GetByPolicyAsync(
string tenantId,
Guid policyId,
DateTimeOffset? since = null,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets violation events by severity.
/// </summary>
Task<IReadOnlyList<ViolationEventEntity>> GetBySeverityAsync(
string tenantId,
string severity,
DateTimeOffset? since = null,
int limit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets violation events for a PURL.
/// </summary>
Task<IReadOnlyList<ViolationEventEntity>> GetByPurlAsync(
string tenantId,
string purl,
int limit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Counts violations by severity for a time range.
/// </summary>
Task<Dictionary<string, int>> CountBySeverityAsync(
string tenantId,
DateTimeOffset since,
DateTimeOffset until,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,83 @@
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for worker result operations.
/// </summary>
public interface IWorkerResultRepository
{
/// <summary>
/// Creates a new worker result.
/// </summary>
Task<WorkerResultEntity> CreateAsync(WorkerResultEntity result, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a worker result by ID.
/// </summary>
Task<WorkerResultEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a worker result by job type and job ID.
/// </summary>
Task<WorkerResultEntity?> GetByJobAsync(
string tenantId,
string jobType,
string jobId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets worker results by status.
/// </summary>
Task<IReadOnlyList<WorkerResultEntity>> GetByStatusAsync(
string tenantId,
string status,
int limit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets pending worker results ready for execution.
/// </summary>
Task<IReadOnlyList<WorkerResultEntity>> GetPendingAsync(
string? jobType = null,
int limit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates the status and progress of a worker result.
/// </summary>
Task<bool> UpdateProgressAsync(
string tenantId,
Guid id,
string status,
int progress,
string? errorMessage = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Completes a worker result with the final result.
/// </summary>
Task<bool> CompleteAsync(
string tenantId,
Guid id,
string result,
string? outputHash = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Marks a worker result as failed.
/// </summary>
Task<bool> FailAsync(
string tenantId,
Guid id,
string errorMessage,
CancellationToken cancellationToken = default);
/// <summary>
/// Increments the retry count for a worker result.
/// </summary>
Task<bool> IncrementRetryAsync(
string tenantId,
Guid id,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,253 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for ledger export operations.
/// </summary>
public sealed class LedgerExportRepository : RepositoryBase<PolicyDataSource>, ILedgerExportRepository
{
/// <summary>
/// Creates a new ledger export repository.
/// </summary>
public LedgerExportRepository(PolicyDataSource dataSource, ILogger<LedgerExportRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<LedgerExportEntity> CreateAsync(LedgerExportEntity export, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.ledger_exports (
id, tenant_id, export_type, status, format, metadata, created_by
)
VALUES (
@id, @tenant_id, @export_type, @status, @format, @metadata::jsonb, @created_by
)
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(export.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddExportParameters(command, export);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapExport(reader);
}
/// <inheritdoc />
public async Task<LedgerExportEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.ledger_exports WHERE tenant_id = @tenant_id AND id = @id";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapExport,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<LedgerExportEntity?> GetByDigestAsync(string contentDigest, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.ledger_exports WHERE content_digest = @content_digest LIMIT 1";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "content_digest", contentDigest);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return MapExport(reader);
}
return null;
}
/// <inheritdoc />
public async Task<IReadOnlyList<LedgerExportEntity>> GetAllAsync(
string tenantId,
string? status = null,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
var sql = "SELECT * FROM policy.ledger_exports WHERE tenant_id = @tenant_id";
if (!string.IsNullOrEmpty(status))
{
sql += " AND status = @status";
}
sql += " ORDER BY created_at DESC LIMIT @limit OFFSET @offset";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
if (!string.IsNullOrEmpty(status))
{
AddParameter(cmd, "status", status);
}
},
MapExport,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> UpdateStatusAsync(
string tenantId,
Guid id,
string status,
string? errorMessage = null,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.ledger_exports
SET status = @status, error_message = @error_message,
start_time = CASE WHEN @status = 'running' AND start_time IS NULL THEN NOW() ELSE start_time END
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "status", status);
AddParameter(cmd, "error_message", errorMessage as object ?? DBNull.Value);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> CompleteAsync(
string tenantId,
Guid id,
string contentDigest,
int recordCount,
long byteSize,
string? storagePath,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.ledger_exports
SET status = 'completed',
content_digest = @content_digest,
record_count = @record_count,
byte_size = @byte_size,
storage_path = @storage_path,
end_time = NOW()
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "content_digest", contentDigest);
AddParameter(cmd, "record_count", recordCount);
AddParameter(cmd, "byte_size", byteSize);
AddParameter(cmd, "storage_path", storagePath as object ?? DBNull.Value);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<LedgerExportEntity?> GetLatestCompletedAsync(
string tenantId,
string? exportType = null,
CancellationToken cancellationToken = default)
{
var sql = """
SELECT * FROM policy.ledger_exports
WHERE tenant_id = @tenant_id AND status = 'completed'
""";
if (!string.IsNullOrEmpty(exportType))
{
sql += " AND export_type = @export_type";
}
sql += " ORDER BY end_time DESC LIMIT 1";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
if (!string.IsNullOrEmpty(exportType))
{
AddParameter(cmd, "export_type", exportType);
}
},
MapExport,
cancellationToken).ConfigureAwait(false);
}
private static void AddExportParameters(NpgsqlCommand command, LedgerExportEntity export)
{
AddParameter(command, "id", export.Id);
AddParameter(command, "tenant_id", export.TenantId);
AddParameter(command, "export_type", export.ExportType);
AddParameter(command, "status", export.Status);
AddParameter(command, "format", export.Format);
AddJsonbParameter(command, "metadata", export.Metadata);
AddParameter(command, "created_by", export.CreatedBy as object ?? DBNull.Value);
}
private static LedgerExportEntity MapExport(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
ExportType = reader.GetString(reader.GetOrdinal("export_type")),
Status = reader.GetString(reader.GetOrdinal("status")),
Format = reader.GetString(reader.GetOrdinal("format")),
ContentDigest = GetNullableString(reader, reader.GetOrdinal("content_digest")),
RecordCount = reader.IsDBNull(reader.GetOrdinal("record_count"))
? null
: reader.GetInt32(reader.GetOrdinal("record_count")),
ByteSize = reader.IsDBNull(reader.GetOrdinal("byte_size"))
? null
: reader.GetInt64(reader.GetOrdinal("byte_size")),
StoragePath = GetNullableString(reader, reader.GetOrdinal("storage_path")),
StartTime = reader.IsDBNull(reader.GetOrdinal("start_time"))
? null
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("start_time")),
EndTime = reader.IsDBNull(reader.GetOrdinal("end_time"))
? null
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("end_time")),
ErrorMessage = GetNullableString(reader, reader.GetOrdinal("error_message")),
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
};
}

View File

@@ -0,0 +1,179 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for policy snapshot operations.
/// </summary>
public sealed class SnapshotRepository : RepositoryBase<PolicyDataSource>, ISnapshotRepository
{
/// <summary>
/// Creates a new snapshot repository.
/// </summary>
public SnapshotRepository(PolicyDataSource dataSource, ILogger<SnapshotRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<SnapshotEntity> CreateAsync(SnapshotEntity snapshot, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.snapshots (
id, tenant_id, policy_id, version, content_digest, content,
created_by, metadata
)
VALUES (
@id, @tenant_id, @policy_id, @version, @content_digest, @content::jsonb,
@created_by, @metadata::jsonb
)
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(snapshot.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddSnapshotParameters(command, snapshot);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapSnapshot(reader);
}
/// <inheritdoc />
public async Task<SnapshotEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.snapshots WHERE tenant_id = @tenant_id AND id = @id";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapSnapshot,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<SnapshotEntity?> GetLatestByPolicyAsync(
string tenantId,
Guid policyId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.snapshots
WHERE tenant_id = @tenant_id AND policy_id = @policy_id
ORDER BY version DESC
LIMIT 1
""";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "policy_id", policyId);
},
MapSnapshot,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<SnapshotEntity?> GetByDigestAsync(string contentDigest, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.snapshots WHERE content_digest = @content_digest LIMIT 1";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "content_digest", contentDigest);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return MapSnapshot(reader);
}
return null;
}
/// <inheritdoc />
public async Task<IReadOnlyList<SnapshotEntity>> GetByPolicyAsync(
string tenantId,
Guid policyId,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.snapshots
WHERE tenant_id = @tenant_id AND policy_id = @policy_id
ORDER BY version DESC
LIMIT @limit OFFSET @offset
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "policy_id", policyId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapSnapshot,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM policy.snapshots WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
private static void AddSnapshotParameters(NpgsqlCommand command, SnapshotEntity snapshot)
{
AddParameter(command, "id", snapshot.Id);
AddParameter(command, "tenant_id", snapshot.TenantId);
AddParameter(command, "policy_id", snapshot.PolicyId);
AddParameter(command, "version", snapshot.Version);
AddParameter(command, "content_digest", snapshot.ContentDigest);
AddParameter(command, "content", snapshot.Content);
AddParameter(command, "created_by", snapshot.CreatedBy);
AddJsonbParameter(command, "metadata", snapshot.Metadata);
}
private static SnapshotEntity MapSnapshot(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
PolicyId = reader.GetGuid(reader.GetOrdinal("policy_id")),
Version = reader.GetInt32(reader.GetOrdinal("version")),
ContentDigest = reader.GetString(reader.GetOrdinal("content_digest")),
Content = reader.GetString(reader.GetOrdinal("content")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
CreatedBy = reader.GetString(reader.GetOrdinal("created_by")),
Metadata = reader.GetString(reader.GetOrdinal("metadata"))
};
}

View File

@@ -0,0 +1,265 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for append-only violation event operations.
/// </summary>
public sealed class ViolationEventRepository : RepositoryBase<PolicyDataSource>, IViolationEventRepository
{
/// <summary>
/// Creates a new violation event repository.
/// </summary>
public ViolationEventRepository(PolicyDataSource dataSource, ILogger<ViolationEventRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<ViolationEventEntity> AppendAsync(ViolationEventEntity violationEvent, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.violation_events (
id, tenant_id, policy_id, rule_id, severity, subject_purl,
subject_cve, details, remediation, correlation_id, occurred_at
)
VALUES (
@id, @tenant_id, @policy_id, @rule_id, @severity, @subject_purl,
@subject_cve, @details::jsonb, @remediation, @correlation_id, @occurred_at
)
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(violationEvent.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddViolationParameters(command, violationEvent);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapViolation(reader);
}
/// <inheritdoc />
public async Task<int> AppendBatchAsync(IEnumerable<ViolationEventEntity> events, CancellationToken cancellationToken = default)
{
var eventList = events.ToList();
if (eventList.Count == 0) return 0;
const string sql = """
INSERT INTO policy.violation_events (
id, tenant_id, policy_id, rule_id, severity, subject_purl,
subject_cve, details, remediation, correlation_id, occurred_at
)
VALUES (
@id, @tenant_id, @policy_id, @rule_id, @severity, @subject_purl,
@subject_cve, @details::jsonb, @remediation, @correlation_id, @occurred_at
)
""";
var tenantId = eventList[0].TenantId;
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
.ConfigureAwait(false);
var count = 0;
foreach (var evt in eventList)
{
await using var command = CreateCommand(sql, connection);
AddViolationParameters(command, evt);
count += await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
return count;
}
/// <inheritdoc />
public async Task<ViolationEventEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.violation_events WHERE tenant_id = @tenant_id AND id = @id";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapViolation,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ViolationEventEntity>> GetByPolicyAsync(
string tenantId,
Guid policyId,
DateTimeOffset? since = null,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
var sql = """
SELECT * FROM policy.violation_events
WHERE tenant_id = @tenant_id AND policy_id = @policy_id
""";
if (since.HasValue)
{
sql += " AND occurred_at >= @since";
}
sql += " ORDER BY occurred_at DESC LIMIT @limit OFFSET @offset";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "policy_id", policyId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
if (since.HasValue)
{
AddParameter(cmd, "since", since.Value);
}
},
MapViolation,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ViolationEventEntity>> GetBySeverityAsync(
string tenantId,
string severity,
DateTimeOffset? since = null,
int limit = 100,
CancellationToken cancellationToken = default)
{
var sql = """
SELECT * FROM policy.violation_events
WHERE tenant_id = @tenant_id AND severity = @severity
""";
if (since.HasValue)
{
sql += " AND occurred_at >= @since";
}
sql += " ORDER BY occurred_at DESC LIMIT @limit";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "severity", severity);
AddParameter(cmd, "limit", limit);
if (since.HasValue)
{
AddParameter(cmd, "since", since.Value);
}
},
MapViolation,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ViolationEventEntity>> GetByPurlAsync(
string tenantId,
string purl,
int limit = 100,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.violation_events
WHERE tenant_id = @tenant_id AND subject_purl = @purl
ORDER BY occurred_at DESC
LIMIT @limit
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "purl", purl);
AddParameter(cmd, "limit", limit);
},
MapViolation,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<Dictionary<string, int>> CountBySeverityAsync(
string tenantId,
DateTimeOffset since,
DateTimeOffset until,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT severity, COUNT(*)::int as count
FROM policy.violation_events
WHERE tenant_id = @tenant_id AND occurred_at >= @since AND occurred_at < @until
GROUP BY severity
""";
var results = new Dictionary<string, int>();
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "since", since);
AddParameter(command, "until", until);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
var severity = reader.GetString(reader.GetOrdinal("severity"));
var count = reader.GetInt32(reader.GetOrdinal("count"));
results[severity] = count;
}
return results;
}
private static void AddViolationParameters(NpgsqlCommand command, ViolationEventEntity violation)
{
AddParameter(command, "id", violation.Id);
AddParameter(command, "tenant_id", violation.TenantId);
AddParameter(command, "policy_id", violation.PolicyId);
AddParameter(command, "rule_id", violation.RuleId);
AddParameter(command, "severity", violation.Severity);
AddParameter(command, "subject_purl", violation.SubjectPurl as object ?? DBNull.Value);
AddParameter(command, "subject_cve", violation.SubjectCve as object ?? DBNull.Value);
AddJsonbParameter(command, "details", violation.Details);
AddParameter(command, "remediation", violation.Remediation as object ?? DBNull.Value);
AddParameter(command, "correlation_id", violation.CorrelationId as object ?? DBNull.Value);
AddParameter(command, "occurred_at", violation.OccurredAt);
}
private static ViolationEventEntity MapViolation(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
PolicyId = reader.GetGuid(reader.GetOrdinal("policy_id")),
RuleId = reader.GetString(reader.GetOrdinal("rule_id")),
Severity = reader.GetString(reader.GetOrdinal("severity")),
SubjectPurl = GetNullableString(reader, reader.GetOrdinal("subject_purl")),
SubjectCve = GetNullableString(reader, reader.GetOrdinal("subject_cve")),
Details = reader.GetString(reader.GetOrdinal("details")),
Remediation = GetNullableString(reader, reader.GetOrdinal("remediation")),
CorrelationId = GetNullableString(reader, reader.GetOrdinal("correlation_id")),
OccurredAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("occurred_at")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at"))
};
}

View File

@@ -0,0 +1,310 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for worker result operations.
/// </summary>
public sealed class WorkerResultRepository : RepositoryBase<PolicyDataSource>, IWorkerResultRepository
{
/// <summary>
/// Creates a new worker result repository.
/// </summary>
public WorkerResultRepository(PolicyDataSource dataSource, ILogger<WorkerResultRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<WorkerResultEntity> CreateAsync(WorkerResultEntity result, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.worker_results (
id, tenant_id, job_type, job_id, status, progress,
input_hash, max_retries, scheduled_at, metadata, created_by
)
VALUES (
@id, @tenant_id, @job_type, @job_id, @status, @progress,
@input_hash, @max_retries, @scheduled_at, @metadata::jsonb, @created_by
)
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(result.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddResultParameters(command, result);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapResult(reader);
}
/// <inheritdoc />
public async Task<WorkerResultEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.worker_results WHERE tenant_id = @tenant_id AND id = @id";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapResult,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<WorkerResultEntity?> GetByJobAsync(
string tenantId,
string jobType,
string jobId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.worker_results
WHERE tenant_id = @tenant_id AND job_type = @job_type AND job_id = @job_id
""";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "job_type", jobType);
AddParameter(cmd, "job_id", jobId);
},
MapResult,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<WorkerResultEntity>> GetByStatusAsync(
string tenantId,
string status,
int limit = 100,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.worker_results
WHERE tenant_id = @tenant_id AND status = @status
ORDER BY created_at DESC
LIMIT @limit
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "status", status);
AddParameter(cmd, "limit", limit);
},
MapResult,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<WorkerResultEntity>> GetPendingAsync(
string? jobType = null,
int limit = 100,
CancellationToken cancellationToken = default)
{
var sql = """
SELECT * FROM policy.worker_results
WHERE status = 'pending'
""";
if (!string.IsNullOrEmpty(jobType))
{
sql += " AND job_type = @job_type";
}
sql += " ORDER BY scheduled_at ASC NULLS LAST, created_at ASC LIMIT @limit";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "limit", limit);
if (!string.IsNullOrEmpty(jobType))
{
AddParameter(command, "job_type", jobType);
}
var results = new List<WorkerResultEntity>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapResult(reader));
}
return results;
}
/// <inheritdoc />
public async Task<bool> UpdateProgressAsync(
string tenantId,
Guid id,
string status,
int progress,
string? errorMessage = null,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.worker_results
SET status = @status, progress = @progress, error_message = @error_message,
started_at = CASE WHEN @status = 'running' AND started_at IS NULL THEN NOW() ELSE started_at END
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "status", status);
AddParameter(cmd, "progress", progress);
AddParameter(cmd, "error_message", errorMessage as object ?? DBNull.Value);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> CompleteAsync(
string tenantId,
Guid id,
string result,
string? outputHash = null,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.worker_results
SET status = 'completed', progress = 100, result = @result::jsonb,
output_hash = @output_hash, completed_at = NOW()
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "result", result);
AddParameter(cmd, "output_hash", outputHash as object ?? DBNull.Value);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> FailAsync(
string tenantId,
Guid id,
string errorMessage,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.worker_results
SET status = 'failed', error_message = @error_message, completed_at = NOW()
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "error_message", errorMessage);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> IncrementRetryAsync(
string tenantId,
Guid id,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.worker_results
SET retry_count = retry_count + 1, status = 'pending', started_at = NULL
WHERE tenant_id = @tenant_id AND id = @id AND retry_count < max_retries
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
private static void AddResultParameters(NpgsqlCommand command, WorkerResultEntity result)
{
AddParameter(command, "id", result.Id);
AddParameter(command, "tenant_id", result.TenantId);
AddParameter(command, "job_type", result.JobType);
AddParameter(command, "job_id", result.JobId);
AddParameter(command, "status", result.Status);
AddParameter(command, "progress", result.Progress);
AddParameter(command, "input_hash", result.InputHash as object ?? DBNull.Value);
AddParameter(command, "max_retries", result.MaxRetries);
AddParameter(command, "scheduled_at", result.ScheduledAt as object ?? DBNull.Value);
AddJsonbParameter(command, "metadata", result.Metadata);
AddParameter(command, "created_by", result.CreatedBy as object ?? DBNull.Value);
}
private static WorkerResultEntity MapResult(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
JobType = reader.GetString(reader.GetOrdinal("job_type")),
JobId = reader.GetString(reader.GetOrdinal("job_id")),
Status = reader.GetString(reader.GetOrdinal("status")),
Progress = reader.GetInt32(reader.GetOrdinal("progress")),
Result = GetNullableString(reader, reader.GetOrdinal("result")),
InputHash = GetNullableString(reader, reader.GetOrdinal("input_hash")),
OutputHash = GetNullableString(reader, reader.GetOrdinal("output_hash")),
ErrorMessage = GetNullableString(reader, reader.GetOrdinal("error_message")),
RetryCount = reader.GetInt32(reader.GetOrdinal("retry_count")),
MaxRetries = reader.GetInt32(reader.GetOrdinal("max_retries")),
ScheduledAt = reader.IsDBNull(reader.GetOrdinal("scheduled_at"))
? null
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("scheduled_at")),
StartedAt = reader.IsDBNull(reader.GetOrdinal("started_at"))
? null
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("started_at")),
CompletedAt = reader.IsDBNull(reader.GetOrdinal("completed_at"))
? null
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("completed_at")),
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
};
}

View File

@@ -37,6 +37,11 @@ public static class ServiceCollectionExtensions
services.AddScoped<IReceiptRepository, PostgresReceiptRepository>();
services.AddScoped<IExplanationRepository, ExplanationRepository>();
services.AddScoped<IPolicyAuditRepository, PolicyAuditRepository>();
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
services.AddScoped<IViolationEventRepository, ViolationEventRepository>();
services.AddScoped<IConflictRepository, ConflictRepository>();
services.AddScoped<ILedgerExportRepository, LedgerExportRepository>();
services.AddScoped<IWorkerResultRepository, WorkerResultRepository>();
return services;
}
@@ -64,6 +69,11 @@ public static class ServiceCollectionExtensions
services.AddScoped<IReceiptRepository, PostgresReceiptRepository>();
services.AddScoped<IExplanationRepository, ExplanationRepository>();
services.AddScoped<IPolicyAuditRepository, PolicyAuditRepository>();
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
services.AddScoped<IViolationEventRepository, ViolationEventRepository>();
services.AddScoped<IConflictRepository, ConflictRepository>();
services.AddScoped<ILedgerExportRepository, LedgerExportRepository>();
services.AddScoped<IWorkerResultRepository, WorkerResultRepository>();
return services;
}

View File

@@ -13,7 +13,7 @@
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
@@ -100,13 +100,32 @@
"output": "."
}
],
"styles": [
"src/styles.scss"
],
"scripts": []
}
}
}
}
}
}
"styles": [
"src/styles.scss"
],
"scripts": []
}
},
"storybook": {
"builder": "@storybook/angular:start-storybook",
"options": {
"configDir": ".storybook",
"browserTarget": "stellaops-web:build",
"port": 4600,
"quiet": true,
"ci": true
}
},
"build-storybook": {
"builder": "@storybook/angular:build-storybook",
"options": {
"configDir": ".storybook",
"browserTarget": "stellaops-web:build",
"outputDir": "storybook-static",
"quiet": true
}
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -13,8 +13,8 @@
"serve:test": "ng serve --configuration development --port 4400 --host 127.0.0.1",
"verify:chromium": "node ./scripts/verify-chromium.js",
"ci:install": "npm ci --prefer-offline --no-audit --no-fund",
"storybook": "storybook dev -p 4600",
"storybook:build": "storybook build",
"storybook": "ng run stellaops-web:storybook",
"storybook:build": "ng run stellaops-web:build-storybook",
"test:a11y": "FAIL_ON_A11Y=0 playwright test tests/e2e/a11y-smoke.spec.ts"
},
"engines": {
@@ -45,7 +45,6 @@
"@storybook/addon-essentials": "8.1.0",
"@storybook/addon-interactions": "8.1.0",
"@storybook/angular": "8.1.0",
"@storybook/angular-renderer": "8.1.0",
"@storybook/test": "8.1.0",
"@storybook/testing-library": "0.2.2",
"storybook": "8.1.0",

View File

@@ -0,0 +1,50 @@
#!/usr/bin/env node
/**
* Minimal Storybook wrapper to avoid missing legacy CLI bins.
* Supports:
* node scripts/storybook.js dev --ci --quiet --port 4600
* node scripts/storybook.js build --quiet
*/
const { dev, build } = require('@storybook/core-server');
const args = process.argv.slice(2);
const mode = args.shift() ?? 'dev';
const hasFlag = (flag) => args.includes(flag);
const getFlagValue = (flag) => {
const idx = args.indexOf(flag);
return idx >= 0 ? args[idx + 1] : undefined;
};
const ci = hasFlag('--ci') || process.env.CI === 'true';
const quiet = hasFlag('--quiet') || hasFlag('-q');
const port = Number(getFlagValue('--port') ?? process.env.STORYBOOK_PORT ?? 4600);
const host = process.env.STORYBOOK_HOST ?? '127.0.0.1';
const configDir = process.env.STORYBOOK_CONFIG_DIR ?? '.storybook';
const outputDir = process.env.STORYBOOK_OUTPUT_DIR ?? 'storybook-static';
async function run() {
if (mode === 'build') {
await build({
configDir,
outputDir,
quiet,
loglevel: quiet ? 'warn' : 'info',
});
return;
}
await dev({
configDir,
port,
host,
ci,
quiet,
loglevel: quiet ? 'warn' : 'info',
});
}
run().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -1,5 +1,10 @@
import { Routes } from '@angular/router';
import {
requireOrchViewerGuard,
requireOrchOperatorGuard,
} from './core/auth';
export const routes: Routes = [
{
path: 'dashboard/sources',
@@ -15,6 +20,46 @@ export const routes: Routes = [
(m) => m.ConsoleProfileComponent
),
},
{
path: 'console/status',
loadComponent: () =>
import('./features/console/console-status.component').then(
(m) => m.ConsoleStatusComponent
),
},
// Orchestrator routes - gated by orch:read scope (UI-ORCH-32-001)
{
path: 'orchestrator',
canMatch: [requireOrchViewerGuard],
loadComponent: () =>
import('./features/orchestrator/orchestrator-dashboard.component').then(
(m) => m.OrchestratorDashboardComponent
),
},
{
path: 'orchestrator/jobs',
canMatch: [requireOrchViewerGuard],
loadComponent: () =>
import('./features/orchestrator/orchestrator-jobs.component').then(
(m) => m.OrchestratorJobsComponent
),
},
{
path: 'orchestrator/jobs/:jobId',
canMatch: [requireOrchViewerGuard],
loadComponent: () =>
import('./features/orchestrator/orchestrator-job-detail.component').then(
(m) => m.OrchestratorJobDetailComponent
),
},
{
path: 'orchestrator/quotas',
canMatch: [requireOrchOperatorGuard],
loadComponent: () =>
import('./features/orchestrator/orchestrator-quotas.component').then(
(m) => m.OrchestratorQuotasComponent
),
},
{
path: 'concelier/trivy-db-settings',
loadComponent: () =>
@@ -29,29 +74,29 @@ export const routes: Routes = [
(m) => m.ScanDetailPageComponent
),
},
{
path: 'welcome',
loadComponent: () =>
import('./features/welcome/welcome-page.component').then(
(m) => m.WelcomePageComponent
),
},
{
path: 'risk',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/risk/risk-dashboard.component').then(
(m) => m.RiskDashboardComponent
),
},
{
path: 'vulnerabilities/:vulnId',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/vulnerabilities/vulnerability-detail.component').then(
(m) => m.VulnerabilityDetailComponent
),
},
{
path: 'welcome',
loadComponent: () =>
import('./features/welcome/welcome-page.component').then(
(m) => m.WelcomePageComponent
),
},
{
path: 'risk',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/risk/risk-dashboard.component').then(
(m) => m.RiskDashboardComponent
),
},
{
path: 'vulnerabilities/:vulnId',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/vulnerabilities/vulnerability-detail.component').then(
(m) => m.VulnerabilityDetailComponent
),
},
{
path: 'notify',
loadComponent: () =>

View File

@@ -135,6 +135,81 @@ export interface AocChainEntry {
readonly parentHash?: string;
}
// VEX Decision types (based on docs/schemas/vex-decision.schema.json)
export type VexStatus = 'NOT_AFFECTED' | 'AFFECTED_MITIGATED' | 'AFFECTED_UNMITIGATED' | 'FIXED';
export type VexJustificationType =
| 'CODE_NOT_PRESENT'
| 'CODE_NOT_REACHABLE'
| 'VULNERABLE_CODE_NOT_IN_EXECUTE_PATH'
| 'CONFIGURATION_NOT_AFFECTED'
| 'OS_NOT_AFFECTED'
| 'RUNTIME_MITIGATION_PRESENT'
| 'COMPENSATING_CONTROLS'
| 'ACCEPTED_BUSINESS_RISK'
| 'OTHER';
export interface VexSubjectRef {
readonly type: 'IMAGE' | 'REPO' | 'SBOM_COMPONENT' | 'OTHER';
readonly name: string;
readonly digest: Record<string, string>;
readonly sbomNodeId?: string;
}
export interface VexEvidenceRef {
readonly type: 'PR' | 'TICKET' | 'DOC' | 'COMMIT' | 'OTHER';
readonly title?: string;
readonly url: string;
}
export interface VexScope {
readonly environments?: readonly string[];
readonly projects?: readonly string[];
}
export interface VexValidFor {
readonly notBefore?: string;
readonly notAfter?: string;
}
export interface VexActorRef {
readonly id: string;
readonly displayName: string;
}
export interface VexDecision {
readonly id: string;
readonly vulnerabilityId: string;
readonly subject: VexSubjectRef;
readonly status: VexStatus;
readonly justificationType: VexJustificationType;
readonly justificationText?: string;
readonly evidenceRefs?: readonly VexEvidenceRef[];
readonly scope?: VexScope;
readonly validFor?: VexValidFor;
readonly supersedesDecisionId?: string;
readonly createdBy: VexActorRef;
readonly createdAt: string;
readonly updatedAt?: string;
}
// VEX status summary for UI display
export interface VexStatusSummary {
readonly notAffected: number;
readonly affectedMitigated: number;
readonly affectedUnmitigated: number;
readonly fixed: number;
readonly total: number;
}
// VEX conflict indicator
export interface VexConflict {
readonly vulnerabilityId: string;
readonly conflictingStatuses: readonly VexStatus[];
readonly decisionIds: readonly string[];
readonly reason: string;
}
// Evidence panel data combining all elements
export interface EvidenceData {
readonly advisoryId: string;
@@ -142,6 +217,8 @@ export interface EvidenceData {
readonly observations: readonly Observation[];
readonly linkset?: Linkset;
readonly policyEvidence?: PolicyEvidence;
readonly vexDecisions?: readonly VexDecision[];
readonly vexConflicts?: readonly VexConflict[];
readonly hasConflicts: boolean;
readonly conflictCount: number;
}
@@ -155,6 +232,32 @@ export interface SourceInfo {
readonly lastUpdated?: string;
}
// Filter configuration for observations/linksets
export type SeverityBucket = 'critical' | 'high' | 'medium' | 'low' | 'all';
export interface ObservationFilters {
readonly sources: readonly string[]; // Filter by source IDs
readonly severityBucket: SeverityBucket; // Filter by severity level
readonly conflictOnly: boolean; // Show only observations with conflicts
readonly hasCvssVector: boolean | null; // null = all, true = has vector, false = no vector
}
export const DEFAULT_OBSERVATION_FILTERS: ObservationFilters = {
sources: [],
severityBucket: 'all',
conflictOnly: false,
hasCvssVector: null,
};
// Pagination configuration
export interface PaginationState {
readonly pageSize: number;
readonly currentPage: number;
readonly totalItems: number;
}
export const DEFAULT_PAGE_SIZE = 10;
export const SOURCE_INFO: Record<string, SourceInfo> = {
ghsa: {
sourceId: 'ghsa',

View File

@@ -2,6 +2,7 @@ import { inject } from '@angular/core';
import { CanMatchFn, Router } from '@angular/router';
import { AuthSessionStore } from './auth-session.store';
import { StellaOpsScopes, type StellaOpsScope } from './scopes';
/**
* Simple guard to prevent unauthenticated navigation to protected routes.
@@ -13,3 +14,116 @@ export const requireAuthGuard: CanMatchFn = () => {
const isAuthenticated = auth.isAuthenticated();
return isAuthenticated ? true : router.createUrlTree(['/welcome']);
};
/**
* Creates a guard that requires specific scopes.
* Redirects to /welcome if not authenticated, or returns false if missing scopes.
*
* @param requiredScopes - Scopes that must all be present
* @param redirectPath - Optional path to redirect to if scope check fails (default: none, just denies)
*/
export function requireScopesGuard(
requiredScopes: readonly StellaOpsScope[],
redirectPath?: string
): CanMatchFn {
return () => {
const auth = inject(AuthSessionStore);
const router = inject(Router);
if (!auth.isAuthenticated()) {
return router.createUrlTree(['/welcome']);
}
const session = auth.session();
const userScopes = session?.scopes ?? [];
// Admin scope grants access to everything
if (userScopes.includes(StellaOpsScopes.ADMIN)) {
return true;
}
const hasAllRequired = requiredScopes.every((scope) =>
userScopes.includes(scope)
);
if (hasAllRequired) {
return true;
}
if (redirectPath) {
return router.createUrlTree([redirectPath]);
}
return false;
};
}
/**
* Creates a guard that requires any of the specified scopes.
* Redirects to /welcome if not authenticated, or returns false if no matching scopes.
*
* @param requiredScopes - At least one of these scopes must be present
* @param redirectPath - Optional path to redirect to if scope check fails
*/
export function requireAnyScopeGuard(
requiredScopes: readonly StellaOpsScope[],
redirectPath?: string
): CanMatchFn {
return () => {
const auth = inject(AuthSessionStore);
const router = inject(Router);
if (!auth.isAuthenticated()) {
return router.createUrlTree(['/welcome']);
}
const session = auth.session();
const userScopes = session?.scopes ?? [];
// Admin scope grants access to everything
if (userScopes.includes(StellaOpsScopes.ADMIN)) {
return true;
}
const hasAnyRequired = requiredScopes.some((scope) =>
userScopes.includes(scope)
);
if (hasAnyRequired) {
return true;
}
if (redirectPath) {
return router.createUrlTree([redirectPath]);
}
return false;
};
}
// Pre-built guards for common scope requirements (UI-ORCH-32-001)
/**
* Guard requiring orch:read scope for Orchestrator dashboard access.
* Redirects to /console/profile if user lacks Orchestrator viewer access.
*/
export const requireOrchViewerGuard: CanMatchFn = requireScopesGuard(
[StellaOpsScopes.ORCH_READ],
'/console/profile'
);
/**
* Guard requiring orch:operate scope for Orchestrator control actions.
*/
export const requireOrchOperatorGuard: CanMatchFn = requireScopesGuard(
[StellaOpsScopes.ORCH_READ, StellaOpsScopes.ORCH_OPERATE],
'/console/profile'
);
/**
* Guard requiring orch:quota scope for quota management.
*/
export const requireOrchQuotaGuard: CanMatchFn = requireScopesGuard(
[StellaOpsScopes.ORCH_READ, StellaOpsScopes.ORCH_QUOTA],
'/console/profile'
);

View File

@@ -41,6 +41,11 @@ export interface AuthService {
canEditGraph(): boolean;
canExportGraph(): boolean;
canSimulate(): boolean;
// Orchestrator access (UI-ORCH-32-001)
canViewOrchestrator(): boolean;
canOperateOrchestrator(): boolean;
canManageOrchestratorQuotas(): boolean;
canInitiateBackfill(): boolean;
}
// ============================================================================
@@ -75,6 +80,10 @@ const MOCK_USER: AuthUser = {
StellaOpsScopes.RELEASE_READ,
// AOC permissions
StellaOpsScopes.AOC_READ,
// Orchestrator permissions (UI-ORCH-32-001)
StellaOpsScopes.ORCH_READ,
// UI permissions
StellaOpsScopes.UI_READ,
],
};
@@ -118,6 +127,23 @@ export class MockAuthService implements AuthService {
StellaOpsScopes.POLICY_SIMULATE,
]);
}
// Orchestrator access methods (UI-ORCH-32-001)
canViewOrchestrator(): boolean {
return this.hasScope(StellaOpsScopes.ORCH_READ);
}
canOperateOrchestrator(): boolean {
return this.hasScope(StellaOpsScopes.ORCH_OPERATE);
}
canManageOrchestratorQuotas(): boolean {
return this.hasScope(StellaOpsScopes.ORCH_QUOTA);
}
canInitiateBackfill(): boolean {
return this.hasScope(StellaOpsScopes.ORCH_BACKFILL);
}
}
// Re-export scopes for convenience

View File

@@ -14,3 +14,12 @@ export {
AUTH_SERVICE,
MockAuthService,
} from './auth.service';
export {
requireAuthGuard,
requireScopesGuard,
requireAnyScopeGuard,
requireOrchViewerGuard,
requireOrchOperatorGuard,
requireOrchQuotaGuard,
} from './auth.guard';

View File

@@ -49,6 +49,15 @@ export const StellaOpsScopes = {
AOC_READ: 'aoc:read',
AOC_VERIFY: 'aoc:verify',
// Orchestrator scopes (UI-ORCH-32-001)
ORCH_READ: 'orch:read',
ORCH_OPERATE: 'orch:operate',
ORCH_QUOTA: 'orch:quota',
ORCH_BACKFILL: 'orch:backfill',
// UI scopes
UI_READ: 'ui.read',
// Admin scopes
ADMIN: 'admin',
TENANT_ADMIN: 'tenant:admin',
@@ -99,6 +108,26 @@ export const ScopeGroups = {
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_WRITE,
] as const,
// Orchestrator scope groups (UI-ORCH-32-001)
ORCH_VIEWER: [
StellaOpsScopes.ORCH_READ,
StellaOpsScopes.UI_READ,
] as const,
ORCH_OPERATOR: [
StellaOpsScopes.ORCH_READ,
StellaOpsScopes.ORCH_OPERATE,
StellaOpsScopes.UI_READ,
] as const,
ORCH_ADMIN: [
StellaOpsScopes.ORCH_READ,
StellaOpsScopes.ORCH_OPERATE,
StellaOpsScopes.ORCH_QUOTA,
StellaOpsScopes.ORCH_BACKFILL,
StellaOpsScopes.UI_READ,
] as const,
} as const;
/**
@@ -129,6 +158,14 @@ export const ScopeLabels: Record<StellaOpsScope, string> = {
'release:bypass': 'Bypass Release Gates',
'aoc:read': 'View AOC Status',
'aoc:verify': 'Trigger AOC Verification',
// Orchestrator scope labels (UI-ORCH-32-001)
'orch:read': 'View Orchestrator Jobs',
'orch:operate': 'Operate Orchestrator',
'orch:quota': 'Manage Orchestrator Quotas',
'orch:backfill': 'Initiate Backfill Runs',
// UI scope labels
'ui.read': 'Console Access',
// Admin scope labels
'admin': 'System Administrator',
'tenant:admin': 'Tenant Administrator',
};

View File

@@ -5,16 +5,56 @@
<h2 id="evidence-panel-title" class="evidence-panel__title">
Evidence: {{ advisoryId() }}
</h2>
<button
type="button"
class="evidence-panel__close"
(click)="onClose()"
aria-label="Close evidence panel"
>
<span aria-hidden="true">&times;</span>
</button>
<div class="evidence-panel__actions">
<button
type="button"
class="evidence-panel__permalink-btn"
(click)="togglePermalink()"
[attr.aria-expanded]="showPermalink()"
aria-controls="permalink-section"
title="Share permalink"
>
<span aria-hidden="true">&#128279;</span>
<span class="visually-hidden">Share permalink</span>
</button>
<button
type="button"
class="evidence-panel__close"
(click)="onClose()"
aria-label="Close evidence panel"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
</div>
<!-- Permalink Section -->
@if (showPermalink()) {
<div id="permalink-section" class="evidence-panel__permalink">
<label for="permalink-input" class="visually-hidden">Permalink URL</label>
<input
id="permalink-input"
type="text"
readonly
[value]="permalink()"
class="evidence-panel__permalink-input"
aria-describedby="permalink-hint"
/>
<button
type="button"
class="evidence-panel__copy-btn"
[class.copied]="permalinkCopied()"
(click)="copyPermalink()"
[attr.aria-label]="permalinkCopied() ? 'Copied!' : 'Copy to clipboard'"
>
{{ permalinkCopied() ? 'Copied!' : 'Copy' }}
</button>
<span id="permalink-hint" class="evidence-panel__permalink-hint">
Share this link to navigate directly to this evidence view
</span>
</div>
}
<!-- Policy Decision Summary -->
@if (policyEvidence(); as policy) {
<div class="evidence-panel__decision-summary" [class]="policyDecisionClass()">
@@ -77,7 +117,7 @@
[attr.aria-selected]="isActiveTab('observations')"
(click)="setActiveTab('observations')"
>
Observations ({{ observations().length }})
Observations ({{ filteredObservations().length }}@if (activeFilterCount() > 0) {/{{ observations().length }}})
</button>
<button
type="button"
@@ -90,6 +130,21 @@
>
Linkset
</button>
<button
type="button"
role="tab"
class="evidence-panel__tab"
[class.active]="isActiveTab('vex')"
[class.has-conflicts]="hasVexConflicts()"
[attr.aria-selected]="isActiveTab('vex')"
(click)="setActiveTab('vex')"
[disabled]="!hasVexData()"
>
VEX ({{ vexDecisions().length }})
@if (hasVexConflicts()) {
<span class="conflict-indicator" aria-label="Has conflicts">!</span>
}
</button>
<button
type="button"
role="tab"
@@ -123,26 +178,191 @@
role="tabpanel"
aria-label="Observations"
>
<!-- View Toggle -->
<div class="evidence-panel__view-toggle">
<button
type="button"
class="view-btn"
[class.active]="observationView() === 'side-by-side'"
(click)="setObservationView('side-by-side')"
aria-label="Side by side view"
>
Side by Side
</button>
<button
type="button"
class="view-btn"
[class.active]="observationView() === 'stacked'"
(click)="setObservationView('stacked')"
aria-label="Stacked view"
>
Stacked
</button>
<!-- Toolbar: View Toggle + Filters -->
<div class="evidence-panel__toolbar">
<div class="evidence-panel__view-toggle">
<button
type="button"
class="view-btn"
[class.active]="observationView() === 'side-by-side'"
(click)="setObservationView('side-by-side')"
aria-label="Side by side view"
>
Side by Side
</button>
<button
type="button"
class="view-btn"
[class.active]="observationView() === 'stacked'"
(click)="setObservationView('stacked')"
aria-label="Stacked view"
>
Stacked
</button>
</div>
<div class="evidence-panel__filter-controls">
<button
type="button"
class="filter-toggle-btn"
[class.active]="showFilters()"
(click)="toggleFilters()"
[attr.aria-expanded]="showFilters()"
aria-controls="observation-filters"
>
Filters
@if (activeFilterCount() > 0) {
<span class="filter-badge" aria-label="{{ activeFilterCount() }} active filters">
{{ activeFilterCount() }}
</span>
}
</button>
@if (activeFilterCount() > 0) {
<button
type="button"
class="filter-clear-btn"
(click)="clearFilters()"
aria-label="Clear all filters"
>
Clear
</button>
}
</div>
</div>
<!-- Filter Panel -->
@if (showFilters()) {
<div id="observation-filters" class="evidence-panel__filters" role="group" aria-label="Observation filters">
<!-- Source Filter -->
<fieldset class="filter-group">
<legend>Source</legend>
<div class="filter-options">
@for (source of availableSources(); track source.sourceId) {
<label class="filter-checkbox">
<input
type="checkbox"
[checked]="isSourceSelected(source.sourceId)"
(change)="toggleSourceFilter(source.sourceId)"
/>
<span>{{ source.name }}</span>
</label>
}
</div>
</fieldset>
<!-- Severity Bucket Filter -->
<fieldset class="filter-group">
<legend>Severity</legend>
<div class="filter-options filter-options--inline">
<label class="filter-radio">
<input
type="radio"
name="severity"
[checked]="isSeverityBucketSelected('all')"
(change)="updateSeverityBucket('all')"
/>
<span>All</span>
</label>
<label class="filter-radio severity-critical">
<input
type="radio"
name="severity"
[checked]="isSeverityBucketSelected('critical')"
(change)="updateSeverityBucket('critical')"
/>
<span>Critical</span>
</label>
<label class="filter-radio severity-high">
<input
type="radio"
name="severity"
[checked]="isSeverityBucketSelected('high')"
(change)="updateSeverityBucket('high')"
/>
<span>High</span>
</label>
<label class="filter-radio severity-medium">
<input
type="radio"
name="severity"
[checked]="isSeverityBucketSelected('medium')"
(change)="updateSeverityBucket('medium')"
/>
<span>Medium</span>
</label>
<label class="filter-radio severity-low">
<input
type="radio"
name="severity"
[checked]="isSeverityBucketSelected('low')"
(change)="updateSeverityBucket('low')"
/>
<span>Low</span>
</label>
</div>
</fieldset>
<!-- Conflict Only Filter -->
@if (hasConflicts()) {
<fieldset class="filter-group">
<legend>Conflicts</legend>
<div class="filter-options">
<label class="filter-checkbox">
<input
type="checkbox"
[checked]="filters().conflictOnly"
(change)="toggleConflictOnly()"
/>
<span>Show only conflicting sources</span>
</label>
</div>
</fieldset>
}
<!-- CVSS Vector Presence Filter -->
<fieldset class="filter-group">
<legend>CVSS Vector</legend>
<div class="filter-options filter-options--inline">
<label class="filter-radio">
<input
type="radio"
name="cvss-vector"
[checked]="filters().hasCvssVector === null"
(change)="updateCvssVectorFilter(null)"
/>
<span>All</span>
</label>
<label class="filter-radio">
<input
type="radio"
name="cvss-vector"
[checked]="filters().hasCvssVector === true"
(change)="updateCvssVectorFilter(true)"
/>
<span>Has Vector</span>
</label>
<label class="filter-radio">
<input
type="radio"
name="cvss-vector"
[checked]="filters().hasCvssVector === false"
(change)="updateCvssVectorFilter(false)"
/>
<span>No Vector</span>
</label>
</div>
</fieldset>
</div>
}
<!-- Results Summary -->
<div class="evidence-panel__results-summary">
<span>
Showing {{ paginatedObservations().length }} of {{ filteredObservations().length }}
@if (filteredObservations().length !== observations().length) {
({{ observations().length }} total)
}
</span>
</div>
<!-- Observations Grid -->
@@ -151,7 +371,7 @@
[class.side-by-side]="observationView() === 'side-by-side'"
[class.stacked]="observationView() === 'stacked'"
>
@for (obs of observations(); track trackByObservationId($index, obs)) {
@for (obs of paginatedObservations(); track trackByObservationId($index, obs)) {
<article
class="observation-card"
[class.expanded]="isObservationExpanded(obs.observationId)"
@@ -299,6 +519,65 @@
</article>
}
</div>
<!-- Pagination Controls -->
@if (totalPages() > 1) {
<nav class="evidence-panel__pagination" aria-label="Observation pagination">
<div class="pagination-info">
Page {{ currentPage() + 1 }} of {{ totalPages() }}
</div>
<div class="pagination-controls">
<button
type="button"
class="pagination-btn"
[disabled]="!hasPreviousPage()"
(click)="previousPage()"
aria-label="Previous page"
>
&larr; Previous
</button>
<!-- Page number buttons (show max 5) -->
@for (page of [].constructor(Math.min(5, totalPages())); track $index; let i = $index) {
@let pageNum = currentPage() < 2 ? i : Math.min(currentPage() - 2 + i, totalPages() - 1);
<button
type="button"
class="pagination-btn pagination-btn--number"
[class.active]="currentPage() === pageNum"
(click)="goToPage(pageNum)"
[attr.aria-current]="currentPage() === pageNum ? 'page' : null"
aria-label="Page {{ pageNum + 1 }}"
>
{{ pageNum + 1 }}
</button>
}
<button
type="button"
class="pagination-btn"
[disabled]="!hasNextPage()"
(click)="nextPage()"
aria-label="Next page"
>
Next &rarr;
</button>
</div>
<div class="pagination-size">
<label for="page-size">Per page:</label>
<select
id="page-size"
[value]="pageSize()"
(change)="updatePageSize(+$any($event.target).value)"
>
<option value="5">5</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
</select>
</div>
</nav>
}
</section>
}
@@ -436,6 +715,216 @@
</section>
}
<!-- VEX Tab -->
@if (isActiveTab('vex')) {
<section
class="evidence-panel__section"
role="tabpanel"
aria-label="VEX Decisions"
>
<div class="vex-panel">
<!-- Header with export actions -->
<header class="vex-panel__header">
<div class="vex-panel__title">
<h3>VEX Decisions</h3>
<p class="vex-panel__description">
Vulnerability exploitability decisions for this advisory
</p>
</div>
<div class="vex-panel__actions">
<button
type="button"
class="vex-export-btn"
(click)="onExportVex('json')"
title="Export as JSON"
>
Export JSON
</button>
<button
type="button"
class="vex-export-btn"
(click)="onExportVex('openvex')"
title="Export as OpenVEX"
>
Export OpenVEX
</button>
<button
type="button"
class="vex-export-btn"
(click)="onExportVex('csaf')"
title="Export as CSAF VEX"
>
Export CSAF
</button>
</div>
</header>
<!-- Status Summary Cards -->
<div class="vex-panel__summary">
<div class="vex-summary-card vex-summary-card--not-affected">
<span class="vex-summary-card__count">{{ vexStatusSummary().notAffected }}</span>
<span class="vex-summary-card__label">Not Affected</span>
</div>
<div class="vex-summary-card vex-summary-card--mitigated">
<span class="vex-summary-card__count">{{ vexStatusSummary().affectedMitigated }}</span>
<span class="vex-summary-card__label">Mitigated</span>
</div>
<div class="vex-summary-card vex-summary-card--unmitigated">
<span class="vex-summary-card__count">{{ vexStatusSummary().affectedUnmitigated }}</span>
<span class="vex-summary-card__label">Unmitigated</span>
</div>
<div class="vex-summary-card vex-summary-card--fixed">
<span class="vex-summary-card__count">{{ vexStatusSummary().fixed }}</span>
<span class="vex-summary-card__label">Fixed</span>
</div>
</div>
<!-- VEX Conflicts Warning -->
@if (hasVexConflicts()) {
<div class="vex-panel__conflicts" role="alert">
<header class="vex-conflicts__header">
<span class="vex-conflicts__icon" aria-hidden="true">!</span>
<span class="vex-conflicts__title">
{{ vexConflicts().length }} Conflicting Decision(s) Detected
</span>
</header>
<ul class="vex-conflicts__list">
@for (conflict of vexConflicts(); track trackByVexConflictId($index, conflict)) {
<li class="vex-conflicts__item">
<strong>{{ conflict.vulnerabilityId }}:</strong>
{{ conflict.reason }}
<span class="vex-conflicts__statuses">
({{ conflict.conflictingStatuses.map(getVexStatusLabel).join(' vs ') }})
</span>
</li>
}
</ul>
</div>
}
<!-- VEX Decisions List -->
<div class="vex-panel__decisions">
<h4>Decisions ({{ vexDecisions().length }})</h4>
@for (decision of vexDecisions(); track trackByVexDecisionId($index, decision)) {
<article
class="vex-decision-card"
[class.expired]="isVexDecisionExpired(decision)"
[class.pending]="isVexDecisionPending(decision)"
>
<header class="vex-decision-card__header">
<div class="vex-decision-card__status">
<span
class="vex-status-badge"
[class]="getVexStatusClass(decision.status)"
>
{{ getVexStatusLabel(decision.status) }}
</span>
@if (isVexDecisionExpired(decision)) {
<span class="vex-expired-badge">Expired</span>
}
@if (isVexDecisionPending(decision)) {
<span class="vex-pending-badge">Pending</span>
}
</div>
<code class="vex-decision-card__vuln-id">{{ decision.vulnerabilityId }}</code>
</header>
<div class="vex-decision-card__body">
<!-- Subject -->
<div class="vex-decision-card__section">
<dt>Subject:</dt>
<dd>
<span class="vex-subject-type">{{ decision.subject.type }}</span>
<code class="vex-subject-name">{{ decision.subject.name }}</code>
</dd>
</div>
<!-- Justification -->
<div class="vex-decision-card__section">
<dt>Justification:</dt>
<dd>
<span class="vex-justification-type">
{{ getVexJustificationLabel(decision.justificationType) }}
</span>
@if (decision.justificationText) {
<p class="vex-justification-text">{{ decision.justificationText }}</p>
}
</dd>
</div>
<!-- Scope -->
@if (decision.scope) {
<div class="vex-decision-card__section">
<dt>Scope:</dt>
<dd>
@if (decision.scope.environments && decision.scope.environments.length > 0) {
<span class="vex-scope-label">Environments:</span>
<span class="vex-scope-values">{{ decision.scope.environments.join(', ') }}</span>
}
@if (decision.scope.projects && decision.scope.projects.length > 0) {
<span class="vex-scope-label">Projects:</span>
<span class="vex-scope-values">{{ decision.scope.projects.join(', ') }}</span>
}
</dd>
</div>
}
<!-- Validity -->
@if (decision.validFor) {
<div class="vex-decision-card__section">
<dt>Valid:</dt>
<dd>
@if (decision.validFor.notBefore) {
<span>From {{ formatDate(decision.validFor.notBefore) }}</span>
}
@if (decision.validFor.notAfter) {
<span>Until {{ formatDate(decision.validFor.notAfter) }}</span>
}
</dd>
</div>
}
<!-- Evidence References -->
@if (decision.evidenceRefs && decision.evidenceRefs.length > 0) {
<div class="vex-decision-card__section">
<dt>Evidence:</dt>
<dd>
<ul class="vex-evidence-list">
@for (ref of decision.evidenceRefs; track ref.url) {
<li>
<span class="vex-evidence-type">{{ ref.type }}</span>
<a
[href]="ref.url"
target="_blank"
rel="noopener noreferrer"
class="vex-evidence-link"
>
{{ ref.title || ref.url }}
</a>
</li>
}
</ul>
</dd>
</div>
}
<!-- Created By -->
<div class="vex-decision-card__footer">
<span class="vex-decision-card__author">
By {{ decision.createdBy.displayName }}
</span>
<span class="vex-decision-card__date">
{{ formatDate(decision.createdAt) }}
</span>
</div>
</div>
</article>
}
</div>
</div>
</section>
}
<!-- Policy Tab -->
@if (isActiveTab('policy') && policyEvidence(); as policy) {
<section

View File

@@ -44,6 +44,98 @@ $color-text-muted: #6b7280;
color: #111827;
}
&__actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
&__permalink-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
border: 1px solid $color-border;
border-radius: 4px;
background: #fff;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.15s, border-color 0.15s;
&:hover {
background: #f3f4f6;
border-color: #9ca3af;
}
&:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
}
&__permalink {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding: 0.75rem;
background: #f3f4f6;
border-radius: 6px;
border: 1px solid $color-border;
}
&__permalink-input {
flex: 1;
min-width: 200px;
padding: 0.5rem 0.75rem;
border: 1px solid $color-border;
border-radius: 4px;
background: #fff;
font-size: 0.8125rem;
font-family: 'Monaco', 'Consolas', monospace;
color: #374151;
&:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
}
&__copy-btn {
padding: 0.5rem 1rem;
border: 1px solid #3b82f6;
border-radius: 4px;
background: #3b82f6;
font-size: 0.8125rem;
font-weight: 500;
color: #fff;
cursor: pointer;
transition: background-color 0.15s;
&:hover {
background: #2563eb;
}
&:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
&.copied {
background: #22c55e;
border-color: #22c55e;
}
}
&__permalink-hint {
flex-basis: 100%;
font-size: 0.75rem;
color: $color-text-muted;
}
&__close {
display: flex;
align-items: center;
@@ -257,10 +349,18 @@ $color-text-muted: #6b7280;
animation: fadeIn 0.2s ease-out;
}
&__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
&__view-toggle {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
.view-btn {
padding: 0.5rem 0.75rem;
@@ -288,6 +388,245 @@ $color-text-muted: #6b7280;
}
}
}
&__filter-controls {
display: flex;
gap: 0.5rem;
align-items: center;
.filter-toggle-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid $color-border;
border-radius: 4px;
background: #fff;
font-size: 0.8125rem;
color: $color-text-muted;
cursor: pointer;
transition: background-color 0.15s, border-color 0.15s;
&:hover {
border-color: #9ca3af;
}
&.active {
background: #eff6ff;
border-color: #3b82f6;
color: #3b82f6;
}
&:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.filter-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.25rem;
height: 1.25rem;
padding: 0 0.375rem;
border-radius: 10px;
background: #3b82f6;
color: #fff;
font-size: 0.6875rem;
font-weight: 600;
}
}
.filter-clear-btn {
padding: 0.5rem 0.75rem;
border: none;
border-radius: 4px;
background: transparent;
font-size: 0.8125rem;
color: #3b82f6;
cursor: pointer;
&:hover {
text-decoration: underline;
}
&:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
}
}
&__filters {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
padding: 1rem;
margin-bottom: 1rem;
background: $color-bg-muted;
border-radius: 8px;
border: 1px solid $color-border;
.filter-group {
border: none;
padding: 0;
margin: 0;
min-width: 150px;
legend {
font-size: 0.75rem;
font-weight: 600;
color: #374151;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
}
.filter-options {
display: flex;
flex-direction: column;
gap: 0.375rem;
&--inline {
flex-direction: row;
flex-wrap: wrap;
gap: 0.75rem;
}
}
.filter-checkbox,
.filter-radio {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
color: #374151;
cursor: pointer;
input {
width: 1rem;
height: 1rem;
margin: 0;
cursor: pointer;
accent-color: #3b82f6;
}
&.severity-critical span {
color: $color-critical;
font-weight: 500;
}
&.severity-high span {
color: $color-high;
font-weight: 500;
}
&.severity-medium span {
color: #a16207;
font-weight: 500;
}
&.severity-low span {
color: #15803d;
font-weight: 500;
}
}
}
&__results-summary {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
font-size: 0.8125rem;
color: $color-text-muted;
}
&__pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid $color-border;
.pagination-info {
font-size: 0.8125rem;
color: $color-text-muted;
}
.pagination-controls {
display: flex;
align-items: center;
gap: 0.25rem;
}
.pagination-btn {
display: flex;
align-items: center;
justify-content: center;
min-width: 2rem;
height: 2rem;
padding: 0 0.5rem;
border: 1px solid $color-border;
border-radius: 4px;
background: #fff;
font-size: 0.8125rem;
color: $color-text-muted;
cursor: pointer;
transition: background-color 0.15s, border-color 0.15s;
&:hover:not(:disabled) {
border-color: #9ca3af;
background: #f3f4f6;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&.active {
background: #3b82f6;
border-color: #3b82f6;
color: #fff;
}
&:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
&--number {
font-weight: 500;
}
}
.pagination-size {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
color: $color-text-muted;
select {
padding: 0.375rem 0.5rem;
border: 1px solid $color-border;
border-radius: 4px;
background: #fff;
font-size: 0.8125rem;
cursor: pointer;
&:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
}
}
}
}
// Observations Grid
@@ -994,6 +1333,412 @@ $color-text-muted: #6b7280;
color: #374151;
}
// VEX Panel
.vex-panel {
&__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
h3 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #111827;
}
}
&__title {
flex: 1;
}
&__description {
margin: 0.25rem 0 0;
font-size: 0.875rem;
color: $color-text-muted;
}
&__actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
&__summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
&__conflicts {
margin-bottom: 1.5rem;
padding: 1rem;
border-radius: 8px;
background: #fef3c7;
border: 1px solid #fcd34d;
}
&__decisions {
h4 {
margin: 0 0 1rem;
font-size: 0.9375rem;
font-weight: 600;
color: #374151;
}
}
}
.vex-export-btn {
padding: 0.5rem 0.75rem;
border: 1px solid #3b82f6;
border-radius: 4px;
background: #fff;
font-size: 0.8125rem;
color: #3b82f6;
cursor: pointer;
transition: background-color 0.15s;
&:hover {
background: #eff6ff;
}
&:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
}
.vex-summary-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem;
border-radius: 8px;
background: #f9fafb;
border: 1px solid $color-border;
&__count {
font-size: 1.5rem;
font-weight: 700;
line-height: 1;
}
&__label {
margin-top: 0.5rem;
font-size: 0.75rem;
color: $color-text-muted;
text-align: center;
}
&--not-affected {
background: #f0fdf4;
border-color: #86efac;
.vex-summary-card__count {
color: #15803d;
}
}
&--mitigated {
background: #fef9c3;
border-color: #fde047;
.vex-summary-card__count {
color: #a16207;
}
}
&--unmitigated {
background: #fee2e2;
border-color: #fca5a5;
.vex-summary-card__count {
color: #dc2626;
}
}
&--fixed {
background: #eff6ff;
border-color: #93c5fd;
.vex-summary-card__count {
color: #2563eb;
}
}
}
.vex-conflicts {
&__header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background: #f59e0b;
color: #fff;
font-weight: 700;
font-size: 0.875rem;
}
&__title {
font-weight: 600;
color: #92400e;
}
&__list {
margin: 0;
padding-left: 2rem;
font-size: 0.875rem;
color: #78350f;
}
&__item {
margin: 0.5rem 0;
}
&__statuses {
color: $color-text-muted;
font-style: italic;
}
}
.vex-decision-card {
border: 1px solid $color-border;
border-radius: 8px;
margin-bottom: 1rem;
background: #fff;
overflow: hidden;
&.expired {
opacity: 0.7;
border-color: #fca5a5;
}
&.pending {
border-color: #fde047;
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
background: $color-bg-muted;
border-bottom: 1px solid $color-border;
flex-wrap: wrap;
}
&__status {
display: flex;
align-items: center;
gap: 0.5rem;
}
&__vuln-id {
font-size: 0.875rem;
background: #f3f4f6;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
&__body {
padding: 1rem;
}
&__section {
margin-bottom: 0.75rem;
&:last-child {
margin-bottom: 0;
}
dt {
font-size: 0.75rem;
font-weight: 600;
color: $color-text-muted;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.25rem;
}
dd {
margin: 0;
font-size: 0.875rem;
color: #374151;
}
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid $color-border;
font-size: 0.8125rem;
color: $color-text-muted;
}
}
.vex-status-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
&.vex-status--not-affected {
background: #dcfce7;
color: #15803d;
}
&.vex-status--mitigated {
background: #fef3c7;
color: #92400e;
}
&.vex-status--unmitigated {
background: #fee2e2;
color: #dc2626;
}
&.vex-status--fixed {
background: #dbeafe;
color: #2563eb;
}
}
.vex-expired-badge,
.vex-pending-badge {
display: inline-block;
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
}
.vex-expired-badge {
background: #fee2e2;
color: #dc2626;
}
.vex-pending-badge {
background: #fef9c3;
color: #a16207;
}
.vex-subject-type {
display: inline-block;
padding: 0.125rem 0.375rem;
border-radius: 4px;
background: #e0e7ff;
color: #4338ca;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
margin-right: 0.5rem;
}
.vex-subject-name {
font-size: 0.8125rem;
word-break: break-all;
}
.vex-justification-type {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
background: #f3f4f6;
color: #374151;
font-size: 0.8125rem;
font-weight: 500;
}
.vex-justification-text {
margin: 0.5rem 0 0;
font-size: 0.875rem;
color: $color-text-muted;
line-height: 1.5;
}
.vex-scope-label {
display: inline-block;
font-size: 0.75rem;
color: $color-text-muted;
margin-right: 0.25rem;
}
.vex-scope-values {
font-weight: 500;
margin-right: 1rem;
}
.vex-evidence-list {
margin: 0;
padding-left: 1.25rem;
font-size: 0.8125rem;
li {
margin: 0.375rem 0;
}
}
.vex-evidence-type {
display: inline-block;
padding: 0.125rem 0.25rem;
border-radius: 2px;
background: #e5e7eb;
color: #374151;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
margin-right: 0.5rem;
}
.vex-evidence-link {
color: #3b82f6;
word-break: break-all;
&:hover {
text-decoration: underline;
}
}
// Tab conflict indicator
.evidence-panel__tab {
.conflict-indicator {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
margin-left: 0.375rem;
border-radius: 50%;
background: #f59e0b;
color: #fff;
font-size: 0.625rem;
font-weight: 700;
}
&.has-conflicts {
color: #92400e;
}
}
// Animation
@keyframes fadeIn {
from {
@@ -1010,3 +1755,16 @@ $color-text-muted: #6b7280;
code {
font-family: 'Monaco', 'Consolas', 'Liberation Mono', monospace;
}
// Accessibility utility - visually hidden but accessible to screen readers
.visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}

View File

@@ -11,19 +11,28 @@ import {
import {
AocChainEntry,
DEFAULT_OBSERVATION_FILTERS,
DEFAULT_PAGE_SIZE,
EvidenceData,
Linkset,
LinksetConflict,
Observation,
ObservationFilters,
PolicyDecision,
PolicyEvidence,
PolicyRuleResult,
SeverityBucket,
SOURCE_INFO,
SourceInfo,
VexConflict,
VexDecision,
VexJustificationType,
VexStatus,
VexStatusSummary,
} from '../../core/api/evidence.models';
import { EvidenceApi, EVIDENCE_API } from '../../core/api/evidence.client';
type TabId = 'observations' | 'linkset' | 'policy' | 'aoc';
type TabId = 'observations' | 'linkset' | 'vex' | 'policy' | 'aoc';
type ObservationView = 'side-by-side' | 'stacked';
@Component({
@@ -37,6 +46,9 @@ type ObservationView = 'side-by-side' | 'stacked';
export class EvidencePanelComponent {
private readonly evidenceApi = inject(EVIDENCE_API);
// Expose Math for template usage
readonly Math = Math;
// Inputs
readonly advisoryId = input.required<string>();
readonly evidenceData = input<EvidenceData | null>(null);
@@ -52,6 +64,14 @@ export class EvidencePanelComponent {
readonly expandedAocEntry = signal<string | null>(null);
readonly showConflictDetails = signal(false);
// Filter state
readonly filters = signal<ObservationFilters>(DEFAULT_OBSERVATION_FILTERS);
readonly showFilters = signal(false);
// Pagination state
readonly pageSize = signal(DEFAULT_PAGE_SIZE);
readonly currentPage = signal(0);
// Loading/error state
readonly loading = signal(false);
readonly error = signal<string | null>(null);
@@ -83,6 +103,130 @@ export class EvidencePanelComponent {
return obs.map((o) => this.getSourceInfo(o.source));
});
// Available sources for filter dropdown
readonly availableSources = computed(() => {
const obs = this.observations();
const sourceIds = [...new Set(obs.map((o) => o.source))];
return sourceIds.map((id) => this.getSourceInfo(id));
});
// Filtered observations based on current filters
readonly filteredObservations = computed(() => {
const obs = this.observations();
const f = this.filters();
const linkset = this.linkset();
return obs.filter((o) => {
// Source filter
if (f.sources.length > 0 && !f.sources.includes(o.source)) {
return false;
}
// Severity bucket filter
if (f.severityBucket !== 'all') {
const maxScore = Math.max(...o.severities.map((s) => s.score), 0);
if (!this.matchesSeverityBucket(maxScore, f.severityBucket)) {
return false;
}
}
// Conflict-only filter
if (f.conflictOnly && linkset) {
const isInConflict = linkset.conflicts.some((c) =>
c.sourceIds?.includes(o.source)
);
if (!isInConflict) {
return false;
}
}
// CVSS vector presence filter
if (f.hasCvssVector !== null) {
const hasVector = o.severities.some((s) => !!s.vector);
if (f.hasCvssVector !== hasVector) {
return false;
}
}
return true;
});
});
// Paginated observations
readonly paginatedObservations = computed(() => {
const filtered = this.filteredObservations();
const page = this.currentPage();
const size = this.pageSize();
const start = page * size;
return filtered.slice(start, start + size);
});
// Total pages for pagination
readonly totalPages = computed(() => {
const total = this.filteredObservations().length;
const size = this.pageSize();
return Math.ceil(total / size);
});
// Whether there are more pages
readonly hasNextPage = computed(() => this.currentPage() < this.totalPages() - 1);
readonly hasPreviousPage = computed(() => this.currentPage() > 0);
// Active filter count for badge
readonly activeFilterCount = computed(() => {
const f = this.filters();
let count = 0;
if (f.sources.length > 0) count++;
if (f.severityBucket !== 'all') count++;
if (f.conflictOnly) count++;
if (f.hasCvssVector !== null) count++;
return count;
});
// VEX computed values
readonly vexDecisions = computed(() => this.evidenceData()?.vexDecisions ?? []);
readonly vexConflicts = computed(() => this.evidenceData()?.vexConflicts ?? []);
readonly hasVexData = computed(() => this.vexDecisions().length > 0);
readonly hasVexConflicts = computed(() => this.vexConflicts().length > 0);
// Permalink state
readonly showPermalink = signal(false);
readonly permalinkCopied = signal(false);
readonly vexStatusSummary = computed((): VexStatusSummary => {
const decisions = this.vexDecisions();
return {
notAffected: decisions.filter((d) => d.status === 'NOT_AFFECTED').length,
affectedMitigated: decisions.filter((d) => d.status === 'AFFECTED_MITIGATED').length,
affectedUnmitigated: decisions.filter((d) => d.status === 'AFFECTED_UNMITIGATED').length,
fixed: decisions.filter((d) => d.status === 'FIXED').length,
total: decisions.length,
};
});
// Permalink computed value
readonly permalink = computed(() => {
const advisoryId = this.advisoryId();
const tab = this.activeTab();
const linkset = this.linkset();
const policy = this.policyEvidence();
// Build query params for current state
const params = new URLSearchParams();
params.set('tab', tab);
if (linkset) {
params.set('linkset', linkset.linksetId);
}
if (policy) {
params.set('policy', policy.policyId);
}
// Base URL with advisory path and query string
const baseUrl = typeof window !== 'undefined' ? window.location.origin : '';
return `${baseUrl}/evidence/${encodeURIComponent(advisoryId)}?${params.toString()}`;
});
// Tab methods
setActiveTab(tab: TabId): void {
this.activeTab.set(tab);
@@ -106,6 +250,94 @@ export class EvidencePanelComponent {
return this.expandedObservation() === observationId;
}
// Filter methods
toggleFilters(): void {
this.showFilters.update((v) => !v);
}
updateSourceFilter(sources: readonly string[]): void {
this.filters.update((f) => ({ ...f, sources }));
this.currentPage.set(0); // Reset to first page on filter change
}
toggleSourceFilter(sourceId: string): void {
this.filters.update((f) => {
const sources = f.sources.includes(sourceId)
? f.sources.filter((s) => s !== sourceId)
: [...f.sources, sourceId];
return { ...f, sources };
});
this.currentPage.set(0);
}
updateSeverityBucket(bucket: SeverityBucket): void {
this.filters.update((f) => ({ ...f, severityBucket: bucket }));
this.currentPage.set(0);
}
toggleConflictOnly(): void {
this.filters.update((f) => ({ ...f, conflictOnly: !f.conflictOnly }));
this.currentPage.set(0);
}
updateCvssVectorFilter(value: boolean | null): void {
this.filters.update((f) => ({ ...f, hasCvssVector: value }));
this.currentPage.set(0);
}
clearFilters(): void {
this.filters.set(DEFAULT_OBSERVATION_FILTERS);
this.currentPage.set(0);
}
isSourceSelected(sourceId: string): boolean {
return this.filters().sources.includes(sourceId);
}
isSeverityBucketSelected(bucket: SeverityBucket): boolean {
return this.filters().severityBucket === bucket;
}
// Severity bucket matching helper
matchesSeverityBucket(score: number, bucket: SeverityBucket): boolean {
switch (bucket) {
case 'critical':
return score >= 9.0;
case 'high':
return score >= 7.0 && score < 9.0;
case 'medium':
return score >= 4.0 && score < 7.0;
case 'low':
return score < 4.0;
case 'all':
default:
return true;
}
}
// Pagination methods
goToPage(page: number): void {
const maxPage = Math.max(0, this.totalPages() - 1);
this.currentPage.set(Math.max(0, Math.min(page, maxPage)));
}
nextPage(): void {
if (this.hasNextPage()) {
this.currentPage.update((p) => p + 1);
}
}
previousPage(): void {
if (this.hasPreviousPage()) {
this.currentPage.update((p) => p - 1);
}
}
updatePageSize(size: number): void {
this.pageSize.set(size);
this.currentPage.set(0);
}
// AOC chain methods
toggleAocEntry(attestationId: string): void {
const current = this.expandedAocEntry();
@@ -205,6 +437,105 @@ export class EvidencePanelComponent {
return 'Low';
}
// VEX helpers
getVexStatusLabel(status: VexStatus): string {
switch (status) {
case 'NOT_AFFECTED':
return 'Not Affected';
case 'AFFECTED_MITIGATED':
return 'Affected (Mitigated)';
case 'AFFECTED_UNMITIGATED':
return 'Affected (Unmitigated)';
case 'FIXED':
return 'Fixed';
default:
return status;
}
}
getVexStatusClass(status: VexStatus): string {
switch (status) {
case 'NOT_AFFECTED':
return 'vex-status--not-affected';
case 'AFFECTED_MITIGATED':
return 'vex-status--mitigated';
case 'AFFECTED_UNMITIGATED':
return 'vex-status--unmitigated';
case 'FIXED':
return 'vex-status--fixed';
default:
return '';
}
}
getVexJustificationLabel(type: VexJustificationType): string {
const labels: Record<VexJustificationType, string> = {
CODE_NOT_PRESENT: 'Code Not Present',
CODE_NOT_REACHABLE: 'Code Not Reachable',
VULNERABLE_CODE_NOT_IN_EXECUTE_PATH: 'Vulnerable Code Not In Execute Path',
CONFIGURATION_NOT_AFFECTED: 'Configuration Not Affected',
OS_NOT_AFFECTED: 'OS Not Affected',
RUNTIME_MITIGATION_PRESENT: 'Runtime Mitigation Present',
COMPENSATING_CONTROLS: 'Compensating Controls',
ACCEPTED_BUSINESS_RISK: 'Accepted Business Risk',
OTHER: 'Other',
};
return labels[type] ?? type;
}
isVexDecisionExpired(decision: VexDecision): boolean {
if (!decision.validFor?.notAfter) return false;
return new Date(decision.validFor.notAfter) < new Date();
}
isVexDecisionPending(decision: VexDecision): boolean {
if (!decision.validFor?.notBefore) return false;
return new Date(decision.validFor.notBefore) > new Date();
}
// VEX export handlers
readonly exportVex = output<{ format: 'json' | 'csaf' | 'openvex' }>();
onExportVex(format: 'json' | 'csaf' | 'openvex'): void {
this.exportVex.emit({ format });
}
// Permalink methods
togglePermalink(): void {
this.showPermalink.update((v) => !v);
this.permalinkCopied.set(false);
}
async copyPermalink(): Promise<void> {
const link = this.permalink();
try {
await navigator.clipboard.writeText(link);
this.permalinkCopied.set(true);
// Reset after 2 seconds
setTimeout(() => this.permalinkCopied.set(false), 2000);
} catch (err) {
// Fallback for browsers without clipboard API
this.fallbackCopyToClipboard(link);
}
}
private fallbackCopyToClipboard(text: string): void {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
this.permalinkCopied.set(true);
setTimeout(() => this.permalinkCopied.set(false), 2000);
} catch {
console.error('Fallback: Unable to copy');
}
document.body.removeChild(textArea);
}
// Download handlers
onDownloadObservation(observationId: string): void {
this.downloadDocument.emit({ type: 'observation', id: observationId });
@@ -252,4 +583,12 @@ export class EvidencePanelComponent {
trackByRuleId(_: number, rule: PolicyRuleResult): string {
return rule.ruleId;
}
trackByVexDecisionId(_: number, decision: VexDecision): string {
return decision.id;
}
trackByVexConflictId(_: number, conflict: VexConflict): string {
return conflict.vulnerabilityId;
}
}

View File

@@ -0,0 +1,173 @@
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { AUTH_SERVICE, AuthService } from '../../core/auth';
/**
* Orchestrator Dashboard - Main landing page for Orchestrator features.
* Requires orch:read scope for access (gated by requireOrchViewerGuard).
*
* @see UI-ORCH-32-001
*/
@Component({
selector: 'app-orchestrator-dashboard',
standalone: true,
imports: [CommonModule, RouterLink],
template: `
<div class="orch-dashboard">
<header class="orch-dashboard__header">
<h1 class="orch-dashboard__title">Orchestrator Dashboard</h1>
<p class="orch-dashboard__description">
Monitor and manage orchestrated jobs, quotas, and backfill operations.
</p>
</header>
<nav class="orch-dashboard__nav">
<a routerLink="/orchestrator/jobs" class="orch-dashboard__card">
<span class="orch-dashboard__card-icon">&#x1F4CB;</span>
<span class="orch-dashboard__card-title">Jobs</span>
<span class="orch-dashboard__card-desc">View job status and history</span>
</a>
@if (authService.canOperateOrchestrator()) {
<a routerLink="/orchestrator/quotas" class="orch-dashboard__card">
<span class="orch-dashboard__card-icon">&#x2699;</span>
<span class="orch-dashboard__card-title">Quotas</span>
<span class="orch-dashboard__card-desc">Manage resource quotas</span>
</a>
}
</nav>
<section class="orch-dashboard__scope-info">
<h2>Your Orchestrator Access</h2>
<ul>
<li>
<strong>View Jobs:</strong>
{{ authService.canViewOrchestrator() ? 'Granted' : 'Denied' }}
</li>
<li>
<strong>Operate:</strong>
{{ authService.canOperateOrchestrator() ? 'Granted' : 'Denied' }}
</li>
<li>
<strong>Manage Quotas:</strong>
{{ authService.canManageOrchestratorQuotas() ? 'Granted' : 'Denied' }}
</li>
<li>
<strong>Initiate Backfill:</strong>
{{ authService.canInitiateBackfill() ? 'Granted' : 'Denied' }}
</li>
</ul>
</section>
</div>
`,
styles: [`
.orch-dashboard {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.orch-dashboard__header {
margin-bottom: 2rem;
}
.orch-dashboard__title {
margin: 0 0 0.5rem;
font-size: 1.75rem;
font-weight: 600;
color: #111827;
}
.orch-dashboard__description {
margin: 0;
color: #6b7280;
}
.orch-dashboard__nav {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.orch-dashboard__card {
display: flex;
flex-direction: column;
padding: 1.5rem;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
text-decoration: none;
transition: box-shadow 0.15s, border-color 0.15s;
&:hover {
border-color: #3b82f6;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
&:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
}
.orch-dashboard__card-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.orch-dashboard__card-title {
font-size: 1.125rem;
font-weight: 600;
color: #111827;
margin-bottom: 0.25rem;
}
.orch-dashboard__card-desc {
font-size: 0.875rem;
color: #6b7280;
}
.orch-dashboard__scope-info {
padding: 1.5rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
h2 {
margin: 0 0 1rem;
font-size: 1rem;
font-weight: 600;
color: #374151;
}
ul {
margin: 0;
padding: 0;
list-style: none;
}
li {
padding: 0.5rem 0;
font-size: 0.875rem;
color: #374151;
border-bottom: 1px solid #e5e7eb;
&:last-child {
border-bottom: none;
}
strong {
display: inline-block;
min-width: 140px;
color: #6b7280;
}
}
}
`],
})
export class OrchestratorDashboardComponent {
protected readonly authService = inject(AUTH_SERVICE) as AuthService;
}

View File

@@ -0,0 +1,94 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
/**
* Orchestrator Job Detail - Shows details for a specific job.
* Requires orch:read scope for access.
*
* @see UI-ORCH-32-001
*/
@Component({
selector: 'app-orchestrator-job-detail',
standalone: true,
imports: [CommonModule, RouterLink],
template: `
<div class="orch-job-detail">
<header class="orch-job-detail__header">
<a routerLink="/orchestrator/jobs" class="orch-job-detail__back">&larr; Back to Jobs</a>
<h1 class="orch-job-detail__title">Job Detail</h1>
<p class="orch-job-detail__id">ID: {{ jobId }}</p>
</header>
<div class="orch-job-detail__placeholder">
<p>Job details will be implemented when Orchestrator API contract is finalized.</p>
<p class="orch-job-detail__hint">This page requires the <code>orch:read</code> scope.</p>
</div>
</div>
`,
styles: [`
.orch-job-detail {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.orch-job-detail__header {
margin-bottom: 2rem;
}
.orch-job-detail__back {
display: inline-block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
color: #3b82f6;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.orch-job-detail__title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: #111827;
}
.orch-job-detail__id {
margin: 0.25rem 0 0;
font-size: 0.875rem;
color: #6b7280;
font-family: monospace;
}
.orch-job-detail__placeholder {
padding: 3rem;
background: #f9fafb;
border: 1px dashed #d1d5db;
border-radius: 8px;
text-align: center;
color: #6b7280;
p {
margin: 0.5rem 0;
}
code {
background: #e5e7eb;
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.875rem;
}
}
.orch-job-detail__hint {
font-size: 0.875rem;
color: #9ca3af;
}
`],
})
export class OrchestratorJobDetailComponent {
@Input() jobId: string = '';
}

View File

@@ -0,0 +1,84 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
/**
* Orchestrator Jobs List - Shows all orchestrator jobs.
* Requires orch:read scope for access.
*
* @see UI-ORCH-32-001
*/
@Component({
selector: 'app-orchestrator-jobs',
standalone: true,
imports: [CommonModule, RouterLink],
template: `
<div class="orch-jobs">
<header class="orch-jobs__header">
<a routerLink="/orchestrator" class="orch-jobs__back">&larr; Back to Dashboard</a>
<h1 class="orch-jobs__title">Orchestrator Jobs</h1>
</header>
<div class="orch-jobs__placeholder">
<p>Job list will be implemented when Orchestrator API contract is finalized.</p>
<p class="orch-jobs__hint">This page requires the <code>orch:read</code> scope.</p>
</div>
</div>
`,
styles: [`
.orch-jobs {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.orch-jobs__header {
margin-bottom: 2rem;
}
.orch-jobs__back {
display: inline-block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
color: #3b82f6;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.orch-jobs__title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: #111827;
}
.orch-jobs__placeholder {
padding: 3rem;
background: #f9fafb;
border: 1px dashed #d1d5db;
border-radius: 8px;
text-align: center;
color: #6b7280;
p {
margin: 0.5rem 0;
}
code {
background: #e5e7eb;
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.875rem;
}
}
.orch-jobs__hint {
font-size: 0.875rem;
color: #9ca3af;
}
`],
})
export class OrchestratorJobsComponent {}

View File

@@ -0,0 +1,86 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
/**
* Orchestrator Quotas Management - Manage resource quotas.
* Requires orch:read + orch:operate scopes for access.
*
* @see UI-ORCH-32-001
*/
@Component({
selector: 'app-orchestrator-quotas',
standalone: true,
imports: [CommonModule, RouterLink],
template: `
<div class="orch-quotas">
<header class="orch-quotas__header">
<a routerLink="/orchestrator" class="orch-quotas__back">&larr; Back to Dashboard</a>
<h1 class="orch-quotas__title">Orchestrator Quotas</h1>
</header>
<div class="orch-quotas__placeholder">
<p>Quota management will be implemented when Orchestrator API contract is finalized.</p>
<p class="orch-quotas__hint">
This page requires the <code>orch:read</code> and <code>orch:operate</code> scopes.
</p>
</div>
</div>
`,
styles: [`
.orch-quotas {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.orch-quotas__header {
margin-bottom: 2rem;
}
.orch-quotas__back {
display: inline-block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
color: #3b82f6;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.orch-quotas__title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: #111827;
}
.orch-quotas__placeholder {
padding: 3rem;
background: #f9fafb;
border: 1px dashed #d1d5db;
border-radius: 8px;
text-align: center;
color: #6b7280;
p {
margin: 0.5rem 0;
}
code {
background: #e5e7eb;
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.875rem;
}
}
.orch-quotas__hint {
font-size: 0.875rem;
color: #9ca3af;
}
`],
})
export class OrchestratorQuotasComponent {}

View File

@@ -0,0 +1,86 @@
{
"$schema": "https://stella-ops.org/i18n/micro-interactions.schema.json",
"_meta": {
"version": "1.0",
"locale": "en-US",
"description": "Micro-interaction copy for StellaOps Console (MI9)"
},
"loading": {
"skeleton": "Loading...",
"spinner": "Please wait...",
"progress": "Loading {percent}%",
"slow": "This is taking longer than expected...",
"retry": "Retry"
},
"error": {
"generic": "Something went wrong",
"network": "Network error. Check your connection.",
"timeout": "Request timed out. Please try again.",
"notFound": "The requested resource was not found.",
"unauthorized": "You don't have permission to view this.",
"serverError": "Server error. Please try again later.",
"tryAgain": "Try again",
"goBack": "Go back",
"contactSupport": "Contact support"
},
"offline": {
"banner": "You're offline",
"description": "Some features may be unavailable.",
"lastSync": "Last synced {time}",
"reconnecting": "Reconnecting...",
"reconnected": "Back online"
},
"toast": {
"success": "Success",
"info": "Info",
"warning": "Warning",
"error": "Error",
"dismiss": "Dismiss",
"undo": "Undo",
"undoCountdown": "Undo ({seconds}s)"
},
"actions": {
"save": "Save",
"saving": "Saving...",
"saved": "Saved",
"cancel": "Cancel",
"confirm": "Confirm",
"delete": "Delete",
"deleting": "Deleting...",
"deleted": "Deleted",
"submit": "Submit",
"submitting": "Submitting...",
"submitted": "Submitted",
"close": "Close",
"expand": "Expand",
"collapse": "Collapse",
"showMore": "Show more",
"showLess": "Show less"
},
"validation": {
"required": "This field is required",
"invalid": "Invalid value",
"tooLong": "Maximum {max} characters allowed",
"tooShort": "Minimum {min} characters required",
"invalidEmail": "Please enter a valid email address",
"invalidUrl": "Please enter a valid URL"
},
"accessibility": {
"loading": "Content is loading",
"loaded": "Content loaded",
"error": "An error occurred",
"expanded": "Expanded",
"collapsed": "Collapsed",
"selected": "Selected",
"deselected": "Deselected",
"required": "Required field",
"optional": "Optional",
"menu": "Menu",
"dialog": "Dialog",
"alert": "Alert"
},
"motion": {
"reducedMotion": "Animations reduced",
"motionEnabled": "Animations enabled"
}
}

View File

@@ -16,6 +16,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0-rc.2.25502.107" />

View File

@@ -0,0 +1,13 @@
namespace StellaOps.Microservice.SourceGen;
/// <summary>
/// Placeholder type for the source generator project.
/// This will be replaced with actual source generator implementation in a later sprint.
/// </summary>
public static class Placeholder
{
/// <summary>
/// Indicates the source generator is not yet implemented.
/// </summary>
public const string Status = "NotImplemented";
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,24 @@
using StellaOps.Router.Common;
namespace StellaOps.Microservice;
/// <summary>
/// Configuration for a router endpoint.
/// </summary>
public sealed class RouterEndpointConfig
{
/// <summary>
/// Gets or sets the router host.
/// </summary>
public required string Host { get; set; }
/// <summary>
/// Gets or sets the router port.
/// </summary>
public required int Port { get; set; }
/// <summary>
/// Gets or sets the transport type to use.
/// </summary>
public TransportType TransportType { get; set; } = TransportType.Tcp;
}

View File

@@ -0,0 +1,28 @@
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Microservice;
/// <summary>
/// Extension methods for registering Stella microservice services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds Stella microservice services to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configure">Action to configure the microservice options.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddStellaMicroservice(
this IServiceCollection services,
Action<StellaMicroserviceOptions> configure)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
// Stub implementation - will be filled in later sprints
services.Configure(configure);
return services;
}
}

View File

@@ -0,0 +1,40 @@
using StellaOps.Router.Common;
namespace StellaOps.Microservice;
/// <summary>
/// Options for configuring a Stella microservice.
/// </summary>
public sealed class StellaMicroserviceOptions
{
/// <summary>
/// Gets or sets the service name.
/// </summary>
public required string ServiceName { get; set; }
/// <summary>
/// Gets or sets the semantic version.
/// </summary>
public required string Version { get; set; }
/// <summary>
/// Gets or sets the region where this instance is deployed.
/// </summary>
public required string Region { get; set; }
/// <summary>
/// Gets or sets the unique instance identifier.
/// </summary>
public string InstanceId { get; set; } = Guid.NewGuid().ToString("N");
/// <summary>
/// Gets the router endpoints to connect to.
/// At least one router endpoint is required.
/// </summary>
public List<RouterEndpointConfig> Routers { get; set; } = [];
/// <summary>
/// Gets or sets the optional path to a YAML config file for endpoint overrides.
/// </summary>
public string? EndpointConfigPath { get; set; }
}

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,17 @@
namespace StellaOps.Router.Common;
/// <summary>
/// Represents a claim requirement for endpoint authorization.
/// </summary>
public sealed record ClaimRequirement
{
/// <summary>
/// Gets the claim type that must be present.
/// </summary>
public required string Type { get; init; }
/// <summary>
/// Gets the optional claim value that must match.
/// </summary>
public string? Value { get; init; }
}

View File

@@ -0,0 +1,42 @@
namespace StellaOps.Router.Common;
/// <summary>
/// Represents the state of a connection between a microservice and the router.
/// </summary>
public sealed class ConnectionState
{
/// <summary>
/// Gets the unique identifier for this connection.
/// </summary>
public required string ConnectionId { get; init; }
/// <summary>
/// Gets the instance descriptor for the connected microservice.
/// </summary>
public required InstanceDescriptor Instance { get; init; }
/// <summary>
/// Gets or sets the health status of this connection.
/// </summary>
public InstanceHealthStatus Status { get; set; } = InstanceHealthStatus.Unknown;
/// <summary>
/// Gets or sets the UTC timestamp of the last heartbeat.
/// </summary>
public DateTime LastHeartbeatUtc { get; set; } = DateTime.UtcNow;
/// <summary>
/// Gets or sets the average ping time in milliseconds.
/// </summary>
public double AveragePingMs { get; set; }
/// <summary>
/// Gets the endpoints served by this connection.
/// </summary>
public Dictionary<(string Method, string Path), EndpointDescriptor> Endpoints { get; } = new();
/// <summary>
/// Gets the transport type used for this connection.
/// </summary>
public required TransportType TransportType { get; init; }
}

View File

@@ -0,0 +1,42 @@
namespace StellaOps.Router.Common;
/// <summary>
/// Describes an endpoint's identity and metadata.
/// </summary>
public sealed record EndpointDescriptor
{
/// <summary>
/// Gets the name of the service that owns this endpoint.
/// </summary>
public required string ServiceName { get; init; }
/// <summary>
/// Gets the semantic version of the service.
/// </summary>
public required string Version { get; init; }
/// <summary>
/// Gets the HTTP method (GET, POST, PUT, PATCH, DELETE).
/// </summary>
public required string Method { get; init; }
/// <summary>
/// Gets the path template (e.g., "/billing/invoices/{id}").
/// </summary>
public required string Path { get; init; }
/// <summary>
/// Gets the default timeout for this endpoint.
/// </summary>
public TimeSpan DefaultTimeout { get; init; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Gets the claim requirements for authorization.
/// </summary>
public IReadOnlyList<ClaimRequirement> RequiringClaims { get; init; } = [];
/// <summary>
/// Gets a value indicating whether this endpoint supports streaming.
/// </summary>
public bool SupportsStreaming { get; init; }
}

View File

@@ -0,0 +1,22 @@
namespace StellaOps.Router.Common;
/// <summary>
/// Represents a protocol frame in the router transport layer.
/// </summary>
public sealed record Frame
{
/// <summary>
/// Gets the type of this frame.
/// </summary>
public required FrameType Type { get; init; }
/// <summary>
/// Gets the correlation ID for request/response matching.
/// </summary>
public string? CorrelationId { get; init; }
/// <summary>
/// Gets the raw payload bytes.
/// </summary>
public ReadOnlyMemory<byte> Payload { get; init; }
}

View File

@@ -0,0 +1,47 @@
namespace StellaOps.Router.Common;
/// <summary>
/// Defines the frame types used in the router protocol.
/// </summary>
public enum FrameType
{
/// <summary>
/// Initial registration frame sent by microservice.
/// </summary>
Hello,
/// <summary>
/// Periodic health check frame.
/// </summary>
Heartbeat,
/// <summary>
/// Request frame containing method, path, headers, and body.
/// </summary>
Request,
/// <summary>
/// Response frame containing status, headers, and body.
/// </summary>
Response,
/// <summary>
/// Streaming request data frame.
/// </summary>
RequestStreamData,
/// <summary>
/// Streaming response data frame.
/// </summary>
ResponseStreamData,
/// <summary>
/// Cancellation frame for aborting in-flight requests.
/// </summary>
Cancel,
/// <summary>
/// Optional frame for updating endpoint metadata at runtime.
/// </summary>
EndpointsUpdate
}

View File

@@ -0,0 +1,41 @@
namespace StellaOps.Router.Common;
/// <summary>
/// Provides global routing state derived from all live connections.
/// </summary>
public interface IGlobalRoutingState
{
/// <summary>
/// Resolves an HTTP request to an endpoint descriptor.
/// </summary>
/// <param name="method">The HTTP method.</param>
/// <param name="path">The request path.</param>
/// <returns>The endpoint descriptor, or null if not found.</returns>
EndpointDescriptor? ResolveEndpoint(string method, string path);
/// <summary>
/// Gets all connections that can handle the specified endpoint.
/// </summary>
/// <param name="serviceName">The service name.</param>
/// <param name="version">The service version.</param>
/// <param name="method">The HTTP method.</param>
/// <param name="path">The request path.</param>
/// <returns>The available connection states.</returns>
IEnumerable<ConnectionState> GetConnectionsForEndpoint(
string serviceName,
string version,
string method,
string path);
/// <summary>
/// Registers a connection and its endpoints.
/// </summary>
/// <param name="connection">The connection state to register.</param>
void RegisterConnection(ConnectionState connection);
/// <summary>
/// Removes a connection from the routing state.
/// </summary>
/// <param name="connectionId">The connection ID to remove.</param>
void UnregisterConnection(string connectionId);
}

View File

@@ -0,0 +1,24 @@
namespace StellaOps.Router.Common;
/// <summary>
/// Provides extensibility for routing decisions.
/// </summary>
public interface IRoutingPlugin
{
/// <summary>
/// Gets the priority of this plugin. Lower values run first.
/// </summary>
int Priority { get; }
/// <summary>
/// Filters or reorders candidate connections for routing.
/// </summary>
/// <param name="candidates">The candidate connections.</param>
/// <param name="endpoint">The target endpoint.</param>
/// <param name="gatewayRegion">The gateway's region.</param>
/// <returns>The filtered/reordered connections.</returns>
IEnumerable<ConnectionState> Filter(
IEnumerable<ConnectionState> candidates,
EndpointDescriptor endpoint,
string gatewayRegion);
}

View File

@@ -0,0 +1,24 @@
namespace StellaOps.Router.Common;
/// <summary>
/// Represents a transport client that connects to routers.
/// </summary>
public interface ITransportClient : IAsyncDisposable
{
/// <summary>
/// Gets the transport type for this client.
/// </summary>
TransportType TransportType { get; }
/// <summary>
/// Connects to a router endpoint.
/// </summary>
/// <param name="host">The router host.</param>
/// <param name="port">The router port.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The established connection.</returns>
Task<ITransportConnection> ConnectAsync(
string host,
int port,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,37 @@
namespace StellaOps.Router.Common;
/// <summary>
/// Represents a bidirectional transport connection.
/// </summary>
public interface ITransportConnection : IAsyncDisposable
{
/// <summary>
/// Gets the unique identifier for this connection.
/// </summary>
string ConnectionId { get; }
/// <summary>
/// Gets a value indicating whether the connection is open.
/// </summary>
bool IsConnected { get; }
/// <summary>
/// Sends a frame over the connection.
/// </summary>
/// <param name="frame">The frame to send.</param>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask SendAsync(Frame frame, CancellationToken cancellationToken = default);
/// <summary>
/// Receives the next frame from the connection.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The received frame, or null if connection closed.</returns>
ValueTask<Frame?> ReceiveAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Closes the connection gracefully.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task CloseAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,45 @@
namespace StellaOps.Router.Common;
/// <summary>
/// Represents a transport server that accepts connections from microservices.
/// </summary>
public interface ITransportServer : IAsyncDisposable
{
/// <summary>
/// Gets the transport type for this server.
/// </summary>
TransportType TransportType { get; }
/// <summary>
/// Starts listening for incoming connections.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task StartAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Stops accepting new connections.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task StopAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Occurs when a new connection is established.
/// </summary>
event EventHandler<TransportConnectionEventArgs>? ConnectionEstablished;
/// <summary>
/// Occurs when a connection is closed.
/// </summary>
event EventHandler<TransportConnectionEventArgs>? ConnectionClosed;
}
/// <summary>
/// Event arguments for transport connection events.
/// </summary>
public sealed class TransportConnectionEventArgs : EventArgs
{
/// <summary>
/// Gets the connection that triggered the event.
/// </summary>
public required ITransportConnection Connection { get; init; }
}

View File

@@ -0,0 +1,27 @@
namespace StellaOps.Router.Common;
/// <summary>
/// Describes a microservice instance's identity.
/// </summary>
public sealed record InstanceDescriptor
{
/// <summary>
/// Gets the unique identifier for this instance.
/// </summary>
public required string InstanceId { get; init; }
/// <summary>
/// Gets the name of the service.
/// </summary>
public required string ServiceName { get; init; }
/// <summary>
/// Gets the semantic version of the service.
/// </summary>
public required string Version { get; init; }
/// <summary>
/// Gets the region where this instance is deployed.
/// </summary>
public required string Region { get; init; }
}

View File

@@ -0,0 +1,32 @@
namespace StellaOps.Router.Common;
/// <summary>
/// Defines the health status of a microservice instance.
/// </summary>
public enum InstanceHealthStatus
{
/// <summary>
/// Health status is not yet determined.
/// </summary>
Unknown,
/// <summary>
/// Instance is healthy and can accept requests.
/// </summary>
Healthy,
/// <summary>
/// Instance is degraded but can still accept requests.
/// </summary>
Degraded,
/// <summary>
/// Instance is draining and will not accept new requests.
/// </summary>
Draining,
/// <summary>
/// Instance is unhealthy and should not receive requests.
/// </summary>
Unhealthy
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,32 @@
namespace StellaOps.Router.Common;
/// <summary>
/// Defines the transport types supported for microservice-to-router communication.
/// </summary>
public enum TransportType
{
/// <summary>
/// In-memory transport for testing and local development.
/// </summary>
InMemory,
/// <summary>
/// UDP transport for small/bounded payloads.
/// </summary>
Udp,
/// <summary>
/// TCP transport with length-prefixed framing.
/// </summary>
Tcp,
/// <summary>
/// TLS/mTLS transport with certificate-based authentication.
/// </summary>
Tls,
/// <summary>
/// RabbitMQ transport for queue-based communication.
/// </summary>
RabbitMq
}

View File

@@ -0,0 +1,25 @@
namespace StellaOps.Router.Config;
/// <summary>
/// Configuration for payload and memory limits.
/// </summary>
public sealed class PayloadLimits
{
/// <summary>
/// Gets or sets the maximum request bytes per call.
/// Default: 10 MB.
/// </summary>
public long MaxRequestBytesPerCall { get; set; } = 10 * 1024 * 1024;
/// <summary>
/// Gets or sets the maximum request bytes per connection.
/// Default: 100 MB.
/// </summary>
public long MaxRequestBytesPerConnection { get; set; } = 100 * 1024 * 1024;
/// <summary>
/// Gets or sets the maximum aggregate in-flight bytes across all requests.
/// Default: 1 GB.
/// </summary>
public long MaxAggregateInflightBytes { get; set; } = 1024 * 1024 * 1024;
}

View File

@@ -0,0 +1,17 @@
namespace StellaOps.Router.Config;
/// <summary>
/// Root configuration for the router.
/// </summary>
public sealed class RouterConfig
{
/// <summary>
/// Gets or sets the payload limits configuration.
/// </summary>
public PayloadLimits PayloadLimits { get; set; } = new();
/// <summary>
/// Gets or sets the service configurations.
/// </summary>
public List<ServiceConfig> Services { get; set; } = [];
}

View File

@@ -0,0 +1,60 @@
using StellaOps.Router.Common;
namespace StellaOps.Router.Config;
/// <summary>
/// Configuration for a service in the router.
/// </summary>
public sealed class ServiceConfig
{
/// <summary>
/// Gets or sets the service name.
/// </summary>
public required string ServiceName { get; set; }
/// <summary>
/// Gets or sets the default version for requests without explicit version.
/// </summary>
public string? DefaultVersion { get; set; }
/// <summary>
/// Gets or sets the default transport type for this service.
/// </summary>
public TransportType DefaultTransport { get; set; } = TransportType.Tcp;
/// <summary>
/// Gets or sets the endpoint configurations.
/// </summary>
public List<EndpointConfig> Endpoints { get; set; } = [];
}
/// <summary>
/// Configuration for an endpoint in a service.
/// </summary>
public sealed class EndpointConfig
{
/// <summary>
/// Gets or sets the HTTP method.
/// </summary>
public required string Method { get; set; }
/// <summary>
/// Gets or sets the path template.
/// </summary>
public required string Path { get; set; }
/// <summary>
/// Gets or sets the default timeout.
/// </summary>
public TimeSpan? DefaultTimeout { get; set; }
/// <summary>
/// Gets or sets whether streaming is supported.
/// </summary>
public bool SupportsStreaming { get; set; }
/// <summary>
/// Gets or sets the claim requirements.
/// </summary>
public List<ClaimRequirement> RequiringClaims { get; set; } = [];
}

View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,82 @@
# StellaOps.Signals.Contracts
Shared signal contracts for cross-module signal communication in StellaOps.
## Purpose
This library provides the common contracts (interfaces and DTOs) for signal-based communication between StellaOps modules. It enables:
- **Concelier** to emit reachability and trust signals
- **Scanner** to emit entropy and unknown symbol signals
- **Policy Engine** to consume all signal types for risk scoring
- **Signals service** to aggregate and cache signals
- **Authority** to emit trust/provenance signals
## Signal Types
| Type | Producer | Description |
|------|----------|-------------|
| `Reachability` | Concelier, Scanner | Whether vulnerable code paths are reachable |
| `Entropy` | Scanner | Code complexity and risk metrics |
| `Exploitability` | Concelier | KEV status, EPSS scores, exploit availability |
| `Trust` | Authority, Scanner | Publisher reputation, provenance, signatures |
| `UnknownSymbol` | Scanner | Unresolved dependencies during analysis |
| `Custom` | Any | Extension point for module-specific signals |
## Usage
### Emitting Signals
```csharp
public class MySignalProducer
{
private readonly ISignalEmitter _emitter;
private readonly ISignalContext _context;
public async Task EmitReachabilityAsync(string purl, bool isReachable)
{
var signal = new ReachabilitySignal
{
Purl = purl,
IsReachable = isReachable,
Confidence = 0.95
};
var envelope = _context.CreateReachabilityEnvelope(purl, signal);
await _emitter.EmitAsync(envelope);
}
}
```
### Consuming Signals
```csharp
public class MySignalConsumer
{
private readonly ISignalConsumer _consumer;
public async Task ProcessSignalsAsync(CancellationToken ct)
{
await foreach (var signal in _consumer.ConsumeAsync(SignalType.Reachability, ct))
{
// Process signal
}
}
}
```
## Dependencies
- `Microsoft.Extensions.DependencyInjection.Abstractions` — DI registration helpers
## Implementation Notes
This library contains only contracts. Actual transport implementations are provided by:
- `StellaOps.Signals.Nats` — NATS JetStream transport
- `StellaOps.Signals.InMemory` — In-memory transport for testing
## Related
- [Signal Flow Architecture](../../docs/modules/signals/architecture.md)
- [Policy Engine Signals Integration](../../docs/modules/policy/signals.md)

View File

@@ -0,0 +1,38 @@
namespace StellaOps.Signals.Contracts;
/// <summary>
/// Interface for consuming signals from the signal bus.
/// Implemented by services that process signals.
/// </summary>
public interface ISignalConsumer
{
/// <summary>
/// Consumes signals from the signal bus as an async enumerable.
/// </summary>
/// <param name="filterType">Optional signal type to filter by.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of signal envelopes.</returns>
IAsyncEnumerable<SignalEnvelope> ConsumeAsync(
SignalType? filterType = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the latest signal for a given key.
/// </summary>
/// <param name="signalKey">The signal key to look up.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The signal envelope if found, null otherwise.</returns>
ValueTask<SignalEnvelope?> GetLatestAsync(
string signalKey,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all signals for a given PURL.
/// </summary>
/// <param name="purl">The package URL to look up.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Collection of signal envelopes for the PURL.</returns>
ValueTask<IReadOnlyList<SignalEnvelope>> GetByPurlAsync(
string purl,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,58 @@
namespace StellaOps.Signals.Contracts;
/// <summary>
/// Interface for signal context providing metadata and correlation.
/// Used by signal producers to add context to emitted signals.
/// </summary>
public interface ISignalContext
{
/// <summary>
/// Gets the current tenant ID.
/// </summary>
string? TenantId { get; }
/// <summary>
/// Gets the current correlation ID for distributed tracing.
/// </summary>
string? CorrelationId { get; }
/// <summary>
/// Gets the name of the service producing signals.
/// </summary>
string ServiceName { get; }
/// <summary>
/// Creates a signal envelope with context metadata.
/// </summary>
/// <typeparam name="T">Type of the signal value.</typeparam>
/// <param name="signalKey">Unique key for the signal.</param>
/// <param name="signalType">Type of the signal.</param>
/// <param name="value">The signal value.</param>
/// <returns>A fully populated signal envelope.</returns>
SignalEnvelope CreateEnvelope<T>(string signalKey, SignalType signalType, T value) where T : notnull;
/// <summary>
/// Creates a reachability signal envelope.
/// </summary>
SignalEnvelope CreateReachabilityEnvelope(string purl, ReachabilitySignal signal);
/// <summary>
/// Creates an entropy signal envelope.
/// </summary>
SignalEnvelope CreateEntropyEnvelope(string purl, EntropySignal signal);
/// <summary>
/// Creates an exploitability signal envelope.
/// </summary>
SignalEnvelope CreateExploitabilityEnvelope(string cveId, ExploitabilitySignal signal);
/// <summary>
/// Creates a trust signal envelope.
/// </summary>
SignalEnvelope CreateTrustEnvelope(string purl, TrustSignal signal);
/// <summary>
/// Creates an unknown symbol signal envelope.
/// </summary>
SignalEnvelope CreateUnknownSymbolEnvelope(string symbolId, UnknownSymbolSignal signal);
}

View File

@@ -0,0 +1,22 @@
namespace StellaOps.Signals.Contracts;
/// <summary>
/// Interface for emitting signals to the signal bus.
/// Implemented by services that produce signals.
/// </summary>
public interface ISignalEmitter
{
/// <summary>
/// Emits a single signal to the signal bus.
/// </summary>
/// <param name="signal">The signal envelope to emit.</param>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask EmitAsync(SignalEnvelope signal, CancellationToken cancellationToken = default);
/// <summary>
/// Emits a batch of signals to the signal bus.
/// </summary>
/// <param name="signals">The signal envelopes to emit.</param>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask EmitBatchAsync(IEnumerable<SignalEnvelope> signals, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,63 @@
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Signals.Contracts;
/// <summary>
/// Extension methods for registering signal services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds signal contracts services to the service collection.
/// This is a marker method for modules that want to declare dependency on Signals.Contracts.
/// Actual implementations are registered by specific transport libraries.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddSignalContracts(this IServiceCollection services)
{
// This method serves as a marker for dependency on Signals.Contracts.
// Actual implementations of ISignalEmitter, ISignalConsumer, and ISignalContext
// are provided by transport-specific libraries (e.g., NATS, in-memory, etc.)
return services;
}
/// <summary>
/// Adds a signal emitter implementation to the service collection.
/// </summary>
/// <typeparam name="TEmitter">The emitter implementation type.</typeparam>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddSignalEmitter<TEmitter>(this IServiceCollection services)
where TEmitter : class, ISignalEmitter
{
services.AddSingleton<ISignalEmitter, TEmitter>();
return services;
}
/// <summary>
/// Adds a signal consumer implementation to the service collection.
/// </summary>
/// <typeparam name="TConsumer">The consumer implementation type.</typeparam>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddSignalConsumer<TConsumer>(this IServiceCollection services)
where TConsumer : class, ISignalConsumer
{
services.AddSingleton<ISignalConsumer, TConsumer>();
return services;
}
/// <summary>
/// Adds a signal context implementation to the service collection.
/// </summary>
/// <typeparam name="TContext">The context implementation type.</typeparam>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddSignalContext<TContext>(this IServiceCollection services)
where TContext : class, ISignalContext
{
services.AddScoped<ISignalContext, TContext>();
return services;
}
}

View File

@@ -0,0 +1,64 @@
using System.Text.Json.Serialization;
namespace StellaOps.Signals.Contracts;
/// <summary>
/// Entropy signal from code complexity and risk analysis.
/// Measures complexity metrics that may indicate higher risk.
/// </summary>
public sealed record EntropySignal
{
/// <summary>
/// Package URL (PURL) of the analyzed package.
/// </summary>
[JsonPropertyName("purl")]
public required string Purl { get; init; }
/// <summary>
/// Overall entropy score (0.0-1.0, higher = more complex/risky).
/// </summary>
[JsonPropertyName("score")]
public required double Score { get; init; }
/// <summary>
/// Cyclomatic complexity metric.
/// </summary>
[JsonPropertyName("cyclomaticComplexity")]
public double? CyclomaticComplexity { get; init; }
/// <summary>
/// Ratio of opaque/binary content in the package.
/// </summary>
[JsonPropertyName("opaqueRatio")]
public double OpaqueRatio { get; init; }
/// <summary>
/// Number of suspicious patterns detected.
/// </summary>
[JsonPropertyName("suspiciousPatternCount")]
public int SuspiciousPatternCount { get; init; }
/// <summary>
/// Total lines of code analyzed.
/// </summary>
[JsonPropertyName("linesOfCode")]
public int? LinesOfCode { get; init; }
/// <summary>
/// Dependency depth in the tree.
/// </summary>
[JsonPropertyName("dependencyDepth")]
public int? DependencyDepth { get; init; }
/// <summary>
/// Penalty applied from entropy analysis.
/// </summary>
[JsonPropertyName("penalty")]
public double Penalty { get; init; }
/// <summary>
/// Top file contributing to entropy (if applicable).
/// </summary>
[JsonPropertyName("topOffendingFile")]
public string? TopOffendingFile { get; init; }
}

View File

@@ -0,0 +1,305 @@
using System.Text.Json.Serialization;
namespace StellaOps.Signals.Contracts.Evidence;
/// <summary>
/// Single call path from entry point to vulnerable symbol.
/// </summary>
public sealed record CallPath
{
/// <summary>
/// Unique path identifier.
/// </summary>
[JsonPropertyName("pathId")]
public required string PathId { get; init; }
/// <summary>
/// Call depth (number of hops).
/// </summary>
[JsonPropertyName("depth")]
public required int Depth { get; init; }
/// <summary>
/// Path confidence (0.0-1.0).
/// </summary>
[JsonPropertyName("confidence")]
public double Confidence { get; init; } = 1.0;
/// <summary>
/// How the path was discovered.
/// </summary>
[JsonPropertyName("pathType")]
public PathDiscoveryType PathType { get; init; } = PathDiscoveryType.Static;
/// <summary>
/// Entry point for this path.
/// </summary>
[JsonPropertyName("entryPoint")]
public EntryPoint? EntryPoint { get; init; }
/// <summary>
/// Ordered list of nodes in the path.
/// </summary>
[JsonPropertyName("nodes")]
public required IReadOnlyList<CallPathNode> Nodes { get; init; }
/// <summary>
/// Edges connecting the nodes.
/// </summary>
[JsonPropertyName("edges")]
public IReadOnlyList<CallEdge> Edges { get; init; } = [];
}
/// <summary>
/// How a call path was discovered.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum PathDiscoveryType
{
/// <summary>Discovered through static analysis.</summary>
Static,
/// <summary>Observed at runtime.</summary>
Dynamic,
/// <summary>Inferred from other evidence.</summary>
Inferred
}
/// <summary>
/// Node in a call path.
/// </summary>
public sealed record CallPathNode
{
/// <summary>
/// Unique node identifier.
/// </summary>
[JsonPropertyName("nodeId")]
public required string NodeId { get; init; }
/// <summary>
/// Function/method name.
/// </summary>
[JsonPropertyName("functionName")]
public string? FunctionName { get; init; }
/// <summary>
/// Package URL of containing package.
/// </summary>
[JsonPropertyName("purl")]
public string? Purl { get; init; }
/// <summary>
/// Immutable code identity anchor.
/// </summary>
[JsonPropertyName("codeAnchor")]
public CodeAnchor? CodeAnchor { get; init; }
/// <summary>
/// Whether this is the vulnerable function.
/// </summary>
[JsonPropertyName("isVulnerable")]
public bool IsVulnerable { get; init; }
/// <summary>
/// Whether this is an entry point.
/// </summary>
[JsonPropertyName("isEntryPoint")]
public bool IsEntryPoint { get; init; }
/// <summary>
/// Number of runtime observations at this node.
/// </summary>
[JsonPropertyName("runtimeHitCount")]
public int RuntimeHitCount { get; init; }
}
/// <summary>
/// Edge between call path nodes.
/// </summary>
public sealed record CallEdge
{
/// <summary>
/// Source node ID.
/// </summary>
[JsonPropertyName("from")]
public required string From { get; init; }
/// <summary>
/// Target node ID.
/// </summary>
[JsonPropertyName("to")]
public required string To { get; init; }
/// <summary>
/// Call edge type.
/// </summary>
[JsonPropertyName("kind")]
public required CallEdgeKind Kind { get; init; }
/// <summary>
/// Edge confidence (0.0-1.0).
/// </summary>
[JsonPropertyName("confidence")]
public double Confidence { get; init; } = 1.0;
/// <summary>
/// Evidence sources (e.g., reloc:.plt.got, bb-target:0x40f0ff).
/// </summary>
[JsonPropertyName("evidence")]
public IReadOnlyList<string> Evidence { get; init; } = [];
/// <summary>
/// Reason for edge.
/// </summary>
[JsonPropertyName("reason")]
public EdgeReason Reason { get; init; } = EdgeReason.Direct;
/// <summary>
/// Whether edge was revoked/disproven.
/// </summary>
[JsonPropertyName("revoked")]
public bool Revoked { get; init; }
}
/// <summary>
/// Type of call edge.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum CallEdgeKind
{
/// <summary>Direct static call.</summary>
Static,
/// <summary>Virtual/polymorphic call.</summary>
Virtual,
/// <summary>Dynamic dispatch.</summary>
Dynamic,
/// <summary>Import/external call.</summary>
Import,
/// <summary>Callback/function pointer.</summary>
Callback,
/// <summary>Reflection-based call.</summary>
Reflection
}
/// <summary>
/// Reason for edge existence.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum EdgeReason
{
/// <summary>Direct call observed.</summary>
Direct,
/// <summary>Indirect call inferred.</summary>
Indirect,
/// <summary>Observed at runtime.</summary>
RuntimeObserved,
/// <summary>Inferred from other signals.</summary>
Inferred,
/// <summary>Contested/uncertain.</summary>
Contested
}
/// <summary>
/// Application entry point.
/// </summary>
public sealed record EntryPoint
{
/// <summary>
/// Entry point identifier.
/// </summary>
[JsonPropertyName("entryPointId")]
public required string EntryPointId { get; init; }
/// <summary>
/// Entry point type.
/// </summary>
[JsonPropertyName("type")]
public required EntryPointType Type { get; init; }
/// <summary>
/// Entry point name.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }
/// <summary>
/// HTTP route pattern if applicable.
/// </summary>
[JsonPropertyName("route")]
public string? Route { get; init; }
/// <summary>
/// Code identity anchor.
/// </summary>
[JsonPropertyName("codeAnchor")]
public CodeAnchor? CodeAnchor { get; init; }
/// <summary>
/// Execution phase (load, init, runtime).
/// </summary>
[JsonPropertyName("phase")]
public ExecutionPhase Phase { get; init; } = ExecutionPhase.Runtime;
}
/// <summary>
/// Type of application entry point.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum EntryPointType
{
/// <summary>Main function.</summary>
Main,
/// <summary>HTTP request handler.</summary>
HttpHandler,
/// <summary>Event handler.</summary>
EventHandler,
/// <summary>Scheduled task.</summary>
ScheduledTask,
/// <summary>Init array constructor.</summary>
InitArray,
/// <summary>Class constructor.</summary>
Constructor,
/// <summary>CLI command handler.</summary>
CliCommand,
/// <summary>gRPC method.</summary>
GrpcMethod,
/// <summary>Message queue handler.</summary>
MessageHandler,
/// <summary>Other entry point.</summary>
Other
}
/// <summary>
/// Execution phase.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ExecutionPhase
{
/// <summary>Load-time execution.</summary>
Load,
/// <summary>Initialization phase.</summary>
Init,
/// <summary>Runtime execution.</summary>
Runtime
}

View File

@@ -0,0 +1,167 @@
using System.Text.Json.Serialization;
namespace StellaOps.Signals.Contracts.Evidence;
/// <summary>
/// Immutable code identity anchor (code_id) for function-level evidence.
/// Anchors every vulnerability finding to an immutable {artifact_digest, code_id} tuple.
/// </summary>
public sealed record CodeAnchor
{
/// <summary>
/// Binary/source format.
/// </summary>
[JsonPropertyName("format")]
public required CodeFormat Format { get; init; }
/// <summary>
/// Artifact content digest (sha256:...).
/// </summary>
[JsonPropertyName("artifactDigest")]
public required string ArtifactDigest { get; init; }
/// <summary>
/// Build ID (.note.gnu.build-id or equivalent).
/// </summary>
[JsonPropertyName("buildId")]
public string? BuildId { get; init; }
/// <summary>
/// Section name (e.g., .text, .init).
/// </summary>
[JsonPropertyName("section")]
public string? Section { get; init; }
/// <summary>
/// Start address (hex, e.g., 0x4012a0).
/// </summary>
[JsonPropertyName("startAddress")]
public string? StartAddress { get; init; }
/// <summary>
/// Code block length in bytes.
/// </summary>
[JsonPropertyName("length")]
public int? Length { get; init; }
/// <summary>
/// Optional hash of code bytes (blake3:...).
/// </summary>
[JsonPropertyName("codeBlockHash")]
public string? CodeBlockHash { get; init; }
/// <summary>
/// Symbol information.
/// </summary>
[JsonPropertyName("symbol")]
public SymbolInfo? Symbol { get; init; }
}
/// <summary>
/// Binary/source format for code anchors.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum CodeFormat
{
/// <summary>ELF binary format.</summary>
ELF,
/// <summary>Windows PE format.</summary>
PE,
/// <summary>macOS Mach-O format.</summary>
MachO,
/// <summary>Java bytecode.</summary>
JVM,
/// <summary>.NET Common Language Runtime.</summary>
CLR,
/// <summary>WebAssembly.</summary>
WASM,
/// <summary>Source code.</summary>
SOURCE
}
/// <summary>
/// Symbol information with demangling metadata.
/// </summary>
public sealed record SymbolInfo
{
/// <summary>
/// Mangled symbol name.
/// </summary>
[JsonPropertyName("mangled")]
public string? Mangled { get; init; }
/// <summary>
/// Demangled/human-readable name.
/// </summary>
[JsonPropertyName("demangled")]
public string? Demangled { get; init; }
/// <summary>
/// Symbol source (DWARF, PDB, SYM, STABS, EXPORTS, none).
/// </summary>
[JsonPropertyName("source")]
public SymbolSource Source { get; init; } = SymbolSource.None;
/// <summary>
/// Symbol resolution confidence (0.0-1.0).
/// </summary>
[JsonPropertyName("confidence")]
public double Confidence { get; init; } = 1.0;
/// <summary>
/// Inferred source language.
/// </summary>
[JsonPropertyName("language")]
public SourceLanguage? Language { get; init; }
}
/// <summary>
/// Symbol debug info source.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum SymbolSource
{
/// <summary>No symbol info available.</summary>
None,
/// <summary>DWARF debug info.</summary>
DWARF,
/// <summary>Windows PDB file.</summary>
PDB,
/// <summary>Symbol table.</summary>
SYM,
/// <summary>STABS format.</summary>
STABS,
/// <summary>Export table.</summary>
EXPORTS
}
/// <summary>
/// Inferred source language.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum SourceLanguage
{
Unknown,
C,
Cpp,
Rust,
Go,
Java,
CSharp,
Python,
JavaScript,
TypeScript,
Ruby,
PHP
}

View File

@@ -0,0 +1,300 @@
using System.Text.Json.Serialization;
namespace StellaOps.Signals.Contracts.Evidence;
/// <summary>
/// Complete reachability evidence chain with call paths, runtime hits, and attestations.
/// Used by CLI 'stella graph explain' and UI explain drawer.
/// </summary>
public sealed record ReachabilityEvidenceChain
{
/// <summary>
/// Unique evidence chain identifier.
/// </summary>
[JsonPropertyName("evidenceId")]
public required Guid EvidenceId { get; init; }
/// <summary>
/// Type of evidence.
/// </summary>
[JsonPropertyName("evidenceType")]
public required EvidenceType EvidenceType { get; init; }
/// <summary>
/// Static call graph analysis evidence.
/// </summary>
[JsonPropertyName("graphEvidence")]
public GraphEvidence? GraphEvidence { get; init; }
/// <summary>
/// Runtime observation evidence.
/// </summary>
[JsonPropertyName("runtimeEvidence")]
public RuntimeEvidence? RuntimeEvidence { get; init; }
/// <summary>
/// Immutable code identity anchors.
/// </summary>
[JsonPropertyName("codeAnchors")]
public IReadOnlyList<CodeAnchor> CodeAnchors { get; init; } = [];
/// <summary>
/// Application entry points analyzed.
/// </summary>
[JsonPropertyName("entryPoints")]
public IReadOnlyList<EntryPoint> EntryPoints { get; init; } = [];
/// <summary>
/// Unresolved symbols/edges for uncertainty tracking.
/// </summary>
[JsonPropertyName("unknowns")]
public IReadOnlyList<UnknownRecord> Unknowns { get; init; } = [];
}
/// <summary>
/// Type of evidence.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum EvidenceType
{
/// <summary>Static call graph only.</summary>
CallGraph,
/// <summary>Runtime trace only.</summary>
RuntimeTrace,
/// <summary>Combination of static and runtime.</summary>
Hybrid,
/// <summary>Manual assessment.</summary>
Manual
}
/// <summary>
/// Static call graph analysis evidence.
/// </summary>
public sealed record GraphEvidence
{
/// <summary>
/// BLAKE3 hash of canonical graph.
/// </summary>
[JsonPropertyName("graphHash")]
public required string GraphHash { get; init; }
/// <summary>
/// CAS URI to graph (cas://reachability/graphs/{hash}).
/// </summary>
[JsonPropertyName("graphCasUri")]
public string? GraphCasUri { get; init; }
/// <summary>
/// Graph schema version.
/// </summary>
[JsonPropertyName("graphKind")]
public GraphKind GraphKind { get; init; } = GraphKind.RichGraphV1;
/// <summary>
/// Number of nodes in the graph.
/// </summary>
[JsonPropertyName("nodeCount")]
public int NodeCount { get; init; }
/// <summary>
/// Number of edges in the graph.
/// </summary>
[JsonPropertyName("edgeCount")]
public int EdgeCount { get; init; }
/// <summary>
/// Percentage of code covered by analysis.
/// </summary>
[JsonPropertyName("coveragePercent")]
public double CoveragePercent { get; init; }
/// <summary>
/// Analyzer information.
/// </summary>
[JsonPropertyName("analyzer")]
public AnalyzerInfo? Analyzer { get; init; }
}
/// <summary>
/// Graph schema version.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum GraphKind
{
/// <summary>Rich graph v1 schema.</summary>
[JsonPropertyName("richgraph-v1")]
RichGraphV1,
/// <summary>Simple call graph v1.</summary>
[JsonPropertyName("simplecg-v1")]
SimpleCgV1,
/// <summary>Entry trace v1.</summary>
[JsonPropertyName("entry-trace-v1")]
EntryTraceV1
}
/// <summary>
/// Runtime observation evidence.
/// </summary>
public sealed record RuntimeEvidence
{
/// <summary>
/// BLAKE3 hash of trace data.
/// </summary>
[JsonPropertyName("traceHash")]
public string? TraceHash { get; init; }
/// <summary>
/// CAS URI to trace data.
/// </summary>
[JsonPropertyName("traceCasUri")]
public string? TraceCasUri { get; init; }
/// <summary>
/// Observation window start.
/// </summary>
[JsonPropertyName("observationStart")]
public DateTimeOffset? ObservationStart { get; init; }
/// <summary>
/// Observation window end.
/// </summary>
[JsonPropertyName("observationEnd")]
public DateTimeOffset? ObservationEnd { get; init; }
/// <summary>
/// Total hit count.
/// </summary>
[JsonPropertyName("hitCount")]
public int HitCount { get; init; }
/// <summary>
/// Number of unique functions hit.
/// </summary>
[JsonPropertyName("uniqueFunctionsHit")]
public int UniqueFunctionsHit { get; init; }
/// <summary>
/// Runtime probe type.
/// </summary>
[JsonPropertyName("probeType")]
public ProbeType ProbeType { get; init; } = ProbeType.Instrumentation;
}
/// <summary>
/// Runtime probe type.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ProbeType
{
/// <summary>.NET EventPipe.</summary>
EventPipe,
/// <summary>Java Flight Recorder.</summary>
JFR,
/// <summary>Linux eBPF.</summary>
eBPF,
/// <summary>Code instrumentation.</summary>
Instrumentation
}
/// <summary>
/// Information about the analyzer that produced evidence.
/// </summary>
public sealed record AnalyzerInfo
{
/// <summary>
/// Analyzer name.
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// Analyzer version.
/// </summary>
[JsonPropertyName("version")]
public required string Version { get; init; }
/// <summary>
/// Analyzer binary/image digest.
/// </summary>
[JsonPropertyName("digest")]
public string? Digest { get; init; }
/// <summary>
/// Hash of analyzer configuration.
/// </summary>
[JsonPropertyName("configHash")]
public string? ConfigHash { get; init; }
}
/// <summary>
/// Unresolved symbol or edge for uncertainty tracking.
/// </summary>
public sealed record UnknownRecord
{
/// <summary>
/// Type of unknown.
/// </summary>
[JsonPropertyName("unknownType")]
public required UnknownType UnknownType { get; init; }
/// <summary>
/// Identifier for the unknown.
/// </summary>
[JsonPropertyName("identifier")]
public required string Identifier { get; init; }
/// <summary>
/// Context where unknown was encountered.
/// </summary>
[JsonPropertyName("context")]
public string? Context { get; init; }
/// <summary>
/// Uncertainty tier (U1, U2, U3).
/// </summary>
[JsonPropertyName("uncertaintyLevel")]
public UncertaintyLevel UncertaintyLevel { get; init; } = UncertaintyLevel.U1;
}
/// <summary>
/// Type of unknown element.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum UnknownType
{
/// <summary>Unresolved symbol.</summary>
Symbol,
/// <summary>Unresolved edge.</summary>
Edge,
/// <summary>Unresolved import.</summary>
Import,
/// <summary>Unresolved reference.</summary>
Reference
}
/// <summary>
/// Uncertainty tier.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum UncertaintyLevel
{
/// <summary>Low uncertainty.</summary>
U1,
/// <summary>Medium uncertainty.</summary>
U2,
/// <summary>High uncertainty.</summary>
U3
}

View File

@@ -0,0 +1,319 @@
using System.Text.Json.Serialization;
namespace StellaOps.Signals.Contracts.Evidence;
/// <summary>
/// Request to explain reachability for a vulnerability/package.
/// Used by CLI 'stella graph explain' command.
/// </summary>
public sealed record ReachabilityExplainRequest
{
/// <summary>
/// Unique request identifier for idempotency.
/// </summary>
[JsonPropertyName("requestId")]
public required Guid RequestId { get; init; }
/// <summary>
/// Correlation ID for tracing.
/// </summary>
[JsonPropertyName("correlationId")]
public string? CorrelationId { get; init; }
/// <summary>
/// Tenant scope.
/// </summary>
[JsonPropertyName("tenantId")]
public required string TenantId { get; init; }
/// <summary>
/// Subject being analyzed for reachability.
/// </summary>
[JsonPropertyName("subject")]
public required ReachabilitySubject Subject { get; init; }
/// <summary>
/// Options for the explain request.
/// </summary>
[JsonPropertyName("options")]
public ExplainOptions? Options { get; init; }
}
/// <summary>
/// Subject being analyzed for reachability.
/// </summary>
public sealed record ReachabilitySubject
{
/// <summary>
/// Package URL of the component.
/// </summary>
[JsonPropertyName("purl")]
public required string Purl { get; init; }
/// <summary>
/// CVE identifier if analyzing vulnerability.
/// </summary>
[JsonPropertyName("cve")]
public string? Cve { get; init; }
/// <summary>
/// Vulnerable function/method name.
/// </summary>
[JsonPropertyName("vulnerableSymbol")]
public string? VulnerableSymbol { get; init; }
/// <summary>
/// Container image digest.
/// </summary>
[JsonPropertyName("imageDigest")]
public string? ImageDigest { get; init; }
/// <summary>
/// Scan job identifier.
/// </summary>
[JsonPropertyName("scanId")]
public string? ScanId { get; init; }
/// <summary>
/// Artifact content digest.
/// </summary>
[JsonPropertyName("artifactDigest")]
public string? ArtifactDigest { get; init; }
}
/// <summary>
/// Options for explain request.
/// </summary>
public sealed record ExplainOptions
{
/// <summary>
/// Maximum number of paths to return (1-100, default 10).
/// </summary>
[JsonPropertyName("maxPaths")]
public int MaxPaths { get; init; } = 10;
/// <summary>
/// Maximum call depth to analyze (1-50, default 20).
/// </summary>
[JsonPropertyName("maxDepth")]
public int MaxDepth { get; init; } = 20;
/// <summary>
/// Whether to include runtime hits.
/// </summary>
[JsonPropertyName("includeRuntimeHits")]
public bool IncludeRuntimeHits { get; init; } = true;
/// <summary>
/// Whether to include unresolved symbols.
/// </summary>
[JsonPropertyName("includeUnknowns")]
public bool IncludeUnknowns { get; init; }
/// <summary>
/// Only return attested evidence.
/// </summary>
[JsonPropertyName("requireAttestation")]
public bool RequireAttestation { get; init; }
/// <summary>
/// Output format.
/// </summary>
[JsonPropertyName("format")]
public ExplainOutputFormat Format { get; init; } = ExplainOutputFormat.Json;
}
/// <summary>
/// Output format for explain results.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ExplainOutputFormat
{
/// <summary>JSON format.</summary>
Json,
/// <summary>SARIF format.</summary>
Sarif,
/// <summary>Graphviz DOT format.</summary>
Graphviz
}
/// <summary>
/// Response containing reachability explanation with call paths and evidence.
/// </summary>
public sealed record ReachabilityExplainResponse
{
/// <summary>
/// Original request identifier.
/// </summary>
[JsonPropertyName("requestId")]
public required Guid RequestId { get; init; }
/// <summary>
/// Response status.
/// </summary>
[JsonPropertyName("status")]
public required ExplainStatus Status { get; init; }
/// <summary>
/// Reachability state determination.
/// </summary>
[JsonPropertyName("reachabilityState")]
public ReachabilityState? ReachabilityState { get; init; }
/// <summary>
/// Evidence chain supporting the determination.
/// </summary>
[JsonPropertyName("evidenceChain")]
public ReachabilityEvidenceChain? EvidenceChain { get; init; }
/// <summary>
/// Ordered call paths from entry points to vulnerable symbol.
/// </summary>
[JsonPropertyName("callPaths")]
public IReadOnlyList<CallPath> CallPaths { get; init; } = [];
/// <summary>
/// Runtime observations confirming reachability.
/// </summary>
[JsonPropertyName("runtimeHits")]
public IReadOnlyList<RuntimeHit> RuntimeHits { get; init; } = [];
/// <summary>
/// DSSE attestations backing the evidence.
/// </summary>
[JsonPropertyName("attestations")]
public IReadOnlyList<AttestationReference> Attestations { get; init; } = [];
/// <summary>
/// Analysis metadata.
/// </summary>
[JsonPropertyName("analysisMetadata")]
public AnalysisMetadata? AnalysisMetadata { get; init; }
/// <summary>
/// Error details if status is ERROR.
/// </summary>
[JsonPropertyName("error")]
public ReachabilityError? Error { get; init; }
}
/// <summary>
/// Explain response status.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ExplainStatus
{
/// <summary>Full explanation available.</summary>
Success,
/// <summary>Partial explanation (some data missing).</summary>
Partial,
/// <summary>Subject not found.</summary>
NotFound,
/// <summary>Error during explanation.</summary>
Error
}
/// <summary>
/// Analysis metadata.
/// </summary>
public sealed record AnalysisMetadata
{
/// <summary>
/// Analysis identifier.
/// </summary>
[JsonPropertyName("analysisId")]
public Guid? AnalysisId { get; init; }
/// <summary>
/// When analysis started.
/// </summary>
[JsonPropertyName("startedAt")]
public DateTimeOffset? StartedAt { get; init; }
/// <summary>
/// When analysis completed.
/// </summary>
[JsonPropertyName("completedAt")]
public DateTimeOffset? CompletedAt { get; init; }
/// <summary>
/// Duration in milliseconds.
/// </summary>
[JsonPropertyName("durationMs")]
public long? DurationMs { get; init; }
/// <summary>
/// Analyzer information.
/// </summary>
[JsonPropertyName("analyzer")]
public AnalyzerInfo? Analyzer { get; init; }
/// <summary>
/// Whether analysis can be replayed.
/// </summary>
[JsonPropertyName("replayable")]
public bool Replayable { get; init; }
/// <summary>
/// URI to replay manifest.
/// </summary>
[JsonPropertyName("replayManifestUri")]
public string? ReplayManifestUri { get; init; }
}
/// <summary>
/// Reachability error details.
/// </summary>
public sealed record ReachabilityError
{
/// <summary>
/// Error code.
/// </summary>
[JsonPropertyName("code")]
public required ReachabilityErrorCode Code { get; init; }
/// <summary>
/// Human-readable error message.
/// </summary>
[JsonPropertyName("message")]
public required string Message { get; init; }
/// <summary>
/// Additional error details.
/// </summary>
[JsonPropertyName("details")]
public IDictionary<string, object>? Details { get; init; }
}
/// <summary>
/// Reachability error codes.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ReachabilityErrorCode
{
/// <summary>Subject not found.</summary>
SubjectNotFound,
/// <summary>Call graph not available.</summary>
GraphNotAvailable,
/// <summary>Analysis failed.</summary>
AnalysisFailed,
/// <summary>Attestation invalid.</summary>
AttestationInvalid,
/// <summary>Transparency log unavailable.</summary>
TransparencyUnavailable,
/// <summary>Operation timed out.</summary>
Timeout,
/// <summary>Internal error.</summary>
InternalError
}

View File

@@ -0,0 +1,95 @@
using System.Text.Json.Serialization;
namespace StellaOps.Signals.Contracts.Evidence;
/// <summary>
/// Lattice-based reachability state determination.
/// </summary>
public sealed record ReachabilityState
{
/// <summary>
/// Reachability lattice state.
/// </summary>
[JsonPropertyName("state")]
public required ReachabilityLatticeState State { get; init; }
/// <summary>
/// Confidence score (0.0-1.0).
/// </summary>
[JsonPropertyName("confidence")]
public double Confidence { get; init; } = 1.0;
/// <summary>
/// Analysis method used.
/// </summary>
[JsonPropertyName("analysisMethod")]
public AnalysisMethod AnalysisMethod { get; init; } = AnalysisMethod.Static;
/// <summary>
/// Number of paths reaching vulnerable symbol.
/// </summary>
[JsonPropertyName("callPathCount")]
public int CallPathCount { get; init; }
/// <summary>
/// Minimum call depth from entry point.
/// </summary>
[JsonPropertyName("minCallDepth")]
public int? MinCallDepth { get; init; }
/// <summary>
/// Maximum call depth from entry point.
/// </summary>
[JsonPropertyName("maxCallDepth")]
public int? MaxCallDepth { get; init; }
/// <summary>
/// Number of runtime observations.
/// </summary>
[JsonPropertyName("runtimeHitCount")]
public int RuntimeHitCount { get; init; }
}
/// <summary>
/// Reachability lattice states.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ReachabilityLatticeState
{
/// <summary>Vulnerable symbol is reachable from entry points.</summary>
Reachable,
/// <summary>Vulnerable symbol is not reachable from any entry point.</summary>
Unreachable,
/// <summary>Vulnerable symbol may be reachable but analysis is incomplete.</summary>
PotentiallyReachable,
/// <summary>Reachability cannot be determined.</summary>
Unknown,
/// <summary>Awaiting manual review.</summary>
UnderReview
}
/// <summary>
/// Analysis method used to determine reachability.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum AnalysisMethod
{
/// <summary>Static analysis only.</summary>
Static,
/// <summary>Dynamic/runtime analysis.</summary>
Dynamic,
/// <summary>Combination of static and dynamic.</summary>
Hybrid,
/// <summary>Runtime observation only.</summary>
Runtime,
/// <summary>Inferred from other signals.</summary>
Inferred
}

View File

@@ -0,0 +1,165 @@
using System.Text.Json.Serialization;
namespace StellaOps.Signals.Contracts.Evidence;
/// <summary>
/// Runtime observation of function execution.
/// </summary>
public sealed record RuntimeHit
{
/// <summary>
/// Symbol identifier.
/// </summary>
[JsonPropertyName("symbolId")]
public required string SymbolId { get; init; }
/// <summary>
/// Code identity anchor.
/// </summary>
[JsonPropertyName("codeAnchor")]
public CodeAnchor? CodeAnchor { get; init; }
/// <summary>
/// Number of times function was hit.
/// </summary>
[JsonPropertyName("hitCount")]
public required int HitCount { get; init; }
/// <summary>
/// When hit was observed.
/// </summary>
[JsonPropertyName("observedAt")]
public required DateTimeOffset ObservedAt { get; init; }
/// <summary>
/// Runtime loader base address (hex).
/// </summary>
[JsonPropertyName("loaderBase")]
public string? LoaderBase { get; init; }
/// <summary>
/// Process identifier.
/// </summary>
[JsonPropertyName("processId")]
public string? ProcessId { get; init; }
/// <summary>
/// Container identifier.
/// </summary>
[JsonPropertyName("containerId")]
public string? ContainerId { get; init; }
/// <summary>
/// CAS URI to raw trace data.
/// </summary>
[JsonPropertyName("casUri")]
public string? CasUri { get; init; }
}
/// <summary>
/// Reference to DSSE attestation.
/// </summary>
public sealed record AttestationReference
{
/// <summary>
/// in-toto predicate type URI.
/// </summary>
[JsonPropertyName("predicateType")]
public required string PredicateType { get; init; }
/// <summary>
/// Attestation envelope digest.
/// </summary>
[JsonPropertyName("digest")]
public required string Digest { get; init; }
/// <summary>
/// CAS URI to attestation.
/// </summary>
[JsonPropertyName("casUri")]
public string? CasUri { get; init; }
/// <summary>
/// Signer key identifier.
/// </summary>
[JsonPropertyName("signerId")]
public string? SignerId { get; init; }
/// <summary>
/// When attestation was signed.
/// </summary>
[JsonPropertyName("signedAt")]
public DateTimeOffset? SignedAt { get; init; }
/// <summary>
/// Rekor transparency log index.
/// </summary>
[JsonPropertyName("rekorLogIndex")]
public long? RekorLogIndex { get; init; }
}
/// <summary>
/// Reference to call graph artifact.
/// </summary>
public sealed record CallGraphReference
{
/// <summary>
/// BLAKE3 hash of the graph.
/// </summary>
[JsonPropertyName("graphHash")]
public required string GraphHash { get; init; }
/// <summary>
/// CAS URI to graph data.
/// </summary>
[JsonPropertyName("casUri")]
public string? CasUri { get; init; }
/// <summary>
/// URI to DSSE envelope.
/// </summary>
[JsonPropertyName("dsseUri")]
public string? DsseUri { get; init; }
/// <summary>
/// Graph schema version.
/// </summary>
[JsonPropertyName("kind")]
public GraphKind Kind { get; init; } = GraphKind.RichGraphV1;
}
/// <summary>
/// Reference to transparency log entry.
/// </summary>
public sealed record TransparencyLogReference
{
/// <summary>
/// Log identifier.
/// </summary>
[JsonPropertyName("logId")]
public string? LogId { get; init; }
/// <summary>
/// Entry index in the log.
/// </summary>
[JsonPropertyName("logIndex")]
public long? LogIndex { get; init; }
/// <summary>
/// When entry was integrated.
/// </summary>
[JsonPropertyName("integratedTime")]
public DateTimeOffset? IntegratedTime { get; init; }
/// <summary>
/// URI to the log entry.
/// </summary>
[JsonPropertyName("entryUri")]
public string? EntryUri { get; init; }
/// <summary>
/// Base64-encoded inclusion proof.
/// </summary>
[JsonPropertyName("inclusionProof")]
public string? InclusionProof { get; init; }
}

View File

@@ -0,0 +1,64 @@
using System.Text.Json.Serialization;
namespace StellaOps.Signals.Contracts;
/// <summary>
/// Exploitability signal from KEV and exploit databases.
/// Indicates known exploitation status and availability.
/// </summary>
public sealed record ExploitabilitySignal
{
/// <summary>
/// CVE identifier for the vulnerability.
/// </summary>
[JsonPropertyName("cveId")]
public required string CveId { get; init; }
/// <summary>
/// Whether this vulnerability is in CISA KEV (Known Exploited Vulnerabilities).
/// </summary>
[JsonPropertyName("inKev")]
public bool InKev { get; init; }
/// <summary>
/// Date added to KEV catalog (if applicable).
/// </summary>
[JsonPropertyName("kevAddedDate")]
public DateTimeOffset? KevAddedDate { get; init; }
/// <summary>
/// Whether a public exploit is available.
/// </summary>
[JsonPropertyName("hasPublicExploit")]
public bool HasPublicExploit { get; init; }
/// <summary>
/// Exploit maturity level (e.g., "poc", "functional", "weaponized").
/// </summary>
[JsonPropertyName("exploitMaturity")]
public string? ExploitMaturity { get; init; }
/// <summary>
/// EPSS (Exploit Prediction Scoring System) score (0.0-1.0).
/// </summary>
[JsonPropertyName("epssScore")]
public double? EpssScore { get; init; }
/// <summary>
/// EPSS percentile ranking.
/// </summary>
[JsonPropertyName("epssPercentile")]
public double? EpssPercentile { get; init; }
/// <summary>
/// References to exploit sources.
/// </summary>
[JsonPropertyName("exploitReferences")]
public IReadOnlyList<string> ExploitReferences { get; init; } = [];
/// <summary>
/// Last date exploit information was updated.
/// </summary>
[JsonPropertyName("lastUpdated")]
public DateTimeOffset? LastUpdated { get; init; }
}

View File

@@ -0,0 +1,58 @@
using System.Text.Json.Serialization;
namespace StellaOps.Signals.Contracts;
/// <summary>
/// Reachability signal from callgraph analysis.
/// Indicates whether a vulnerable code path is reachable from application entry points.
/// </summary>
public sealed record ReachabilitySignal
{
/// <summary>
/// Package URL (PURL) of the analyzed package.
/// </summary>
[JsonPropertyName("purl")]
public required string Purl { get; init; }
/// <summary>
/// Whether the vulnerable symbol is reachable.
/// </summary>
[JsonPropertyName("isReachable")]
public required bool IsReachable { get; init; }
/// <summary>
/// Confidence score (0.0-1.0) of the reachability determination.
/// </summary>
[JsonPropertyName("confidence")]
public double Confidence { get; init; } = 1.0;
/// <summary>
/// Number of call paths that reach the vulnerable symbol.
/// </summary>
[JsonPropertyName("callPathCount")]
public int CallPathCount { get; init; }
/// <summary>
/// Minimum call depth to reach the vulnerable symbol.
/// </summary>
[JsonPropertyName("minCallDepth")]
public int? MinCallDepth { get; init; }
/// <summary>
/// List of entry points that can reach the vulnerable symbol.
/// </summary>
[JsonPropertyName("reachingEntryPoints")]
public IReadOnlyList<string> ReachingEntryPoints { get; init; } = [];
/// <summary>
/// Analysis method used (e.g., "static", "dynamic", "hybrid").
/// </summary>
[JsonPropertyName("analysisMethod")]
public string AnalysisMethod { get; init; } = "static";
/// <summary>
/// The vulnerable symbol identifier.
/// </summary>
[JsonPropertyName("vulnerableSymbol")]
public string? VulnerableSymbol { get; init; }
}

View File

@@ -0,0 +1,64 @@
using System.Text.Json.Serialization;
namespace StellaOps.Signals.Contracts;
/// <summary>
/// Universal envelope for carrying signal data across modules.
/// Provides a common structure for all signal types with metadata.
/// </summary>
public sealed record SignalEnvelope
{
/// <summary>
/// Unique key identifying this signal (e.g., "pkg:npm/lodash@4.17.21:reachability").
/// </summary>
[JsonPropertyName("signalKey")]
public required string SignalKey { get; init; }
/// <summary>
/// Type of signal contained in this envelope.
/// </summary>
[JsonPropertyName("signalType")]
public required SignalType SignalType { get; init; }
/// <summary>
/// The signal payload (type depends on SignalType).
/// </summary>
[JsonPropertyName("value")]
public required object Value { get; init; }
/// <summary>
/// Timestamp when this signal was computed.
/// </summary>
[JsonPropertyName("computedAt")]
public required DateTimeOffset ComputedAt { get; init; }
/// <summary>
/// Name of the service that produced this signal.
/// </summary>
[JsonPropertyName("sourceService")]
public required string SourceService { get; init; }
/// <summary>
/// Optional tenant ID for multi-tenant isolation.
/// </summary>
[JsonPropertyName("tenantId")]
public string? TenantId { get; init; }
/// <summary>
/// Optional correlation ID for distributed tracing.
/// </summary>
[JsonPropertyName("correlationId")]
public string? CorrelationId { get; init; }
/// <summary>
/// Optional content digest for provenance verification.
/// </summary>
[JsonPropertyName("provenanceDigest")]
public string? ProvenanceDigest { get; init; }
/// <summary>
/// Optional schema version for backward compatibility.
/// </summary>
[JsonPropertyName("schemaVersion")]
public string SchemaVersion { get; init; } = "1.0";
}

View File

@@ -0,0 +1,42 @@
namespace StellaOps.Signals.Contracts;
/// <summary>
/// Enumeration of supported signal types in the StellaOps platform.
/// </summary>
public enum SignalType
{
/// <summary>
/// Reachability signal from callgraph analysis.
/// Indicates whether a vulnerable code path is reachable.
/// </summary>
Reachability,
/// <summary>
/// Entropy signal from code complexity analysis.
/// Measures code complexity and associated risk metrics.
/// </summary>
Entropy,
/// <summary>
/// Exploitability signal from KEV/exploit databases.
/// Indicates known exploitation status and availability.
/// </summary>
Exploitability,
/// <summary>
/// Trust signal from reputation and chain-of-custody scoring.
/// Measures publisher/maintainer trustworthiness.
/// </summary>
Trust,
/// <summary>
/// Unknown symbol signal for unresolved dependencies.
/// Flags symbols that could not be resolved during analysis.
/// </summary>
UnknownSymbol,
/// <summary>
/// Custom signal type for extensibility.
/// </summary>
Custom
}

View File

@@ -0,0 +1,76 @@
using System.Text.Json.Serialization;
namespace StellaOps.Signals.Contracts;
/// <summary>
/// Trust signal from reputation and chain-of-custody scoring.
/// Measures publisher/maintainer trustworthiness.
/// </summary>
public sealed record TrustSignal
{
/// <summary>
/// Package URL (PURL) of the analyzed package.
/// </summary>
[JsonPropertyName("purl")]
public required string Purl { get; init; }
/// <summary>
/// Overall trust score (0.0-1.0, higher = more trustworthy).
/// </summary>
[JsonPropertyName("score")]
public required double Score { get; init; }
/// <summary>
/// Publisher/maintainer reputation score.
/// </summary>
[JsonPropertyName("publisherReputation")]
public double? PublisherReputation { get; init; }
/// <summary>
/// Whether the package has verified provenance.
/// </summary>
[JsonPropertyName("hasVerifiedProvenance")]
public bool HasVerifiedProvenance { get; init; }
/// <summary>
/// Whether the package is signed.
/// </summary>
[JsonPropertyName("isSigned")]
public bool IsSigned { get; init; }
/// <summary>
/// Signature type if signed (e.g., "sigstore", "gpg", "npm-provenance").
/// </summary>
[JsonPropertyName("signatureType")]
public string? SignatureType { get; init; }
/// <summary>
/// Age of the package in days since first publish.
/// </summary>
[JsonPropertyName("packageAgeDays")]
public int? PackageAgeDays { get; init; }
/// <summary>
/// Number of maintainers.
/// </summary>
[JsonPropertyName("maintainerCount")]
public int? MaintainerCount { get; init; }
/// <summary>
/// Community adoption score based on downloads/stars.
/// </summary>
[JsonPropertyName("adoptionScore")]
public double? AdoptionScore { get; init; }
/// <summary>
/// Known security incidents for this publisher.
/// </summary>
[JsonPropertyName("publisherIncidentCount")]
public int PublisherIncidentCount { get; init; }
/// <summary>
/// Trust factors that contributed to the score.
/// </summary>
[JsonPropertyName("trustFactors")]
public IReadOnlyDictionary<string, double> TrustFactors { get; init; } = new Dictionary<string, double>();
}

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