Add signal contracts for reachability, exploitability, trust, and unknown symbols
- 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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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; } = [];
|
||||
}
|
||||
13
src/Gateway/StellaOps.Gateway.WebService/Program.cs
Normal file
13
src/Gateway/StellaOps.Gateway.WebService/Program.cs
Normal 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 { }
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 | NR1–NR10 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). |
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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"))
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"))
|
||||
};
|
||||
}
|
||||
@@ -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"))
|
||||
};
|
||||
}
|
||||
@@ -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"))
|
||||
};
|
||||
}
|
||||
@@ -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"))
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
9057
src/Web/StellaOps.Web/package-lock.json
generated
9057
src/Web/StellaOps.Web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
50
src/Web/StellaOps.Web/scripts/storybook.js
Normal file
50
src/Web/StellaOps.Web/scripts/storybook.js
Normal 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);
|
||||
});
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,3 +14,12 @@ export {
|
||||
AUTH_SERVICE,
|
||||
MockAuthService,
|
||||
} from './auth.service';
|
||||
|
||||
export {
|
||||
requireAuthGuard,
|
||||
requireScopesGuard,
|
||||
requireAnyScopeGuard,
|
||||
requireOrchViewerGuard,
|
||||
requireOrchOperatorGuard,
|
||||
requireOrchQuotaGuard,
|
||||
} from './auth.guard';
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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">×</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">🔗</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">×</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"
|
||||
>
|
||||
← 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 →
|
||||
</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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">📋</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">⚙</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;
|
||||
}
|
||||
@@ -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">← 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 = '';
|
||||
}
|
||||
@@ -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">← 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 {}
|
||||
@@ -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">← 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 {}
|
||||
86
src/Web/StellaOps.Web/src/i18n/micro-interactions.en.json
Normal file
86
src/Web/StellaOps.Web/src/i18n/micro-interactions.en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
17
src/__Libraries/StellaOps.Router.Common/ClaimRequirement.cs
Normal file
17
src/__Libraries/StellaOps.Router.Common/ClaimRequirement.cs
Normal 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; }
|
||||
}
|
||||
42
src/__Libraries/StellaOps.Router.Common/ConnectionState.cs
Normal file
42
src/__Libraries/StellaOps.Router.Common/ConnectionState.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
22
src/__Libraries/StellaOps.Router.Common/Frame.cs
Normal file
22
src/__Libraries/StellaOps.Router.Common/Frame.cs
Normal 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; }
|
||||
}
|
||||
47
src/__Libraries/StellaOps.Router.Common/FrameType.cs
Normal file
47
src/__Libraries/StellaOps.Router.Common/FrameType.cs
Normal 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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
24
src/__Libraries/StellaOps.Router.Common/IRoutingPlugin.cs
Normal file
24
src/__Libraries/StellaOps.Router.Common/IRoutingPlugin.cs
Normal 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);
|
||||
}
|
||||
24
src/__Libraries/StellaOps.Router.Common/ITransportClient.cs
Normal file
24
src/__Libraries/StellaOps.Router.Common/ITransportClient.cs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
45
src/__Libraries/StellaOps.Router.Common/ITransportServer.cs
Normal file
45
src/__Libraries/StellaOps.Router.Common/ITransportServer.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
32
src/__Libraries/StellaOps.Router.Common/TransportType.cs
Normal file
32
src/__Libraries/StellaOps.Router.Common/TransportType.cs
Normal 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
|
||||
}
|
||||
25
src/__Libraries/StellaOps.Router.Config/PayloadLimits.cs
Normal file
25
src/__Libraries/StellaOps.Router.Config/PayloadLimits.cs
Normal 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;
|
||||
}
|
||||
17
src/__Libraries/StellaOps.Router.Config/RouterConfig.cs
Normal file
17
src/__Libraries/StellaOps.Router.Config/RouterConfig.cs
Normal 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; } = [];
|
||||
}
|
||||
60
src/__Libraries/StellaOps.Router.Config/ServiceConfig.cs
Normal file
60
src/__Libraries/StellaOps.Router.Config/ServiceConfig.cs
Normal 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; } = [];
|
||||
}
|
||||
@@ -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>
|
||||
82
src/__Libraries/StellaOps.Signals.Contracts/AGENTS.md
Normal file
82
src/__Libraries/StellaOps.Signals.Contracts/AGENTS.md
Normal 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)
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user