This commit is contained in:
StellaOps Bot
2025-12-18 20:37:12 +02:00
278 changed files with 35930 additions and 1134 deletions

View File

@@ -0,0 +1,183 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using MicrosoftOptions = Microsoft.Extensions.Options;
using StellaOps.Signals.Models;
using StellaOps.Signals.Services;
using StellaOps.Signals.Storage.Postgres.Repositories;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.Signals.Storage.Postgres.Tests;
/// <summary>
/// Integration tests for callgraph projection to relational tables.
/// </summary>
[Collection(SignalsPostgresCollection.Name)]
public sealed class CallGraphProjectionIntegrationTests : IAsyncLifetime
{
private readonly SignalsPostgresFixture _fixture;
private readonly ITestOutputHelper _output;
private readonly SignalsDataSource _dataSource;
private readonly PostgresCallGraphQueryRepository _queryRepository;
private readonly CallGraphSyncService _service;
public CallGraphProjectionIntegrationTests(SignalsPostgresFixture fixture, ITestOutputHelper output)
{
_fixture = fixture;
_output = output;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
_dataSource = new SignalsDataSource(MicrosoftOptions.Options.Create(options), NullLogger<SignalsDataSource>.Instance);
var projectionRepository = new PostgresCallGraphProjectionRepository(
_dataSource,
NullLogger<PostgresCallGraphProjectionRepository>.Instance);
_queryRepository = new PostgresCallGraphQueryRepository(
_dataSource,
NullLogger<PostgresCallGraphQueryRepository>.Instance);
_service = new CallGraphSyncService(
projectionRepository,
TimeProvider.System,
NullLogger<CallGraphSyncService>.Instance);
}
public async Task InitializeAsync()
{
await _fixture.ExecuteSqlAsync("TRUNCATE TABLE signals.scans CASCADE;");
}
public async Task DisposeAsync()
{
await _dataSource.DisposeAsync();
}
[Fact]
public async Task SyncAsync_ProjectsNodesToRelationalTable()
{
var scanId = Guid.NewGuid();
var document = CreateSampleDocument();
// Act
var result = await _service.SyncAsync(scanId, "sha256:test-digest", document);
// Assert
Assert.True(result.WasUpdated);
Assert.Equal(document.Nodes.Count, result.NodesProjected);
Assert.Equal(document.Edges.Count, result.EdgesProjected);
Assert.Equal(document.Entrypoints.Count, result.EntrypointsProjected);
Assert.True(result.DurationMs >= 0);
_output.WriteLine($"Projected {result.NodesProjected} nodes, {result.EdgesProjected} edges, {result.EntrypointsProjected} entrypoints in {result.DurationMs}ms");
}
[Fact]
public async Task SyncAsync_IsIdempotent_DoesNotCreateDuplicates()
{
var scanId = Guid.NewGuid();
var document = CreateSampleDocument();
// Act - project twice
var result1 = await _service.SyncAsync(scanId, "sha256:test-digest", document);
var result2 = await _service.SyncAsync(scanId, "sha256:test-digest", document);
// Assert - second run should update, not duplicate
Assert.Equal(result1.NodesProjected, result2.NodesProjected);
Assert.Equal(result1.EdgesProjected, result2.EdgesProjected);
}
[Fact]
public async Task SyncAsync_WithEntrypoints_ProjectsEntrypointsCorrectly()
{
var scanId = Guid.NewGuid();
var document = new CallgraphDocument
{
Id = Guid.NewGuid().ToString("N"),
Language = "csharp",
GraphHash = "test-hash",
Nodes = new List<CallgraphNode>
{
new() { Id = "node-1", Name = "GetUsers", Namespace = "Api.Controllers" },
new() { Id = "node-2", Name = "CreateUser", Namespace = "Api.Controllers" }
},
Edges = new List<CallgraphEdge>(),
Entrypoints = new List<CallgraphEntrypoint>
{
new() { NodeId = "node-1", Kind = EntrypointKind.Http, Route = "/api/users", HttpMethod = "GET", Order = 0 },
new() { NodeId = "node-2", Kind = EntrypointKind.Http, Route = "/api/users", HttpMethod = "POST", Order = 1 }
}
};
// Act
var result = await _service.SyncAsync(scanId, "sha256:test-digest", document);
// Assert
Assert.Equal(2, result.EntrypointsProjected);
_output.WriteLine($"Projected {result.EntrypointsProjected} HTTP entrypoints");
}
[Fact]
public async Task DeleteByScanAsync_RemovesAllProjectedData()
{
var scanId = Guid.NewGuid();
var document = CreateSampleDocument();
// Project first
await _service.SyncAsync(scanId, "sha256:test-digest", document);
// Act
await _service.DeleteByScanAsync(scanId);
// Assert - query should return empty stats
var stats = await _queryRepository.GetStatsAsync(scanId);
Assert.Equal(0, stats.NodeCount);
Assert.Equal(0, stats.EdgeCount);
}
[Fact]
public async Task QueryRepository_CanQueryProjectedData()
{
var scanId = Guid.NewGuid();
var document = CreateSampleDocument();
// Project
await _service.SyncAsync(scanId, "sha256:test-digest", document);
// Act
var stats = await _queryRepository.GetStatsAsync(scanId);
// Assert
Assert.Equal(document.Nodes.Count, stats.NodeCount);
Assert.Equal(document.Edges.Count, stats.EdgeCount);
_output.WriteLine($"Query returned: {stats.NodeCount} nodes, {stats.EdgeCount} edges");
}
private static CallgraphDocument CreateSampleDocument()
{
return new CallgraphDocument
{
Id = Guid.NewGuid().ToString("N"),
Language = "csharp",
GraphHash = "sha256:sample-graph-hash",
Nodes = new List<CallgraphNode>
{
new() { Id = "node-1", Name = "Main", Kind = "method", Namespace = "Program", Visibility = SymbolVisibility.Public, IsEntrypointCandidate = true },
new() { Id = "node-2", Name = "DoWork", Kind = "method", Namespace = "Service", Visibility = SymbolVisibility.Internal },
new() { Id = "node-3", Name = "ProcessData", Kind = "method", Namespace = "Core", Visibility = SymbolVisibility.Private }
},
Edges = new List<CallgraphEdge>
{
new() { SourceId = "node-1", TargetId = "node-2", Kind = EdgeKind.Static, Reason = EdgeReason.DirectCall, Weight = 1.0 },
new() { SourceId = "node-2", TargetId = "node-3", Kind = EdgeKind.Static, Reason = EdgeReason.DirectCall, Weight = 1.0 }
},
Entrypoints = new List<CallgraphEntrypoint>
{
new() { NodeId = "node-1", Kind = EntrypointKind.Main, Phase = EntrypointPhase.AppStart, Order = 0 }
}
};
}
}

View File

@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
@@ -7,7 +8,6 @@ using MicrosoftOptions = Microsoft.Extensions.Options;
using StellaOps.Signals.Models;
using StellaOps.Signals.Services;
using StellaOps.Signals.Storage.Postgres.Repositories;
using StellaOps.Signals.Storage.Postgres.Services;
using Xunit;
namespace StellaOps.Signals.Storage.Postgres.Tests;
@@ -28,7 +28,14 @@ public sealed class CallGraphSyncServiceTests : IAsyncLifetime
options.SchemaName = fixture.SchemaName;
_dataSource = new SignalsDataSource(MicrosoftOptions.Options.Create(options), NullLogger<SignalsDataSource>.Instance);
_syncService = new CallGraphSyncService(_dataSource, TimeProvider.System, NullLogger<CallGraphSyncService>.Instance);
var projectionRepository = new PostgresCallGraphProjectionRepository(
_dataSource,
NullLogger<PostgresCallGraphProjectionRepository>.Instance);
_syncService = new CallGraphSyncService(
projectionRepository,
TimeProvider.System,
NullLogger<CallGraphSyncService>.Instance);
_queryRepository = new PostgresCallGraphQueryRepository(_dataSource, NullLogger<PostgresCallGraphQueryRepository>.Instance);
}
@@ -45,6 +52,8 @@ public sealed class CallGraphSyncServiceTests : IAsyncLifetime
[Fact]
public async Task SyncAsync_ProjectsCallgraph_AndQueryRepositoryReturnsStats()
{
var scanId = Guid.NewGuid();
var document = new CallgraphDocument
{
Id = "callgraph-1",
@@ -99,19 +108,11 @@ public sealed class CallGraphSyncServiceTests : IAsyncLifetime
}
};
var request = new CallGraphSyncRequest(
ArtifactDigest: document.Artifact.Hash,
SbomDigest: null,
RepoUri: null,
CommitSha: null,
PolicyDigest: null,
Document: document);
var result1 = await _syncService.SyncAsync(scanId, document.Artifact.Hash, document, CancellationToken.None);
result1.WasUpdated.Should().BeTrue();
result1.ScanId.Should().Be(scanId);
var result1 = await _syncService.SyncAsync(request, CancellationToken.None);
result1.WasApplied.Should().BeTrue();
result1.ScanId.Should().NotBeEmpty();
var stats = await _queryRepository.GetStatsAsync(result1.ScanId, CancellationToken.None);
var stats = await _queryRepository.GetStatsAsync(scanId, CancellationToken.None);
stats.NodeCount.Should().Be(2);
stats.EdgeCount.Should().Be(1);
stats.EntrypointCount.Should().Be(1);
@@ -119,13 +120,13 @@ public sealed class CallGraphSyncServiceTests : IAsyncLifetime
stats.HeuristicEdgeCount.Should().Be(0);
stats.UnresolvedEdgeCount.Should().Be(0);
var reachable = await _queryRepository.GetReachableSymbolsAsync(result1.ScanId, "n1", cancellationToken: CancellationToken.None);
var reachable = await _queryRepository.GetReachableSymbolsAsync(scanId, "n1", cancellationToken: CancellationToken.None);
reachable.Should().Contain("n2");
var result2 = await _syncService.SyncAsync(request, CancellationToken.None);
var result2 = await _syncService.SyncAsync(scanId, document.Artifact.Hash, document, CancellationToken.None);
result2.ScanId.Should().Be(result1.ScanId);
var stats2 = await _queryRepository.GetStatsAsync(result1.ScanId, CancellationToken.None);
var stats2 = await _queryRepository.GetStatsAsync(scanId, CancellationToken.None);
stats2.NodeCount.Should().Be(2);
stats2.EdgeCount.Should().Be(1);
}

View File

@@ -0,0 +1,466 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Signals.Models;
using StellaOps.Signals.Persistence;
namespace StellaOps.Signals.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="ICallGraphProjectionRepository"/>.
/// Projects callgraph documents into relational tables for efficient querying.
/// </summary>
public sealed class PostgresCallGraphProjectionRepository : RepositoryBase<SignalsDataSource>, ICallGraphProjectionRepository
{
private const int BatchSize = 1000;
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public PostgresCallGraphProjectionRepository(
SignalsDataSource dataSource,
ILogger<PostgresCallGraphProjectionRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<bool> UpsertScanAsync(
Guid scanId,
string artifactDigest,
string? sbomDigest = null,
string? repoUri = null,
string? commitSha = null,
CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO signals.scans (scan_id, artifact_digest, sbom_digest, repo_uri, commit_sha, status, created_at)
VALUES (@scan_id, @artifact_digest, @sbom_digest, @repo_uri, @commit_sha, 'processing', NOW())
ON CONFLICT (scan_id)
DO UPDATE SET
artifact_digest = EXCLUDED.artifact_digest,
sbom_digest = COALESCE(EXCLUDED.sbom_digest, signals.scans.sbom_digest),
repo_uri = COALESCE(EXCLUDED.repo_uri, signals.scans.repo_uri),
commit_sha = COALESCE(EXCLUDED.commit_sha, signals.scans.commit_sha),
status = CASE WHEN signals.scans.status = 'completed' THEN 'completed' ELSE 'processing' END
RETURNING (xmax = 0) AS was_inserted
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@scan_id", scanId);
AddParameter(command, "@artifact_digest", artifactDigest);
AddParameter(command, "@sbom_digest", sbomDigest ?? (object)DBNull.Value);
AddParameter(command, "@repo_uri", repoUri ?? (object)DBNull.Value);
AddParameter(command, "@commit_sha", commitSha ?? (object)DBNull.Value);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return result is true;
}
/// <inheritdoc />
public async Task CompleteScanAsync(Guid scanId, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE signals.scans
SET status = 'completed', completed_at = NOW()
WHERE scan_id = @scan_id
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@scan_id", scanId);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task FailScanAsync(Guid scanId, string errorMessage, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE signals.scans
SET status = 'failed', error_message = @error_message, completed_at = NOW()
WHERE scan_id = @scan_id
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@scan_id", scanId);
AddParameter(command, "@error_message", errorMessage);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<int> UpsertNodesAsync(
Guid scanId,
IReadOnlyList<CallgraphNode> nodes,
CancellationToken cancellationToken = default)
{
if (nodes is not { Count: > 0 })
{
return 0;
}
// Sort nodes deterministically by Id for stable ordering
var sortedNodes = nodes.OrderBy(n => n.Id, StringComparer.Ordinal).ToList();
var totalInserted = 0;
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
try
{
// Process in batches
for (var i = 0; i < sortedNodes.Count; i += BatchSize)
{
var batch = sortedNodes.Skip(i).Take(BatchSize).ToList();
totalInserted += await UpsertNodeBatchAsync(connection, transaction, scanId, batch, cancellationToken).ConfigureAwait(false);
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
return totalInserted;
}
catch
{
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
throw;
}
}
private async Task<int> UpsertNodeBatchAsync(
NpgsqlConnection connection,
NpgsqlTransaction transaction,
Guid scanId,
IReadOnlyList<CallgraphNode> nodes,
CancellationToken cancellationToken)
{
var sql = new StringBuilder();
sql.AppendLine("""
INSERT INTO signals.cg_nodes (scan_id, node_id, artifact_key, symbol_key, visibility, is_entrypoint_candidate, purl, symbol_digest, flags, attributes)
VALUES
""");
var parameters = new List<NpgsqlParameter>();
var paramIndex = 0;
for (var i = 0; i < nodes.Count; i++)
{
var node = nodes[i];
if (i > 0) sql.Append(',');
sql.AppendLine($"""
(@p{paramIndex}, @p{paramIndex + 1}, @p{paramIndex + 2}, @p{paramIndex + 3}, @p{paramIndex + 4}, @p{paramIndex + 5}, @p{paramIndex + 6}, @p{paramIndex + 7}, @p{paramIndex + 8}, @p{paramIndex + 9})
""");
parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", scanId));
parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", node.Id));
parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", node.Namespace ?? (object)DBNull.Value));
parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", BuildSymbolKey(node)));
parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", MapVisibility(node)));
parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", node.IsEntrypointCandidate));
parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", node.Purl ?? (object)DBNull.Value));
parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", node.SymbolDigest ?? (object)DBNull.Value));
parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", MapNodeFlags(node)));
parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", NpgsqlDbType.Jsonb) { Value = SerializeAttributes(node) ?? (object)DBNull.Value });
}
sql.AppendLine("""
ON CONFLICT (scan_id, node_id)
DO UPDATE SET
artifact_key = EXCLUDED.artifact_key,
symbol_key = EXCLUDED.symbol_key,
visibility = EXCLUDED.visibility,
is_entrypoint_candidate = EXCLUDED.is_entrypoint_candidate,
purl = EXCLUDED.purl,
symbol_digest = EXCLUDED.symbol_digest,
flags = EXCLUDED.flags,
attributes = EXCLUDED.attributes
""");
await using var command = new NpgsqlCommand(sql.ToString(), connection, transaction);
command.Parameters.AddRange(parameters.ToArray());
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<int> UpsertEdgesAsync(
Guid scanId,
IReadOnlyList<CallgraphEdge> edges,
CancellationToken cancellationToken = default)
{
if (edges is not { Count: > 0 })
{
return 0;
}
// Sort edges deterministically by (SourceId, TargetId) for stable ordering
var sortedEdges = edges
.OrderBy(e => e.SourceId, StringComparer.Ordinal)
.ThenBy(e => e.TargetId, StringComparer.Ordinal)
.ToList();
var totalInserted = 0;
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
try
{
// Process in batches
for (var i = 0; i < sortedEdges.Count; i += BatchSize)
{
var batch = sortedEdges.Skip(i).Take(BatchSize).ToList();
totalInserted += await UpsertEdgeBatchAsync(connection, transaction, scanId, batch, cancellationToken).ConfigureAwait(false);
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
return totalInserted;
}
catch
{
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
throw;
}
}
private async Task<int> UpsertEdgeBatchAsync(
NpgsqlConnection connection,
NpgsqlTransaction transaction,
Guid scanId,
IReadOnlyList<CallgraphEdge> edges,
CancellationToken cancellationToken)
{
var sql = new StringBuilder();
sql.AppendLine("""
INSERT INTO signals.cg_edges (scan_id, from_node_id, to_node_id, kind, reason, weight, is_resolved, provenance)
VALUES
""");
var parameters = new List<NpgsqlParameter>();
var paramIndex = 0;
for (var i = 0; i < edges.Count; i++)
{
var edge = edges[i];
if (i > 0) sql.Append(',');
sql.AppendLine($"""
(@p{paramIndex}, @p{paramIndex + 1}, @p{paramIndex + 2}, @p{paramIndex + 3}, @p{paramIndex + 4}, @p{paramIndex + 5}, @p{paramIndex + 6}, @p{paramIndex + 7})
""");
parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", scanId));
parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", edge.SourceId));
parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", edge.TargetId));
parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", (short)MapEdgeKind(edge)));
parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", (short)MapEdgeReason(edge)));
parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", (float)(edge.Confidence ?? 1.0)));
parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", edge.IsResolved));
parameters.Add(new NpgsqlParameter($"@p{paramIndex++}", edge.Provenance ?? (object)DBNull.Value));
}
sql.AppendLine("""
ON CONFLICT (scan_id, from_node_id, to_node_id, kind, reason)
DO UPDATE SET
weight = EXCLUDED.weight,
is_resolved = EXCLUDED.is_resolved,
provenance = EXCLUDED.provenance
""");
await using var command = new NpgsqlCommand(sql.ToString(), connection, transaction);
command.Parameters.AddRange(parameters.ToArray());
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<int> UpsertEntrypointsAsync(
Guid scanId,
IReadOnlyList<CallgraphEntrypoint> entrypoints,
CancellationToken cancellationToken = default)
{
if (entrypoints is not { Count: > 0 })
{
return 0;
}
// Sort entrypoints deterministically by (NodeId, Order) for stable ordering
var sortedEntrypoints = entrypoints
.OrderBy(e => e.NodeId, StringComparer.Ordinal)
.ThenBy(e => e.Order)
.ToList();
const string sql = """
INSERT INTO signals.entrypoints (scan_id, node_id, kind, framework, route, http_method, phase, order_idx)
VALUES (@scan_id, @node_id, @kind, @framework, @route, @http_method, @phase, @order_idx)
ON CONFLICT (scan_id, node_id, kind)
DO UPDATE SET
framework = EXCLUDED.framework,
route = EXCLUDED.route,
http_method = EXCLUDED.http_method,
phase = EXCLUDED.phase,
order_idx = EXCLUDED.order_idx
""";
var totalInserted = 0;
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
try
{
foreach (var entrypoint in sortedEntrypoints)
{
await using var command = new NpgsqlCommand(sql, connection, transaction);
command.Parameters.AddWithValue("@scan_id", scanId);
command.Parameters.AddWithValue("@node_id", entrypoint.NodeId);
command.Parameters.AddWithValue("@kind", MapEntrypointKind(entrypoint.Kind));
command.Parameters.AddWithValue("@framework", entrypoint.Framework.ToString().ToLowerInvariant());
command.Parameters.AddWithValue("@route", entrypoint.Route ?? (object)DBNull.Value);
command.Parameters.AddWithValue("@http_method", entrypoint.HttpMethod ?? (object)DBNull.Value);
command.Parameters.AddWithValue("@phase", MapEntrypointPhase(entrypoint.Phase));
command.Parameters.AddWithValue("@order_idx", entrypoint.Order);
totalInserted += await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
return totalInserted;
}
catch
{
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
throw;
}
}
/// <inheritdoc />
public async Task DeleteScanAsync(Guid scanId, CancellationToken cancellationToken = default)
{
// Delete from scans cascades to all related tables via FK
const string sql = "DELETE FROM signals.scans WHERE scan_id = @scan_id";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@scan_id", scanId);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
// ===== HELPER METHODS =====
private static string BuildSymbolKey(CallgraphNode node)
{
// Build canonical symbol key: namespace.name or just name
if (!string.IsNullOrWhiteSpace(node.Namespace))
{
return $"{node.Namespace}.{node.Name}";
}
return node.Name;
}
private static string MapVisibility(CallgraphNode node)
{
return node.Visibility switch
{
SymbolVisibility.Public => "public",
SymbolVisibility.Internal => "internal",
SymbolVisibility.Protected => "protected",
SymbolVisibility.Private => "private",
_ => "unknown"
};
}
private static int MapNodeFlags(CallgraphNode node)
{
// Use the Flags property directly from the node
// The Flags bitfield is already encoded by the parser
return node.Flags;
}
private static string? SerializeAttributes(CallgraphNode node)
{
// Serialize additional attributes if present
if (node.Evidence is not { Count: > 0 })
{
return null;
}
return JsonSerializer.Serialize(new { evidence = node.Evidence }, JsonOptions);
}
private static EdgeKind MapEdgeKind(CallgraphEdge edge)
{
return edge.Kind switch
{
EdgeKind.Static => EdgeKind.Static,
EdgeKind.Heuristic => EdgeKind.Heuristic,
EdgeKind.Runtime => EdgeKind.Runtime,
_ => edge.Type?.ToLowerInvariant() switch
{
"static" => EdgeKind.Static,
"heuristic" => EdgeKind.Heuristic,
"runtime" => EdgeKind.Runtime,
_ => EdgeKind.Static
}
};
}
private static EdgeReason MapEdgeReason(CallgraphEdge edge)
{
return edge.Reason switch
{
EdgeReason.DirectCall => EdgeReason.DirectCall,
EdgeReason.VirtualCall => EdgeReason.VirtualCall,
EdgeReason.ReflectionString => EdgeReason.ReflectionString,
EdgeReason.RuntimeMinted => EdgeReason.RuntimeMinted,
_ => EdgeReason.DirectCall
};
}
private static string MapEntrypointKind(EntrypointKind kind)
{
return kind switch
{
EntrypointKind.Http => "http",
EntrypointKind.Grpc => "grpc",
EntrypointKind.Cli => "cli",
EntrypointKind.Job => "job",
EntrypointKind.Event => "event",
EntrypointKind.MessageQueue => "message_queue",
EntrypointKind.Timer => "timer",
EntrypointKind.Test => "test",
EntrypointKind.Main => "main",
EntrypointKind.ModuleInit => "module_init",
EntrypointKind.StaticConstructor => "static_constructor",
_ => "unknown"
};
}
private static string MapEntrypointPhase(EntrypointPhase phase)
{
return phase switch
{
EntrypointPhase.ModuleInit => "module_init",
EntrypointPhase.AppStart => "app_start",
EntrypointPhase.Runtime => "runtime",
EntrypointPhase.Shutdown => "shutdown",
_ => "runtime"
};
}
}

View File

@@ -6,7 +6,6 @@ using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.Signals.Persistence;
using StellaOps.Signals.Services;
using StellaOps.Signals.Storage.Postgres.Repositories;
using StellaOps.Signals.Storage.Postgres.Services;
namespace StellaOps.Signals.Storage.Postgres;
@@ -44,6 +43,7 @@ public static class ServiceCollectionExtensions
services.AddSingleton<IDeploymentRefsRepository, PostgresDeploymentRefsRepository>();
services.AddSingleton<IGraphMetricsRepository, PostgresGraphMetricsRepository>();
services.AddSingleton<ICallGraphQueryRepository, PostgresCallGraphQueryRepository>();
services.AddSingleton<ICallGraphProjectionRepository, PostgresCallGraphProjectionRepository>();
services.AddSingleton<ICallGraphSyncService, CallGraphSyncService>();
@@ -77,6 +77,7 @@ public static class ServiceCollectionExtensions
services.AddSingleton<IDeploymentRefsRepository, PostgresDeploymentRefsRepository>();
services.AddSingleton<IGraphMetricsRepository, PostgresGraphMetricsRepository>();
services.AddSingleton<ICallGraphQueryRepository, PostgresCallGraphQueryRepository>();
services.AddSingleton<ICallGraphProjectionRepository, PostgresCallGraphProjectionRepository>();
services.AddSingleton<ICallGraphSyncService, CallGraphSyncService>();

View File

@@ -1,565 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Signals.Models;
using StellaOps.Signals.Services;
namespace StellaOps.Signals.Storage.Postgres.Services;
public sealed class CallGraphSyncService : RepositoryBase<SignalsDataSource>, ICallGraphSyncService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
private readonly TimeProvider _timeProvider;
public CallGraphSyncService(
SignalsDataSource dataSource,
TimeProvider timeProvider,
ILogger<CallGraphSyncService> logger)
: base(dataSource, logger)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task<CallGraphSyncResult> SyncAsync(CallGraphSyncRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(request.Document);
var artifactDigest = NormalizeRequired(request.ArtifactDigest);
var sbomDigest = NormalizeOptionalDigest(request.SbomDigest) ?? string.Empty;
try
{
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
var scanId = await UpsertScanAsync(connection, transaction, artifactDigest, sbomDigest, request, cancellationToken).ConfigureAwait(false);
await DeleteProjectedRowsAsync(connection, transaction, scanId, cancellationToken).ConfigureAwait(false);
await InsertScanArtifactAsync(connection, transaction, scanId, artifactDigest, request.Document, cancellationToken).ConfigureAwait(false);
await InsertNodesAsync(connection, transaction, scanId, request.Document, cancellationToken).ConfigureAwait(false);
await InsertEdgesAsync(connection, transaction, scanId, request.Document, cancellationToken).ConfigureAwait(false);
await InsertEntrypointsAsync(connection, transaction, scanId, request.Document, cancellationToken).ConfigureAwait(false);
await InsertSymbolComponentMappingsAsync(connection, transaction, scanId, request.Document, cancellationToken).ConfigureAwait(false);
await MarkScanCompletedAsync(connection, transaction, scanId, cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
Logger.LogInformation(
"Projected callgraph artifact={ArtifactDigest} sbom={SbomDigest} scan={ScanId} nodes={NodeCount} edges={EdgeCount}",
artifactDigest,
sbomDigest,
scanId,
request.Document.Nodes.Count,
request.Document.Edges.Count);
return new CallGraphSyncResult(scanId, WasApplied: true);
}
catch (Exception ex)
{
Logger.LogError(ex, "Callgraph projection failed for artifact={ArtifactDigest} sbom={SbomDigest}", artifactDigest, sbomDigest);
await TryMarkScanFailedAsync(artifactDigest, sbomDigest, request, ex, cancellationToken).ConfigureAwait(false);
throw;
}
}
private async Task<Guid> UpsertScanAsync(
NpgsqlConnection connection,
NpgsqlTransaction transaction,
string artifactDigest,
string sbomDigest,
CallGraphSyncRequest request,
CancellationToken cancellationToken)
{
const string sql = """
INSERT INTO signals.scans (artifact_digest, repo_uri, commit_sha, sbom_digest, policy_digest, status, completed_at, error_message)
VALUES (@artifact_digest, @repo_uri, @commit_sha, @sbom_digest, @policy_digest, 'processing', NULL, NULL)
ON CONFLICT (artifact_digest, sbom_digest)
DO UPDATE SET
repo_uri = EXCLUDED.repo_uri,
commit_sha = EXCLUDED.commit_sha,
policy_digest = EXCLUDED.policy_digest,
status = 'processing',
completed_at = NULL,
error_message = NULL
RETURNING scan_id
""";
await using var command = CreateCommand(sql, connection);
command.Transaction = transaction;
AddParameter(command, "@artifact_digest", artifactDigest);
AddParameter(command, "@repo_uri", NormalizeOptional(request.RepoUri));
AddParameter(command, "@commit_sha", NormalizeOptional(request.CommitSha)?.ToLowerInvariant());
AddParameter(command, "@sbom_digest", sbomDigest);
AddParameter(command, "@policy_digest", NormalizeOptionalDigest(request.PolicyDigest));
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return (Guid)(result ?? throw new InvalidOperationException("scan_id was not returned from signals.scans upsert."));
}
private async Task DeleteProjectedRowsAsync(
NpgsqlConnection connection,
NpgsqlTransaction transaction,
Guid scanId,
CancellationToken cancellationToken)
{
const string sql = """
DELETE FROM signals.symbol_component_map WHERE scan_id = @scan_id;
DELETE FROM signals.entrypoints WHERE scan_id = @scan_id;
DELETE FROM signals.cg_edges WHERE scan_id = @scan_id;
DELETE FROM signals.cg_nodes WHERE scan_id = @scan_id;
DELETE FROM signals.artifacts WHERE scan_id = @scan_id;
""";
await using var command = CreateCommand(sql, connection);
command.Transaction = transaction;
AddParameter(command, "@scan_id", scanId);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private async Task InsertScanArtifactAsync(
NpgsqlConnection connection,
NpgsqlTransaction transaction,
Guid scanId,
string artifactDigest,
CallgraphDocument document,
CancellationToken cancellationToken)
{
const string sql = """
INSERT INTO signals.artifacts (
scan_id,
artifact_key,
kind,
sha256,
purl,
build_id,
file_path,
size_bytes)
VALUES (
@scan_id,
@artifact_key,
@kind,
@sha256,
@purl,
@build_id,
@file_path,
@size_bytes)
""";
await using var command = CreateCommand(sql, connection);
command.Transaction = transaction;
AddParameter(command, "@scan_id", scanId);
AddParameter(command, "@artifact_key", NormalizeOptional(document.Component) ?? "callgraph");
AddParameter(command, "@kind", InferArtifactKind(document.LanguageType));
AddParameter(command, "@sha256", artifactDigest);
AddParameter(command, "@purl", IsPurl(document.Component) ? document.Component.Trim().ToLowerInvariant() : null);
AddParameter(command, "@build_id", NormalizeOptional(document.GraphMetadata?.BuildId));
AddParameter(command, "@file_path", NormalizeOptional(document.Artifact.Path));
AddParameter(command, "@size_bytes", document.Artifact.Length > 0 ? document.Artifact.Length : null);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private async Task InsertNodesAsync(
NpgsqlConnection connection,
NpgsqlTransaction transaction,
Guid scanId,
CallgraphDocument document,
CancellationToken cancellationToken)
{
const string sql = """
INSERT INTO signals.cg_nodes (
scan_id,
node_id,
artifact_key,
symbol_key,
visibility,
is_entrypoint_candidate,
purl,
symbol_digest,
flags,
attributes)
VALUES (
@scan_id,
@node_id,
@artifact_key,
@symbol_key,
@visibility,
@is_entrypoint_candidate,
@purl,
@symbol_digest,
@flags,
@attributes)
""";
await using var command = CreateCommand(sql, connection);
command.Transaction = transaction;
command.Parameters.Add(new NpgsqlParameter<Guid>("@scan_id", NpgsqlDbType.Uuid) { TypedValue = scanId });
command.Parameters.Add(new NpgsqlParameter<string>("@node_id", NpgsqlDbType.Text) { TypedValue = string.Empty });
command.Parameters.Add(new NpgsqlParameter<string?>("@artifact_key", NpgsqlDbType.Text) { TypedValue = null });
command.Parameters.Add(new NpgsqlParameter<string>("@symbol_key", NpgsqlDbType.Text) { TypedValue = string.Empty });
command.Parameters.Add(new NpgsqlParameter<string>("@visibility", NpgsqlDbType.Text) { TypedValue = "unknown" });
command.Parameters.Add(new NpgsqlParameter<bool>("@is_entrypoint_candidate", NpgsqlDbType.Boolean) { TypedValue = false });
command.Parameters.Add(new NpgsqlParameter<string?>("@purl", NpgsqlDbType.Text) { TypedValue = null });
command.Parameters.Add(new NpgsqlParameter<string?>("@symbol_digest", NpgsqlDbType.Text) { TypedValue = null });
command.Parameters.Add(new NpgsqlParameter<int>("@flags", NpgsqlDbType.Integer) { TypedValue = 0 });
command.Parameters.Add(new NpgsqlParameter<string?>("@attributes", NpgsqlDbType.Jsonb) { TypedValue = null });
foreach (var node in document.Nodes.OrderBy(n => n.Id, StringComparer.Ordinal))
{
command.Parameters["@node_id"].Value = NormalizeRequired(node.Id);
command.Parameters["@artifact_key"].Value = NormalizeOptional(node.ArtifactKey);
command.Parameters["@symbol_key"].Value = NormalizeRequired(node.SymbolKey ?? node.Name);
command.Parameters["@visibility"].Value = MapVisibility(node.Visibility);
command.Parameters["@is_entrypoint_candidate"].Value = node.IsEntrypointCandidate;
command.Parameters["@purl"].Value = NormalizeOptionalDigest(node.Purl);
command.Parameters["@symbol_digest"].Value = NormalizeOptionalDigest(node.SymbolDigest);
command.Parameters["@flags"].Value = node.Flags;
command.Parameters["@attributes"].Value = SerializeAttributes(node.Attributes);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
private async Task InsertEdgesAsync(
NpgsqlConnection connection,
NpgsqlTransaction transaction,
Guid scanId,
CallgraphDocument document,
CancellationToken cancellationToken)
{
const string sql = """
INSERT INTO signals.cg_edges (
scan_id,
from_node_id,
to_node_id,
kind,
reason,
weight,
offset_bytes,
is_resolved,
provenance)
VALUES (
@scan_id,
@from_node_id,
@to_node_id,
@kind,
@reason,
@weight,
@offset_bytes,
@is_resolved,
@provenance)
""";
await using var command = CreateCommand(sql, connection);
command.Transaction = transaction;
command.Parameters.Add(new NpgsqlParameter<Guid>("@scan_id", NpgsqlDbType.Uuid) { TypedValue = scanId });
command.Parameters.Add(new NpgsqlParameter<string>("@from_node_id", NpgsqlDbType.Text) { TypedValue = string.Empty });
command.Parameters.Add(new NpgsqlParameter<string>("@to_node_id", NpgsqlDbType.Text) { TypedValue = string.Empty });
command.Parameters.Add(new NpgsqlParameter<short>("@kind", NpgsqlDbType.Smallint) { TypedValue = 0 });
command.Parameters.Add(new NpgsqlParameter<short>("@reason", NpgsqlDbType.Smallint) { TypedValue = 0 });
command.Parameters.Add(new NpgsqlParameter<float>("@weight", NpgsqlDbType.Real) { TypedValue = 1.0f });
command.Parameters.Add(new NpgsqlParameter<int?>("@offset_bytes", NpgsqlDbType.Integer) { TypedValue = null });
command.Parameters.Add(new NpgsqlParameter<bool>("@is_resolved", NpgsqlDbType.Boolean) { TypedValue = true });
command.Parameters.Add(new NpgsqlParameter<string?>("@provenance", NpgsqlDbType.Text) { TypedValue = null });
foreach (var edge in document.Edges
.OrderBy(e => e.SourceId, StringComparer.Ordinal)
.ThenBy(e => e.TargetId, StringComparer.Ordinal)
.ThenBy(e => (int)e.Kind)
.ThenBy(e => (int)e.Reason)
.ThenBy(e => e.Offset ?? -1))
{
command.Parameters["@from_node_id"].Value = NormalizeRequired(edge.SourceId);
command.Parameters["@to_node_id"].Value = NormalizeRequired(edge.TargetId);
command.Parameters["@kind"].Value = (short)edge.Kind;
command.Parameters["@reason"].Value = (short)edge.Reason;
command.Parameters["@weight"].Value = (float)Math.Clamp(edge.Weight, 0.0, 1.0);
command.Parameters["@offset_bytes"].Value = edge.Offset;
command.Parameters["@is_resolved"].Value = edge.IsResolved;
command.Parameters["@provenance"].Value = NormalizeOptional(edge.Provenance);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
private async Task InsertEntrypointsAsync(
NpgsqlConnection connection,
NpgsqlTransaction transaction,
Guid scanId,
CallgraphDocument document,
CancellationToken cancellationToken)
{
const string sql = """
INSERT INTO signals.entrypoints (
scan_id,
node_id,
kind,
framework,
route,
http_method,
phase,
order_idx)
VALUES (
@scan_id,
@node_id,
@kind,
@framework,
@route,
@http_method,
@phase,
@order_idx)
""";
await using var command = CreateCommand(sql, connection);
command.Transaction = transaction;
command.Parameters.Add(new NpgsqlParameter<Guid>("@scan_id", NpgsqlDbType.Uuid) { TypedValue = scanId });
command.Parameters.Add(new NpgsqlParameter<string>("@node_id", NpgsqlDbType.Text) { TypedValue = string.Empty });
command.Parameters.Add(new NpgsqlParameter<string>("@kind", NpgsqlDbType.Text) { TypedValue = "unknown" });
command.Parameters.Add(new NpgsqlParameter<string?>("@framework", NpgsqlDbType.Text) { TypedValue = null });
command.Parameters.Add(new NpgsqlParameter<string?>("@route", NpgsqlDbType.Text) { TypedValue = null });
command.Parameters.Add(new NpgsqlParameter<string?>("@http_method", NpgsqlDbType.Text) { TypedValue = null });
command.Parameters.Add(new NpgsqlParameter<string>("@phase", NpgsqlDbType.Text) { TypedValue = "runtime" });
command.Parameters.Add(new NpgsqlParameter<int>("@order_idx", NpgsqlDbType.Integer) { TypedValue = 0 });
foreach (var entrypoint in document.Entrypoints
.OrderBy(e => (int)e.Phase)
.ThenBy(e => e.Order)
.ThenBy(e => e.NodeId, StringComparer.Ordinal))
{
command.Parameters["@node_id"].Value = NormalizeRequired(entrypoint.NodeId);
command.Parameters["@kind"].Value = MapEntrypointKind(entrypoint.Kind);
command.Parameters["@framework"].Value = entrypoint.Framework == EntrypointFramework.Unknown
? null
: entrypoint.Framework.ToString().ToLowerInvariant();
command.Parameters["@route"].Value = NormalizeOptional(entrypoint.Route);
command.Parameters["@http_method"].Value = NormalizeOptional(entrypoint.HttpMethod)?.ToUpperInvariant();
command.Parameters["@phase"].Value = MapEntrypointPhase(entrypoint.Phase);
command.Parameters["@order_idx"].Value = entrypoint.Order;
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
private async Task InsertSymbolComponentMappingsAsync(
NpgsqlConnection connection,
NpgsqlTransaction transaction,
Guid scanId,
CallgraphDocument document,
CancellationToken cancellationToken)
{
const string sql = """
INSERT INTO signals.symbol_component_map (
scan_id,
node_id,
purl,
mapping_kind,
confidence,
evidence)
VALUES (
@scan_id,
@node_id,
@purl,
@mapping_kind,
@confidence,
@evidence)
""";
await using var command = CreateCommand(sql, connection);
command.Transaction = transaction;
command.Parameters.Add(new NpgsqlParameter<Guid>("@scan_id", NpgsqlDbType.Uuid) { TypedValue = scanId });
command.Parameters.Add(new NpgsqlParameter<string>("@node_id", NpgsqlDbType.Text) { TypedValue = string.Empty });
command.Parameters.Add(new NpgsqlParameter<string>("@purl", NpgsqlDbType.Text) { TypedValue = string.Empty });
command.Parameters.Add(new NpgsqlParameter<string>("@mapping_kind", NpgsqlDbType.Text) { TypedValue = "exact" });
command.Parameters.Add(new NpgsqlParameter<float>("@confidence", NpgsqlDbType.Real) { TypedValue = 1.0f });
command.Parameters.Add(new NpgsqlParameter<string?>("@evidence", NpgsqlDbType.Jsonb) { TypedValue = null });
foreach (var node in document.Nodes
.Where(n => !string.IsNullOrWhiteSpace(n.Purl))
.OrderBy(n => n.Id, StringComparer.Ordinal))
{
command.Parameters["@node_id"].Value = NormalizeRequired(node.Id);
command.Parameters["@purl"].Value = NormalizeRequired(node.Purl!).ToLowerInvariant();
command.Parameters["@mapping_kind"].Value = "exact";
command.Parameters["@confidence"].Value = 1.0f;
command.Parameters["@evidence"].Value = SerializeEvidence(node.Evidence);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
private async Task MarkScanCompletedAsync(
NpgsqlConnection connection,
NpgsqlTransaction transaction,
Guid scanId,
CancellationToken cancellationToken)
{
const string sql = """
UPDATE signals.scans
SET status = 'completed',
completed_at = @completed_at,
error_message = NULL
WHERE scan_id = @scan_id
""";
await using var command = CreateCommand(sql, connection);
command.Transaction = transaction;
AddParameter(command, "@scan_id", scanId);
AddParameter(command, "@completed_at", _timeProvider.GetUtcNow());
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private async Task TryMarkScanFailedAsync(
string artifactDigest,
string sbomDigest,
CallGraphSyncRequest request,
Exception exception,
CancellationToken cancellationToken)
{
try
{
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
const string sql = """
INSERT INTO signals.scans (artifact_digest, repo_uri, commit_sha, sbom_digest, policy_digest, status, completed_at, error_message)
VALUES (@artifact_digest, @repo_uri, @commit_sha, @sbom_digest, @policy_digest, 'failed', @completed_at, @error_message)
ON CONFLICT (artifact_digest, sbom_digest)
DO UPDATE SET
repo_uri = EXCLUDED.repo_uri,
commit_sha = EXCLUDED.commit_sha,
policy_digest = EXCLUDED.policy_digest,
status = 'failed',
completed_at = EXCLUDED.completed_at,
error_message = EXCLUDED.error_message
""";
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@artifact_digest", artifactDigest);
AddParameter(command, "@repo_uri", NormalizeOptional(request.RepoUri));
AddParameter(command, "@commit_sha", NormalizeOptional(request.CommitSha)?.ToLowerInvariant());
AddParameter(command, "@sbom_digest", sbomDigest);
AddParameter(command, "@policy_digest", NormalizeOptionalDigest(request.PolicyDigest));
AddParameter(command, "@completed_at", _timeProvider.GetUtcNow());
AddParameter(command, "@error_message", NormalizeOptional(exception.Message));
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to mark scan as failed (artifact={ArtifactDigest}, sbom={SbomDigest}).", artifactDigest, sbomDigest);
}
}
private static string InferArtifactKind(CallgraphLanguage language)
=> language switch
{
CallgraphLanguage.DotNet => "assembly",
CallgraphLanguage.Java => "jar",
CallgraphLanguage.Binary => "binary",
CallgraphLanguage.Node or CallgraphLanguage.Python or CallgraphLanguage.Ruby or CallgraphLanguage.Php => "script",
_ => "module"
};
private static string MapVisibility(SymbolVisibility visibility)
=> visibility switch
{
SymbolVisibility.Public => "public",
SymbolVisibility.Internal => "internal",
SymbolVisibility.Protected => "protected",
SymbolVisibility.Private => "private",
_ => "unknown"
};
private static string MapEntrypointKind(EntrypointKind kind)
=> kind switch
{
EntrypointKind.Http => "http",
EntrypointKind.Grpc => "grpc",
EntrypointKind.Cli => "cli",
EntrypointKind.Job => "job",
EntrypointKind.Event => "event",
EntrypointKind.MessageQueue => "message_queue",
EntrypointKind.Timer => "timer",
EntrypointKind.Test => "test",
EntrypointKind.Main => "main",
EntrypointKind.ModuleInit => "module_init",
EntrypointKind.StaticConstructor => "static_constructor",
_ => "unknown"
};
private static string MapEntrypointPhase(EntrypointPhase phase)
=> phase switch
{
EntrypointPhase.ModuleInit => "module_init",
EntrypointPhase.AppStart => "app_start",
EntrypointPhase.Shutdown => "shutdown",
_ => "runtime"
};
private static string NormalizeRequired(string value)
=> string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("Value is required.", nameof(value))
: value.Trim();
private static string? NormalizeOptional(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string? NormalizeOptionalDigest(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
private static string? SerializeAttributes(IReadOnlyDictionary<string, string>? attributes)
{
if (attributes is null || attributes.Count == 0)
{
return null;
}
var ordered = attributes
.Where(kv => !string.IsNullOrWhiteSpace(kv.Key))
.OrderBy(kv => kv.Key, StringComparer.Ordinal)
.ToDictionary(
kv => kv.Key.Trim(),
kv => kv.Value,
StringComparer.Ordinal);
return ordered.Count == 0 ? null : JsonSerializer.Serialize(ordered, JsonOptions);
}
private static string? SerializeEvidence(IReadOnlyList<string>? evidence)
{
if (evidence is null || evidence.Count == 0)
{
return null;
}
var ordered = evidence
.Where(v => !string.IsNullOrWhiteSpace(v))
.Select(v => v.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(v => v, StringComparer.Ordinal)
.ToArray();
return ordered.Length == 0 ? null : JsonSerializer.Serialize(ordered, JsonOptions);
}
private static bool IsPurl(string? value)
=> !string.IsNullOrWhiteSpace(value) && value.TrimStart().StartsWith("pkg:", StringComparison.OrdinalIgnoreCase);
}

View File

@@ -0,0 +1,192 @@
// -----------------------------------------------------------------------------
// ScoreExplanation.cs
// Sprint: SPRINT_3800_0001_0001_evidence_api_models
// Description: Score explanation model with additive breakdown of risk factors.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Signals.Models;
/// <summary>
/// Score explanation with additive breakdown of risk factors.
/// Provides transparency into how a risk score was computed.
/// </summary>
public sealed record ScoreExplanation
{
/// <summary>
/// Kind of scoring algorithm (stellaops_risk_v1, cvss_v4, custom).
/// </summary>
[JsonPropertyName("kind")]
public string Kind { get; init; } = "stellaops_risk_v1";
/// <summary>
/// Final computed risk score (0.0 to 10.0 or custom range).
/// </summary>
[JsonPropertyName("risk_score")]
public double RiskScore { get; init; }
/// <summary>
/// Individual score contributions summing to the final score.
/// </summary>
[JsonPropertyName("contributions")]
public IReadOnlyList<ScoreContribution> Contributions { get; init; } = Array.Empty<ScoreContribution>();
/// <summary>
/// When the score was computed.
/// </summary>
[JsonPropertyName("last_seen")]
public DateTimeOffset LastSeen { get; init; }
/// <summary>
/// Version of the scoring algorithm.
/// </summary>
[JsonPropertyName("algorithm_version")]
public string? AlgorithmVersion { get; init; }
/// <summary>
/// Reference to the evidence used for scoring (scan ID, graph hash, etc.).
/// </summary>
[JsonPropertyName("evidence_ref")]
public string? EvidenceRef { get; init; }
/// <summary>
/// Human-readable summary of the score.
/// </summary>
[JsonPropertyName("summary")]
public string? Summary { get; init; }
/// <summary>
/// Any modifiers applied after base calculation (caps, floors, policy overrides).
/// </summary>
[JsonPropertyName("modifiers")]
public IReadOnlyList<ScoreModifier>? Modifiers { get; init; }
}
/// <summary>
/// Individual contribution to the risk score.
/// </summary>
public sealed record ScoreContribution
{
/// <summary>
/// Factor name (cvss_base, epss, reachability, gate_multiplier, vex_override, etc.).
/// </summary>
[JsonPropertyName("factor")]
public string Factor { get; init; } = string.Empty;
/// <summary>
/// Weight applied to this factor (0.0 to 1.0).
/// </summary>
[JsonPropertyName("weight")]
public double Weight { get; init; }
/// <summary>
/// Raw value before weighting.
/// </summary>
[JsonPropertyName("raw_value")]
public double RawValue { get; init; }
/// <summary>
/// Weighted contribution to final score.
/// </summary>
[JsonPropertyName("contribution")]
public double Contribution { get; init; }
/// <summary>
/// Human-readable explanation of this factor.
/// </summary>
[JsonPropertyName("explanation")]
public string? Explanation { get; init; }
/// <summary>
/// Source of the factor value (nvd, first, scan, vex, policy).
/// </summary>
[JsonPropertyName("source")]
public string? Source { get; init; }
/// <summary>
/// When this factor value was last updated.
/// </summary>
[JsonPropertyName("updated_at")]
public DateTimeOffset? UpdatedAt { get; init; }
/// <summary>
/// Confidence in this factor (0.0 to 1.0).
/// </summary>
[JsonPropertyName("confidence")]
public double? Confidence { get; init; }
}
/// <summary>
/// Modifier applied to the score after base calculation.
/// </summary>
public sealed record ScoreModifier
{
/// <summary>
/// Type of modifier (cap, floor, policy_override, vex_reduction, etc.).
/// </summary>
[JsonPropertyName("type")]
public string Type { get; init; } = string.Empty;
/// <summary>
/// Original value before modifier.
/// </summary>
[JsonPropertyName("before")]
public double Before { get; init; }
/// <summary>
/// Value after modifier.
/// </summary>
[JsonPropertyName("after")]
public double After { get; init; }
/// <summary>
/// Reason for the modifier.
/// </summary>
[JsonPropertyName("reason")]
public string? Reason { get; init; }
/// <summary>
/// Policy or rule that triggered the modifier.
/// </summary>
[JsonPropertyName("policy_ref")]
public string? PolicyRef { get; init; }
}
/// <summary>
/// Well-known score factor names.
/// </summary>
public static class ScoreFactors
{
/// <summary>CVSS v4 base score.</summary>
public const string CvssBase = "cvss_base";
/// <summary>CVSS v4 environmental score.</summary>
public const string CvssEnvironmental = "cvss_environmental";
/// <summary>EPSS probability score.</summary>
public const string Epss = "epss";
/// <summary>Reachability analysis result.</summary>
public const string Reachability = "reachability";
/// <summary>Gate-based multiplier (auth, feature flags, etc.).</summary>
public const string GateMultiplier = "gate_multiplier";
/// <summary>VEX-based status override.</summary>
public const string VexOverride = "vex_override";
/// <summary>Time-based decay (older vulnerabilities).</summary>
public const string TimeDecay = "time_decay";
/// <summary>Exposure surface multiplier.</summary>
public const string ExposureSurface = "exposure_surface";
/// <summary>Known exploitation status (KEV, etc.).</summary>
public const string KnownExploitation = "known_exploitation";
/// <summary>Asset criticality multiplier.</summary>
public const string AssetCriticality = "asset_criticality";
}

View File

@@ -0,0 +1,128 @@
// -----------------------------------------------------------------------------
// ScoreExplanationWeights.cs
// Sprint: SPRINT_3800_0001_0002_score_explanation_service
// Description: Configurable weights for additive score explanation.
// -----------------------------------------------------------------------------
using System;
namespace StellaOps.Signals.Options;
/// <summary>
/// Configurable weights for the additive score explanation model.
/// Total score is computed as sum of weighted contributions (0-100 range).
/// </summary>
public sealed class ScoreExplanationWeights
{
/// <summary>
/// Multiplier for CVSS base score (10.0 CVSS × 5.0 = 50 points max).
/// </summary>
public double CvssMultiplier { get; set; } = 5.0;
/// <summary>
/// Points when path reaches entrypoint directly.
/// </summary>
public double EntrypointReachability { get; set; } = 25.0;
/// <summary>
/// Points for direct reachability (caller directly invokes vulnerable code).
/// </summary>
public double DirectReachability { get; set; } = 20.0;
/// <summary>
/// Points for runtime-observed reachability.
/// </summary>
public double RuntimeReachability { get; set; } = 22.0;
/// <summary>
/// Points for unknown reachability status.
/// </summary>
public double UnknownReachability { get; set; } = 12.0;
/// <summary>
/// Points for unreachable paths (typically 0).
/// </summary>
public double UnreachableReachability { get; set; } = 0.0;
/// <summary>
/// Points for HTTP/HTTPS exposed entrypoints.
/// </summary>
public double HttpExposure { get; set; } = 15.0;
/// <summary>
/// Points for gRPC exposed entrypoints.
/// </summary>
public double GrpcExposure { get; set; } = 12.0;
/// <summary>
/// Points for internal-only exposure (not internet-facing).
/// </summary>
public double InternalExposure { get; set; } = 5.0;
/// <summary>
/// Points for CLI or scheduled task exposure.
/// </summary>
public double CliExposure { get; set; } = 3.0;
/// <summary>
/// Discount (negative) when auth gate is detected.
/// </summary>
public double AuthGateDiscount { get; set; } = -3.0;
/// <summary>
/// Discount (negative) when admin-only gate is detected.
/// </summary>
public double AdminGateDiscount { get; set; } = -5.0;
/// <summary>
/// Discount (negative) when feature flag gate is detected.
/// </summary>
public double FeatureFlagDiscount { get; set; } = -2.0;
/// <summary>
/// Discount (negative) when non-default config gate is detected.
/// </summary>
public double NonDefaultConfigDiscount { get; set; } = -2.0;
/// <summary>
/// Multiplier for EPSS probability (0.0-1.0 → 0-10 points).
/// </summary>
public double EpssMultiplier { get; set; } = 10.0;
/// <summary>
/// Bonus for known exploited vulnerabilities (KEV).
/// </summary>
public double KevBonus { get; set; } = 10.0;
/// <summary>
/// Minimum score floor.
/// </summary>
public double MinScore { get; set; } = 0.0;
/// <summary>
/// Maximum score ceiling.
/// </summary>
public double MaxScore { get; set; } = 100.0;
/// <summary>
/// Validates the configuration.
/// </summary>
public void Validate()
{
if (CvssMultiplier < 0)
throw new ArgumentOutOfRangeException(nameof(CvssMultiplier), CvssMultiplier, "Must be non-negative.");
if (MinScore >= MaxScore)
throw new ArgumentException("MinScore must be less than MaxScore.");
// Discounts should be negative or zero
if (AuthGateDiscount > 0)
throw new ArgumentOutOfRangeException(nameof(AuthGateDiscount), AuthGateDiscount, "Discounts should be negative or zero.");
if (AdminGateDiscount > 0)
throw new ArgumentOutOfRangeException(nameof(AdminGateDiscount), AdminGateDiscount, "Discounts should be negative or zero.");
if (FeatureFlagDiscount > 0)
throw new ArgumentOutOfRangeException(nameof(FeatureFlagDiscount), FeatureFlagDiscount, "Discounts should be negative or zero.");
}
}

View File

@@ -12,6 +12,11 @@ public sealed class SignalsScoringOptions
/// </summary>
public SignalsGateMultiplierOptions GateMultipliers { get; } = new();
/// <summary>
/// Score explanation weights for additive risk scoring (Sprint: SPRINT_3800_0001_0002).
/// </summary>
public ScoreExplanationWeights ExplanationWeights { get; } = new();
/// <summary>
/// Confidence assigned when a path exists from entry point to target.
/// </summary>
@@ -68,6 +73,7 @@ public sealed class SignalsScoringOptions
public void Validate()
{
GateMultipliers.Validate();
ExplanationWeights.Validate();
EnsurePercent(nameof(ReachableConfidence), ReachableConfidence);
EnsurePercent(nameof(UnreachableConfidence), UnreachableConfidence);

View File

@@ -0,0 +1,84 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Signals.Models;
namespace StellaOps.Signals.Persistence;
/// <summary>
/// Repository for projecting callgraph documents into relational tables.
/// </summary>
public interface ICallGraphProjectionRepository
{
/// <summary>
/// Upserts or creates a scan record.
/// </summary>
/// <param name="scanId">The scan identifier.</param>
/// <param name="artifactDigest">The artifact digest.</param>
/// <param name="sbomDigest">Optional SBOM digest.</param>
/// <param name="repoUri">Optional repository URI.</param>
/// <param name="commitSha">Optional commit SHA.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if created, false if already existed.</returns>
Task<bool> UpsertScanAsync(
Guid scanId,
string artifactDigest,
string? sbomDigest = null,
string? repoUri = null,
string? commitSha = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Marks a scan as completed.
/// </summary>
Task CompleteScanAsync(Guid scanId, CancellationToken cancellationToken = default);
/// <summary>
/// Marks a scan as failed.
/// </summary>
Task FailScanAsync(Guid scanId, string errorMessage, CancellationToken cancellationToken = default);
/// <summary>
/// Upserts nodes into the relational cg_nodes table.
/// </summary>
/// <param name="scanId">The scan identifier.</param>
/// <param name="nodes">The nodes to upsert.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Number of nodes upserted.</returns>
Task<int> UpsertNodesAsync(
Guid scanId,
IReadOnlyList<CallgraphNode> nodes,
CancellationToken cancellationToken = default);
/// <summary>
/// Upserts edges into the relational cg_edges table.
/// </summary>
/// <param name="scanId">The scan identifier.</param>
/// <param name="edges">The edges to upsert.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Number of edges upserted.</returns>
Task<int> UpsertEdgesAsync(
Guid scanId,
IReadOnlyList<CallgraphEdge> edges,
CancellationToken cancellationToken = default);
/// <summary>
/// Upserts entrypoints into the relational entrypoints table.
/// </summary>
/// <param name="scanId">The scan identifier.</param>
/// <param name="entrypoints">The entrypoints to upsert.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Number of entrypoints upserted.</returns>
Task<int> UpsertEntrypointsAsync(
Guid scanId,
IReadOnlyList<CallgraphEntrypoint> entrypoints,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes all relational data for a scan (cascading via FK).
/// </summary>
/// <param name="scanId">The scan identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DeleteScanAsync(Guid scanId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,156 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Signals.Models;
namespace StellaOps.Signals.Persistence;
/// <summary>
/// In-memory implementation of <see cref="ICallGraphProjectionRepository"/> for testing.
/// </summary>
public sealed class InMemoryCallGraphProjectionRepository : ICallGraphProjectionRepository
{
private readonly ConcurrentDictionary<Guid, ScanRecord> _scans = new();
private readonly ConcurrentDictionary<(Guid ScanId, string NodeId), NodeRecord> _nodes = new();
private readonly ConcurrentDictionary<(Guid ScanId, string FromId, string ToId), EdgeRecord> _edges = new();
private readonly ConcurrentDictionary<(Guid ScanId, string NodeId, string Kind), EntrypointRecord> _entrypoints = new();
public Task<bool> UpsertScanAsync(
Guid scanId,
string artifactDigest,
string? sbomDigest = null,
string? repoUri = null,
string? commitSha = null,
CancellationToken cancellationToken = default)
{
var wasInserted = !_scans.ContainsKey(scanId);
_scans[scanId] = new ScanRecord(scanId, artifactDigest, sbomDigest, repoUri, commitSha, "processing", null);
return Task.FromResult(wasInserted);
}
public Task CompleteScanAsync(Guid scanId, CancellationToken cancellationToken = default)
{
if (_scans.TryGetValue(scanId, out var scan))
{
_scans[scanId] = scan with { Status = "completed", CompletedAt = DateTimeOffset.UtcNow };
}
return Task.CompletedTask;
}
public Task FailScanAsync(Guid scanId, string errorMessage, CancellationToken cancellationToken = default)
{
if (_scans.TryGetValue(scanId, out var scan))
{
_scans[scanId] = scan with { Status = "failed", ErrorMessage = errorMessage, CompletedAt = DateTimeOffset.UtcNow };
}
return Task.CompletedTask;
}
public Task<int> UpsertNodesAsync(
Guid scanId,
IReadOnlyList<CallgraphNode> nodes,
CancellationToken cancellationToken = default)
{
var count = 0;
foreach (var node in nodes.OrderBy(n => n.Id, StringComparer.Ordinal))
{
var key = (scanId, node.Id);
_nodes[key] = new NodeRecord(scanId, node.Id, node.Name, node.Namespace, node.Purl);
count++;
}
return Task.FromResult(count);
}
public Task<int> UpsertEdgesAsync(
Guid scanId,
IReadOnlyList<CallgraphEdge> edges,
CancellationToken cancellationToken = default)
{
var count = 0;
foreach (var edge in edges.OrderBy(e => e.SourceId, StringComparer.Ordinal)
.ThenBy(e => e.TargetId, StringComparer.Ordinal))
{
var key = (scanId, edge.SourceId, edge.TargetId);
_edges[key] = new EdgeRecord(scanId, edge.SourceId, edge.TargetId, edge.Kind.ToString(), edge.Weight);
count++;
}
return Task.FromResult(count);
}
public Task<int> UpsertEntrypointsAsync(
Guid scanId,
IReadOnlyList<CallgraphEntrypoint> entrypoints,
CancellationToken cancellationToken = default)
{
var count = 0;
foreach (var ep in entrypoints.OrderBy(e => e.NodeId, StringComparer.Ordinal))
{
var key = (scanId, ep.NodeId, ep.Kind.ToString());
_entrypoints[key] = new EntrypointRecord(scanId, ep.NodeId, ep.Kind.ToString(), ep.Route, ep.HttpMethod);
count++;
}
return Task.FromResult(count);
}
public Task DeleteScanAsync(Guid scanId, CancellationToken cancellationToken = default)
{
_scans.TryRemove(scanId, out _);
foreach (var key in _nodes.Keys.Where(k => k.ScanId == scanId).ToList())
{
_nodes.TryRemove(key, out _);
}
foreach (var key in _edges.Keys.Where(k => k.ScanId == scanId).ToList())
{
_edges.TryRemove(key, out _);
}
foreach (var key in _entrypoints.Keys.Where(k => k.ScanId == scanId).ToList())
{
_entrypoints.TryRemove(key, out _);
}
return Task.CompletedTask;
}
// Accessors for testing
public IReadOnlyDictionary<Guid, ScanRecord> Scans => _scans;
public IReadOnlyDictionary<(Guid ScanId, string NodeId), NodeRecord> Nodes => _nodes;
public IReadOnlyDictionary<(Guid ScanId, string FromId, string ToId), EdgeRecord> Edges => _edges;
public IReadOnlyDictionary<(Guid ScanId, string NodeId, string Kind), EntrypointRecord> Entrypoints => _entrypoints;
public sealed record ScanRecord(
Guid ScanId,
string ArtifactDigest,
string? SbomDigest,
string? RepoUri,
string? CommitSha,
string Status,
DateTimeOffset? CompletedAt,
string? ErrorMessage = null);
public sealed record NodeRecord(
Guid ScanId,
string NodeId,
string Name,
string? Namespace,
string? Purl);
public sealed record EdgeRecord(
Guid ScanId,
string FromId,
string ToId,
string Kind,
double Weight);
public sealed record EntrypointRecord(
Guid ScanId,
string NodeId,
string Kind,
string? Route,
string? HttpMethod);
}

View File

@@ -83,6 +83,7 @@ builder.Services.AddRouting(options => options.LowercaseUrls = true);
builder.Services.AddSingleton<ICallgraphRepository, InMemoryCallgraphRepository>();
builder.Services.AddSingleton<ICallgraphNormalizationService, CallgraphNormalizationService>();
builder.Services.AddSingleton<ICallGraphProjectionRepository, InMemoryCallGraphProjectionRepository>();
// Configure callgraph artifact storage based on driver
if (bootstrap.Storage.IsRustFsDriver())
@@ -117,7 +118,7 @@ builder.Services.AddSingleton<ICallgraphParser>(new SimpleJsonCallgraphParser("p
builder.Services.AddSingleton<ICallgraphParser>(new SimpleJsonCallgraphParser("go"));
builder.Services.AddSingleton<ICallgraphParserResolver, CallgraphParserResolver>();
builder.Services.AddSingleton<ICallgraphIngestionService, CallgraphIngestionService>();
builder.Services.AddSingleton<ICallGraphSyncService, NullCallGraphSyncService>();
builder.Services.AddSingleton<ICallGraphSyncService, CallGraphSyncService>();
builder.Services.AddSingleton<IReachabilityCache>(sp =>
{
var options = sp.GetRequiredService<IOptions<SignalsOptions>>().Value;
@@ -198,6 +199,7 @@ builder.Services.AddSingleton<IEventsPublisher>(sp =>
eventBuilder);
});
builder.Services.AddSingleton<IReachabilityScoringService, ReachabilityScoringService>();
builder.Services.AddSingleton<IScoreExplanationService, ScoreExplanationService>(); // Sprint: SPRINT_3800_0001_0002
builder.Services.AddSingleton<IRuntimeFactsProvenanceNormalizer, RuntimeFactsProvenanceNormalizer>();
builder.Services.AddSingleton<IRuntimeFactsIngestionService, RuntimeFactsIngestionService>();
builder.Services.AddSingleton<IReachabilityUnionIngestionService, ReachabilityUnionIngestionService>();

View File

@@ -0,0 +1,169 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Signals.Models;
using StellaOps.Signals.Persistence;
namespace StellaOps.Signals.Services;
/// <summary>
/// Synchronizes canonical callgraph documents to relational tables.
/// </summary>
public sealed class CallGraphSyncService : ICallGraphSyncService
{
private readonly ICallGraphProjectionRepository _projectionRepository;
private readonly ILogger<CallGraphSyncService> _logger;
private readonly TimeProvider _timeProvider;
public CallGraphSyncService(
ICallGraphProjectionRepository projectionRepository,
TimeProvider timeProvider,
ILogger<CallGraphSyncService> logger)
{
_projectionRepository = projectionRepository ?? throw new ArgumentNullException(nameof(projectionRepository));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<CallGraphSyncResult> SyncAsync(
Guid scanId,
string artifactDigest,
CallgraphDocument document,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(document);
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
var stopwatch = Stopwatch.StartNew();
_logger.LogInformation(
"Starting callgraph projection for scan {ScanId}, artifact {ArtifactDigest}, nodes={NodeCount}, edges={EdgeCount}",
scanId, artifactDigest, document.Nodes.Count, document.Edges.Count);
try
{
var sbomDigest = TryGetMetadataValue(document.Metadata, "sbomDigest");
if (string.IsNullOrWhiteSpace(sbomDigest))
{
sbomDigest = string.IsNullOrWhiteSpace(document.GraphHash) ? null : document.GraphHash;
}
var repoUri = TryGetMetadataValue(document.Metadata, "repoUri");
var commitSha = TryGetMetadataValue(document.Metadata, "commitSha")?.ToLowerInvariant();
var sortedNodes = document.Nodes
.OrderBy(n => n.Id, StringComparer.Ordinal)
.ToList();
var sortedEdges = document.Edges
.OrderBy(e => e.SourceId, StringComparer.Ordinal)
.ThenBy(e => e.TargetId, StringComparer.Ordinal)
.ThenBy(e => (int)e.Kind)
.ThenBy(e => (int)e.Reason)
.ThenBy(e => e.Offset ?? -1)
.ThenBy(e => e.Type, StringComparer.Ordinal)
.ToList();
var sortedEntrypoints = document.Entrypoints
.OrderBy(e => (int)e.Phase)
.ThenBy(e => e.Order)
.ThenBy(e => e.NodeId, StringComparer.Ordinal)
.ThenBy(e => e.Kind)
.ThenBy(e => e.Framework)
.ThenBy(e => e.Route, StringComparer.Ordinal)
.ThenBy(e => e.HttpMethod, StringComparer.Ordinal)
.ThenBy(e => e.Source, StringComparer.Ordinal)
.ToList();
// Step 1: Upsert scan record
await _projectionRepository.UpsertScanAsync(
scanId,
artifactDigest,
sbomDigest: sbomDigest,
repoUri: repoUri,
commitSha: commitSha,
cancellationToken: cancellationToken).ConfigureAwait(false);
// Step 2: Project nodes in stable order
var nodesProjected = await _projectionRepository.UpsertNodesAsync(
scanId,
sortedNodes,
cancellationToken).ConfigureAwait(false);
// Step 3: Project edges in stable order
var edgesProjected = await _projectionRepository.UpsertEdgesAsync(
scanId,
sortedEdges,
cancellationToken).ConfigureAwait(false);
// Step 4: Project entrypoints in stable order
var entrypointsProjected = 0;
if (sortedEntrypoints is { Count: > 0 })
{
entrypointsProjected = await _projectionRepository.UpsertEntrypointsAsync(
scanId,
sortedEntrypoints,
cancellationToken).ConfigureAwait(false);
}
// Step 5: Mark scan as completed
await _projectionRepository.CompleteScanAsync(scanId, cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
_logger.LogInformation(
"Completed callgraph projection for scan {ScanId}: nodes={NodesProjected}, edges={EdgesProjected}, entrypoints={EntrypointsProjected}, duration={DurationMs}ms",
scanId, nodesProjected, edgesProjected, entrypointsProjected, stopwatch.ElapsedMilliseconds);
return new CallGraphSyncResult(
ScanId: scanId,
NodesProjected: nodesProjected,
EdgesProjected: edgesProjected,
EntrypointsProjected: entrypointsProjected,
WasUpdated: nodesProjected > 0 || edgesProjected > 0,
DurationMs: stopwatch.ElapsedMilliseconds);
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogError(
ex,
"Failed callgraph projection for scan {ScanId} after {DurationMs}ms: {ErrorMessage}",
scanId, stopwatch.ElapsedMilliseconds, ex.Message);
await _projectionRepository.FailScanAsync(scanId, ex.Message, cancellationToken).ConfigureAwait(false);
throw;
}
}
/// <inheritdoc />
public async Task DeleteByScanAsync(Guid scanId, CancellationToken cancellationToken = default)
{
_logger.LogInformation("Deleting callgraph projection for scan {ScanId}", scanId);
await _projectionRepository.DeleteScanAsync(scanId, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Deleted callgraph projection for scan {ScanId}", scanId);
}
private static string? TryGetMetadataValue(IReadOnlyDictionary<string, string?>? metadata, string key)
{
if (metadata is null || string.IsNullOrWhiteSpace(key))
{
return null;
}
if (!metadata.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim();
}
}

View File

@@ -32,7 +32,7 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService
private readonly ICallgraphRepository repository;
private readonly IReachabilityStoreRepository reachabilityStore;
private readonly ICallgraphNormalizationService normalizer;
private readonly ICallGraphSyncService syncService;
private readonly ICallGraphSyncService callGraphSyncService;
private readonly ILogger<CallgraphIngestionService> logger;
private readonly SignalsOptions options;
private readonly TimeProvider timeProvider;
@@ -44,7 +44,7 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService
ICallgraphRepository repository,
IReachabilityStoreRepository reachabilityStore,
ICallgraphNormalizationService normalizer,
ICallGraphSyncService syncService,
ICallGraphSyncService callGraphSyncService,
IOptions<SignalsOptions> options,
TimeProvider timeProvider,
ILogger<CallgraphIngestionService> logger)
@@ -54,7 +54,7 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService
this.repository = repository ?? throw new ArgumentNullException(nameof(repository));
this.reachabilityStore = reachabilityStore ?? throw new ArgumentNullException(nameof(reachabilityStore));
this.normalizer = normalizer ?? throw new ArgumentNullException(nameof(normalizer));
this.syncService = syncService ?? throw new ArgumentNullException(nameof(syncService));
this.callGraphSyncService = callGraphSyncService ?? throw new ArgumentNullException(nameof(callGraphSyncService));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.options = options?.Value ?? throw new ArgumentNullException(nameof(options));
@@ -164,7 +164,37 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService
document.Edges,
cancellationToken).ConfigureAwait(false);
await TrySyncRelationalProjectionAsync(document, cancellationToken).ConfigureAwait(false);
// Project the callgraph into relational tables for cross-artifact queries
// This is triggered post-upsert per SPRINT_3104 requirements
var scanId = Guid.TryParse(document.Id, out var parsedScanId)
? parsedScanId
: Guid.NewGuid();
var artifactDigest = document.Artifact.Hash ?? document.GraphHash ?? document.Id;
try
{
var syncResult = await callGraphSyncService.SyncAsync(
scanId,
artifactDigest,
document,
cancellationToken).ConfigureAwait(false);
logger.LogDebug(
"Projected callgraph {Id} to relational tables: nodes={NodesProjected}, edges={EdgesProjected}, entrypoints={EntrypointsProjected}, duration={DurationMs}ms",
document.Id,
syncResult.NodesProjected,
syncResult.EdgesProjected,
syncResult.EntrypointsProjected,
syncResult.DurationMs);
}
catch (Exception ex)
{
// Log but don't fail the ingest - projection is a secondary operation
logger.LogWarning(
ex,
"Failed to project callgraph {Id} to relational tables. The JSONB document was persisted successfully.",
document.Id);
}
logger.LogInformation(
"Ingested callgraph {Language}:{Component}:{Version} (id={Id}) with {NodeCount} nodes and {EdgeCount} edges.",
@@ -188,45 +218,6 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService
document.Roots?.Count ?? 0);
}
private async Task TrySyncRelationalProjectionAsync(CallgraphDocument document, CancellationToken cancellationToken)
{
try
{
var syncRequest = new CallGraphSyncRequest(
ArtifactDigest: document.Artifact.Hash,
SbomDigest: GetMetadataValue(document.Metadata, "sbomDigest"),
RepoUri: GetMetadataValue(document.Metadata, "repoUri"),
CommitSha: GetMetadataValue(document.Metadata, "commitSha"),
PolicyDigest: GetMetadataValue(document.Metadata, "policyDigest"),
Document: document);
var result = await syncService.SyncAsync(syncRequest, cancellationToken).ConfigureAwait(false);
if (result.WasApplied)
{
logger.LogInformation("Projected callgraph {CallgraphId} into relational tables (scanId={ScanId}).", document.Id, result.ScanId);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Callgraph projection failed for callgraph {CallgraphId}.", document.Id);
}
}
private static string? GetMetadataValue(IReadOnlyDictionary<string, string?>? metadata, string key)
{
if (metadata is null || string.IsNullOrWhiteSpace(key))
{
return null;
}
if (!metadata.TryGetValue(key, out var value))
{
return null;
}
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
private static void ValidateRequest(CallgraphIngestRequest request)
{
ArgumentNullException.ThrowIfNull(request);

View File

@@ -5,18 +5,55 @@ using StellaOps.Signals.Models;
namespace StellaOps.Signals.Services;
/// <summary>
/// Synchronizes canonical callgraph documents to relational tables.
/// Enables cross-artifact queries, analytics, and efficient lookups.
/// </summary>
/// <remarks>
/// This service projects the JSONB <see cref="CallgraphDocument"/> into
/// the relational tables defined in signals.* schema (cg_nodes, cg_edges,
/// entrypoints, etc.) for efficient querying.
/// </remarks>
public interface ICallGraphSyncService
{
Task<CallGraphSyncResult> SyncAsync(CallGraphSyncRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// Projects a callgraph document into relational tables.
/// This operation is idempotent; repeated calls with the same
/// document will not create duplicates.
/// </summary>
/// <param name="scanId">The scan identifier.</param>
/// <param name="artifactDigest">The artifact digest for the scan context.</param>
/// <param name="document">The callgraph document to project.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A result indicating projection status and statistics.</returns>
Task<CallGraphSyncResult> SyncAsync(
Guid scanId,
string artifactDigest,
CallgraphDocument document,
CancellationToken cancellationToken = default);
/// <summary>
/// Removes all relational data for a given scan.
/// Used for cleanup or re-projection.
/// </summary>
/// <param name="scanId">The scan identifier to clean up.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DeleteByScanAsync(Guid scanId, CancellationToken cancellationToken = default);
}
public sealed record CallGraphSyncRequest(
string ArtifactDigest,
string? SbomDigest,
string? RepoUri,
string? CommitSha,
string? PolicyDigest,
CallgraphDocument Document);
public sealed record CallGraphSyncResult(Guid ScanId, bool WasApplied);
/// <summary>
/// Result of a call graph sync operation.
/// </summary>
/// <param name="ScanId">The scan identifier.</param>
/// <param name="NodesProjected">Number of nodes projected.</param>
/// <param name="EdgesProjected">Number of edges projected.</param>
/// <param name="EntrypointsProjected">Number of entrypoints projected.</param>
/// <param name="WasUpdated">True if any data was inserted/updated.</param>
/// <param name="DurationMs">Duration of the sync operation in milliseconds.</param>
public sealed record CallGraphSyncResult(
Guid ScanId,
int NodesProjected,
int EdgesProjected,
int EntrypointsProjected,
bool WasUpdated,
long DurationMs);

View File

@@ -0,0 +1,92 @@
// -----------------------------------------------------------------------------
// IScoreExplanationService.cs
// Sprint: SPRINT_3800_0001_0002_score_explanation_service
// Description: Interface for computing additive score explanations.
// -----------------------------------------------------------------------------
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Signals.Models;
namespace StellaOps.Signals.Services;
/// <summary>
/// Service for computing additive score explanations.
/// Transforms reachability data, CVSS scores, and gate information into
/// human-readable score contributions.
/// </summary>
public interface IScoreExplanationService
{
/// <summary>
/// Computes a score explanation for a reachability fact.
/// </summary>
/// <param name="request">The score explanation request containing all input data.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A score explanation with contributions summing to the risk score.</returns>
Task<ScoreExplanation> ComputeExplanationAsync(
ScoreExplanationRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Computes a score explanation synchronously.
/// </summary>
/// <param name="request">The score explanation request.</param>
/// <returns>A score explanation with contributions.</returns>
ScoreExplanation ComputeExplanation(ScoreExplanationRequest request);
}
/// <summary>
/// Request for computing a score explanation.
/// </summary>
public sealed record ScoreExplanationRequest
{
/// <summary>
/// CVE identifier.
/// </summary>
public string? CveId { get; init; }
/// <summary>
/// CVSS v4 base score (0.0-10.0).
/// </summary>
public double? CvssScore { get; init; }
/// <summary>
/// EPSS probability (0.0-1.0).
/// </summary>
public double? EpssScore { get; init; }
/// <summary>
/// Reachability bucket (entrypoint, direct, runtime, unknown, unreachable).
/// </summary>
public string? ReachabilityBucket { get; init; }
/// <summary>
/// Entrypoint type (http, grpc, cli, internal).
/// </summary>
public string? EntrypointType { get; init; }
/// <summary>
/// Detected gates protecting the path.
/// </summary>
public IReadOnlyList<string>? Gates { get; init; }
/// <summary>
/// Whether the vulnerability is in the KEV list.
/// </summary>
public bool IsKnownExploited { get; init; }
/// <summary>
/// Whether the path is internet-facing.
/// </summary>
public bool? IsInternetFacing { get; init; }
/// <summary>
/// VEX status if available.
/// </summary>
public string? VexStatus { get; init; }
/// <summary>
/// Reference to the evidence source (scan ID, graph hash, etc.).
/// </summary>
public string? EvidenceRef { get; init; }
}

View File

@@ -1,11 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Signals.Services;
internal sealed class NullCallGraphSyncService : ICallGraphSyncService
{
public Task<CallGraphSyncResult> SyncAsync(CallGraphSyncRequest request, CancellationToken cancellationToken = default)
=> Task.FromResult(new CallGraphSyncResult(ScanId: default, WasApplied: false));
}

View File

@@ -0,0 +1,315 @@
// -----------------------------------------------------------------------------
// ScoreExplanationService.cs
// Sprint: SPRINT_3800_0001_0002_score_explanation_service
// Description: Implementation of additive score explanation computation.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Signals.Models;
using StellaOps.Signals.Options;
namespace StellaOps.Signals.Services;
/// <summary>
/// Computes additive score explanations for vulnerability findings.
/// The score is computed as a sum of weighted factors, each with a human-readable explanation.
/// </summary>
public sealed class ScoreExplanationService : IScoreExplanationService
{
private readonly IOptions<SignalsScoringOptions> _options;
private readonly ILogger<ScoreExplanationService> _logger;
private readonly TimeProvider _timeProvider;
public ScoreExplanationService(
IOptions<SignalsScoringOptions> options,
ILogger<ScoreExplanationService> logger,
TimeProvider? timeProvider = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public Task<ScoreExplanation> ComputeExplanationAsync(
ScoreExplanationRequest request,
CancellationToken cancellationToken = default)
{
return Task.FromResult(ComputeExplanation(request));
}
/// <inheritdoc />
public ScoreExplanation ComputeExplanation(ScoreExplanationRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var weights = _options.Value.ExplanationWeights;
var contributions = new List<ScoreContribution>();
var modifiers = new List<ScoreModifier>();
double runningTotal = 0.0;
// 1. CVSS Base Score Contribution
if (request.CvssScore.HasValue)
{
var cvssContribution = request.CvssScore.Value * weights.CvssMultiplier;
contributions.Add(new ScoreContribution
{
Factor = ScoreFactors.CvssBase,
Weight = weights.CvssMultiplier,
RawValue = request.CvssScore.Value,
Contribution = cvssContribution,
Explanation = $"CVSS base score {request.CvssScore.Value:F1} × {weights.CvssMultiplier:F1} weight",
Source = "nvd"
});
runningTotal += cvssContribution;
}
// 2. EPSS Contribution
if (request.EpssScore.HasValue)
{
var epssContribution = request.EpssScore.Value * weights.EpssMultiplier;
contributions.Add(new ScoreContribution
{
Factor = ScoreFactors.Epss,
Weight = weights.EpssMultiplier,
RawValue = request.EpssScore.Value,
Contribution = epssContribution,
Explanation = $"EPSS probability {request.EpssScore.Value:P1} indicates exploitation likelihood",
Source = "first"
});
runningTotal += epssContribution;
}
// 3. Reachability Contribution
if (!string.IsNullOrEmpty(request.ReachabilityBucket))
{
var (reachabilityContribution, reachabilityExplanation) = ComputeReachabilityContribution(
request.ReachabilityBucket, weights);
contributions.Add(new ScoreContribution
{
Factor = ScoreFactors.Reachability,
Weight = 1.0,
RawValue = reachabilityContribution,
Contribution = reachabilityContribution,
Explanation = reachabilityExplanation,
Source = "scan"
});
runningTotal += reachabilityContribution;
}
// 4. Exposure Surface Contribution
if (!string.IsNullOrEmpty(request.EntrypointType))
{
var (exposureContribution, exposureExplanation) = ComputeExposureContribution(
request.EntrypointType, request.IsInternetFacing, weights);
contributions.Add(new ScoreContribution
{
Factor = ScoreFactors.ExposureSurface,
Weight = 1.0,
RawValue = exposureContribution,
Contribution = exposureContribution,
Explanation = exposureExplanation,
Source = "scan"
});
runningTotal += exposureContribution;
}
// 5. Gate Multipliers (Discounts)
if (request.Gates is { Count: > 0 })
{
var (gateDiscount, gateExplanation) = ComputeGateDiscounts(request.Gates, weights);
if (gateDiscount != 0)
{
contributions.Add(new ScoreContribution
{
Factor = ScoreFactors.GateMultiplier,
Weight = 1.0,
RawValue = gateDiscount,
Contribution = gateDiscount,
Explanation = gateExplanation,
Source = "scan"
});
runningTotal += gateDiscount;
}
}
// 6. Known Exploitation Bonus
if (request.IsKnownExploited)
{
contributions.Add(new ScoreContribution
{
Factor = ScoreFactors.KnownExploitation,
Weight = 1.0,
RawValue = weights.KevBonus,
Contribution = weights.KevBonus,
Explanation = "Vulnerability is in CISA KEV list (known exploited)",
Source = "cisa_kev"
});
runningTotal += weights.KevBonus;
}
// 7. VEX Override (if not_affected, reduce to near-zero)
if (string.Equals(request.VexStatus, "not_affected", StringComparison.OrdinalIgnoreCase))
{
var vexReduction = -(runningTotal * 0.9); // Reduce by 90%
modifiers.Add(new ScoreModifier
{
Type = "vex_reduction",
Before = runningTotal,
After = runningTotal + vexReduction,
Reason = "VEX statement indicates vulnerability is not exploitable in this context",
PolicyRef = "vex:not_affected"
});
runningTotal += vexReduction;
}
// Apply floor/ceiling
var originalTotal = runningTotal;
runningTotal = Math.Clamp(runningTotal, weights.MinScore, weights.MaxScore);
if (runningTotal != originalTotal)
{
modifiers.Add(new ScoreModifier
{
Type = runningTotal < originalTotal ? "cap" : "floor",
Before = originalTotal,
After = runningTotal,
Reason = $"Score clamped to {weights.MinScore:F0}-{weights.MaxScore:F0} range"
});
}
_logger.LogDebug(
"Computed score explanation: {Score:F2} with {ContributionCount} contributions for {CveId}",
runningTotal, contributions.Count, request.CveId ?? "unknown");
return new ScoreExplanation
{
Kind = "stellaops_risk_v1",
RiskScore = runningTotal,
Contributions = contributions,
LastSeen = _timeProvider.GetUtcNow(),
AlgorithmVersion = "1.0.0",
EvidenceRef = request.EvidenceRef,
Summary = GenerateSummary(runningTotal, contributions),
Modifiers = modifiers.Count > 0 ? modifiers : null
};
}
private static (double contribution, string explanation) ComputeReachabilityContribution(
string bucket, ScoreExplanationWeights weights)
{
return bucket.ToLowerInvariant() switch
{
"entrypoint" => (weights.EntrypointReachability,
"Vulnerable code is directly reachable from application entrypoint"),
"direct" => (weights.DirectReachability,
"Vulnerable code is directly called from application code"),
"runtime" => (weights.RuntimeReachability,
"Vulnerable code execution observed at runtime"),
"unknown" => (weights.UnknownReachability,
"Reachability could not be determined; assuming partial exposure"),
"unreachable" => (weights.UnreachableReachability,
"No path found from entrypoints to vulnerable code"),
_ => (weights.UnknownReachability,
$"Unknown reachability bucket '{bucket}'; assuming partial exposure")
};
}
private static (double contribution, string explanation) ComputeExposureContribution(
string entrypointType, bool? isInternetFacing, ScoreExplanationWeights weights)
{
var baseContribution = entrypointType.ToLowerInvariant() switch
{
"http" or "https" or "http_handler" => weights.HttpExposure,
"grpc" or "grpc_method" => weights.GrpcExposure,
"cli" or "cli_command" or "scheduled" => weights.CliExposure,
"internal" or "library" => weights.InternalExposure,
_ => weights.InternalExposure
};
var exposureType = entrypointType.ToLowerInvariant() switch
{
"http" or "https" or "http_handler" => "HTTP/HTTPS",
"grpc" or "grpc_method" => "gRPC",
"cli" or "cli_command" => "CLI",
"scheduled" => "scheduled task",
"internal" or "library" => "internal",
_ => entrypointType
};
var internetSuffix = isInternetFacing == true ? " (internet-facing)" : "";
return (baseContribution, $"Exposed via {exposureType} entrypoint{internetSuffix}");
}
private static (double discount, string explanation) ComputeGateDiscounts(
IReadOnlyList<string> gates, ScoreExplanationWeights weights)
{
double totalDiscount = 0;
var gateDescriptions = new List<string>();
foreach (var gate in gates)
{
var normalizedGate = gate.ToLowerInvariant();
if (normalizedGate.Contains("auth") || normalizedGate.Contains("authorize"))
{
totalDiscount += weights.AuthGateDiscount;
gateDescriptions.Add("authentication required");
}
else if (normalizedGate.Contains("admin") || normalizedGate.Contains("role"))
{
totalDiscount += weights.AdminGateDiscount;
gateDescriptions.Add("admin/role restriction");
}
else if (normalizedGate.Contains("feature") || normalizedGate.Contains("flag"))
{
totalDiscount += weights.FeatureFlagDiscount;
gateDescriptions.Add("feature flag protection");
}
else if (normalizedGate.Contains("config") || normalizedGate.Contains("default"))
{
totalDiscount += weights.NonDefaultConfigDiscount;
gateDescriptions.Add("non-default configuration");
}
}
if (gateDescriptions.Count == 0)
{
return (0, "No protective gates detected");
}
return (totalDiscount, $"Protected by: {string.Join(", ", gateDescriptions)}");
}
private static string GenerateSummary(double score, IReadOnlyList<ScoreContribution> contributions)
{
var severity = score switch
{
>= 80 => "Critical",
>= 60 => "High",
>= 40 => "Medium",
>= 20 => "Low",
_ => "Minimal"
};
var topFactors = contributions
.OrderByDescending(c => Math.Abs(c.Contribution))
.Take(2)
.Select(c => c.Factor)
.ToList();
var factorSummary = topFactors.Count > 0
? $" driven by {string.Join(" and ", topFactors)}"
: "";
return $"{severity} risk ({score:F0}/100){factorSummary}";
}
}

View File

@@ -12,4 +12,8 @@ This file mirrors sprint work for the Signals module.
| `GATE-3405-011` | `docs/implplan/SPRINT_3405_0001_0001_gate_multipliers.md` | DONE (2025-12-18) | Applied gate multipliers in `ReachabilityScoringService` using path gate evidence from callgraph edges. |
| `GATE-3405-012` | `docs/implplan/SPRINT_3405_0001_0001_gate_multipliers.md` | DONE (2025-12-18) | Extended reachability fact evidence contract + digest to include `GateMultiplierBps` and `Gates`. |
| `GATE-3405-016` | `docs/implplan/SPRINT_3405_0001_0001_gate_multipliers.md` | DONE (2025-12-18) | Added deterministic parser/normalizer/scoring coverage for gate propagation + multiplier effect. |
| `SIG-CG-3104-003` | `docs/implplan/SPRINT_3104_0001_0001_signals_callgraph_projection_completion.md` | DONE (2025-12-18) | Added callgraph projection trigger via `ICallGraphSyncService` (default no-op implementation). |
| `SIG-CG-3104-001` | `docs/implplan/archived/SPRINT_3104_0001_0001_signals_callgraph_projection_completion.md` | DONE (2025-12-18) | Defined `ICallGraphSyncService` contract for projecting callgraphs into relational tables. |
| `SIG-CG-3104-002` | `docs/implplan/archived/SPRINT_3104_0001_0001_signals_callgraph_projection_completion.md` | DONE (2025-12-18) | Implemented `CallGraphSyncService` + projection repositories with deterministic ordering. |
| `SIG-CG-3104-003` | `docs/implplan/archived/SPRINT_3104_0001_0001_signals_callgraph_projection_completion.md` | DONE (2025-12-18) | Wired projection trigger in `CallgraphIngestionService` post-upsert. |
| `SIG-CG-3104-004` | `docs/implplan/archived/SPRINT_3104_0001_0001_signals_callgraph_projection_completion.md` | DONE (2025-12-18) | Added unit/integration tests for projection + query semantics. |
| `SIG-CG-3104-005` | `docs/implplan/archived/SPRINT_3104_0001_0001_signals_callgraph_projection_completion.md` | DONE (2025-12-18) | Archived sprint 3104 and refreshed module bookkeeping. |

View File

@@ -0,0 +1,271 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Signals.Models;
using StellaOps.Signals.Persistence;
using StellaOps.Signals.Services;
using Xunit;
namespace StellaOps.Signals.Tests;
/// <summary>
/// Unit tests for <see cref="CallGraphSyncService"/>.
/// </summary>
public sealed class CallGraphSyncServiceTests
{
[Fact]
public async Task SyncAsync_WithValidDocument_ReturnsSuccessResult()
{
// Arrange
var repository = new InMemoryCallGraphProjectionRepository();
var service = new CallGraphSyncService(
repository,
TimeProvider.System,
NullLogger<CallGraphSyncService>.Instance);
var scanId = Guid.NewGuid();
var document = CreateSampleDocument();
// Act
var result = await service.SyncAsync(scanId, "sha256:test-digest", document);
// Assert
Assert.Equal(scanId, result.ScanId);
Assert.Equal(3, result.NodesProjected);
Assert.Equal(2, result.EdgesProjected);
Assert.Equal(1, result.EntrypointsProjected);
Assert.True(result.WasUpdated);
Assert.True(result.DurationMs >= 0);
}
[Fact]
public async Task SyncAsync_ProjectsToRepository()
{
// Arrange
var repository = new InMemoryCallGraphProjectionRepository();
var service = new CallGraphSyncService(
repository,
TimeProvider.System,
NullLogger<CallGraphSyncService>.Instance);
var scanId = Guid.NewGuid();
var document = CreateSampleDocument();
// Act
await service.SyncAsync(scanId, "sha256:test-digest", document);
// Assert - check repository state
Assert.Single(repository.Scans);
Assert.Equal(3, repository.Nodes.Count);
Assert.Equal(2, repository.Edges.Count);
Assert.Single(repository.Entrypoints);
}
[Fact]
public async Task SyncAsync_SetsScanStatusToCompleted()
{
// Arrange
var repository = new InMemoryCallGraphProjectionRepository();
var service = new CallGraphSyncService(
repository,
TimeProvider.System,
NullLogger<CallGraphSyncService>.Instance);
var scanId = Guid.NewGuid();
var document = CreateSampleDocument();
// Act
await service.SyncAsync(scanId, "sha256:test-digest", document);
// Assert
Assert.True(repository.Scans.ContainsKey(scanId));
Assert.Equal("completed", repository.Scans[scanId].Status);
}
[Fact]
public async Task SyncAsync_WithEmptyDocument_ReturnsZeroCounts()
{
// Arrange
var repository = new InMemoryCallGraphProjectionRepository();
var service = new CallGraphSyncService(
repository,
TimeProvider.System,
NullLogger<CallGraphSyncService>.Instance);
var scanId = Guid.NewGuid();
var document = new CallgraphDocument
{
Id = Guid.NewGuid().ToString("N"),
Language = "csharp",
GraphHash = "test-hash",
Nodes = new List<CallgraphNode>(),
Edges = new List<CallgraphEdge>(),
Entrypoints = new List<CallgraphEntrypoint>()
};
// Act
var result = await service.SyncAsync(scanId, "sha256:test-digest", document);
// Assert
Assert.Equal(0, result.NodesProjected);
Assert.Equal(0, result.EdgesProjected);
Assert.Equal(0, result.EntrypointsProjected);
Assert.False(result.WasUpdated);
}
[Fact]
public async Task SyncAsync_WithNullDocument_ThrowsArgumentNullException()
{
// Arrange
var repository = new InMemoryCallGraphProjectionRepository();
var service = new CallGraphSyncService(
repository,
TimeProvider.System,
NullLogger<CallGraphSyncService>.Instance);
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(() =>
service.SyncAsync(Guid.NewGuid(), "sha256:test-digest", null!));
}
[Fact]
public async Task SyncAsync_WithEmptyArtifactDigest_ThrowsArgumentException()
{
// Arrange
var repository = new InMemoryCallGraphProjectionRepository();
var service = new CallGraphSyncService(
repository,
TimeProvider.System,
NullLogger<CallGraphSyncService>.Instance);
var document = CreateSampleDocument();
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() =>
service.SyncAsync(Guid.NewGuid(), "", document));
}
[Fact]
public async Task DeleteByScanAsync_RemovesScanFromRepository()
{
// Arrange
var repository = new InMemoryCallGraphProjectionRepository();
var service = new CallGraphSyncService(
repository,
TimeProvider.System,
NullLogger<CallGraphSyncService>.Instance);
var scanId = Guid.NewGuid();
var document = CreateSampleDocument();
await service.SyncAsync(scanId, "sha256:test-digest", document);
// Act
await service.DeleteByScanAsync(scanId);
// Assert
Assert.Empty(repository.Scans);
Assert.Empty(repository.Nodes);
Assert.Empty(repository.Edges);
Assert.Empty(repository.Entrypoints);
}
[Fact]
public async Task SyncAsync_OrdersNodesAndEdgesDeterministically()
{
// Arrange
var repository = new TrackingProjectionRepository();
var service = new CallGraphSyncService(
repository,
TimeProvider.System,
NullLogger<CallGraphSyncService>.Instance);
var scanId = Guid.NewGuid();
var document = new CallgraphDocument
{
Id = Guid.NewGuid().ToString("N"),
Language = "csharp",
GraphHash = "test-hash",
Nodes = new List<CallgraphNode>
{
new() { Id = "z-node", Name = "Last" },
new() { Id = "a-node", Name = "First" },
new() { Id = "m-node", Name = "Middle" }
},
Edges = new List<CallgraphEdge>
{
new() { SourceId = "z-node", TargetId = "a-node" },
new() { SourceId = "a-node", TargetId = "m-node" }
},
Entrypoints = new List<CallgraphEntrypoint>()
};
// Act
await service.SyncAsync(scanId, "sha256:test-digest", document);
// Assert - nodes should be processed in sorted order by Id
Assert.Equal(3, repository.ProjectedNodes.Count);
Assert.Equal("a-node", repository.ProjectedNodes[0].Id);
Assert.Equal("m-node", repository.ProjectedNodes[1].Id);
Assert.Equal("z-node", repository.ProjectedNodes[2].Id);
}
private static CallgraphDocument CreateSampleDocument()
{
return new CallgraphDocument
{
Id = Guid.NewGuid().ToString("N"),
Language = "csharp",
GraphHash = "sha256:sample-graph-hash",
Nodes = new List<CallgraphNode>
{
new() { Id = "node-1", Name = "Main", Kind = "method", Namespace = "Program", Visibility = SymbolVisibility.Public, IsEntrypointCandidate = true },
new() { Id = "node-2", Name = "DoWork", Kind = "method", Namespace = "Service", Visibility = SymbolVisibility.Internal },
new() { Id = "node-3", Name = "ProcessData", Kind = "method", Namespace = "Core", Visibility = SymbolVisibility.Private }
},
Edges = new List<CallgraphEdge>
{
new() { SourceId = "node-1", TargetId = "node-2", Kind = EdgeKind.Static, Reason = EdgeReason.DirectCall, Weight = 1.0 },
new() { SourceId = "node-2", TargetId = "node-3", Kind = EdgeKind.Static, Reason = EdgeReason.DirectCall, Weight = 1.0 }
},
Entrypoints = new List<CallgraphEntrypoint>
{
new() { NodeId = "node-1", Kind = EntrypointKind.Main, Phase = EntrypointPhase.AppStart, Order = 0 }
}
};
}
/// <summary>
/// Test repository that tracks the order of projected nodes.
/// </summary>
private sealed class TrackingProjectionRepository : ICallGraphProjectionRepository
{
public List<CallgraphNode> ProjectedNodes { get; } = new();
public Task<bool> UpsertScanAsync(Guid scanId, string artifactDigest, string? sbomDigest = null, string? repoUri = null, string? commitSha = null, CancellationToken cancellationToken = default)
=> Task.FromResult(true);
public Task CompleteScanAsync(Guid scanId, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task FailScanAsync(Guid scanId, string errorMessage, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task<int> UpsertNodesAsync(Guid scanId, IReadOnlyList<CallgraphNode> nodes, CancellationToken cancellationToken = default)
{
// Store in the order received - the service should have sorted them
ProjectedNodes.AddRange(nodes);
return Task.FromResult(nodes.Count);
}
public Task<int> UpsertEdgesAsync(Guid scanId, IReadOnlyList<CallgraphEdge> edges, CancellationToken cancellationToken = default)
=> Task.FromResult(edges.Count);
public Task<int> UpsertEntrypointsAsync(Guid scanId, IReadOnlyList<CallgraphEntrypoint> entrypoints, CancellationToken cancellationToken = default)
=> Task.FromResult(entrypoints.Count);
public Task DeleteScanAsync(Guid scanId, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
}

View File

@@ -33,12 +33,14 @@ public class CallgraphIngestionServiceTests
var resolver = new StubParserResolver(parser);
var options = Microsoft.Extensions.Options.Options.Create(new SignalsOptions());
var reachabilityStore = new InMemoryReachabilityStoreRepository(_timeProvider);
var callGraphSyncService = new StubCallGraphSyncService();
var service = new CallgraphIngestionService(
resolver,
_artifactStore,
_repository,
reachabilityStore,
_normalizer,
callGraphSyncService,
options,
_timeProvider,
NullLogger<CallgraphIngestionService>.Instance);
@@ -189,4 +191,33 @@ public class CallgraphIngestionServiceTests
return Task.FromResult(document);
}
}
private sealed class StubCallGraphSyncService : ICallGraphSyncService
{
public CallGraphSyncResult? LastSyncResult { get; private set; }
public CallgraphDocument? LastSyncedDocument { get; private set; }
public Task<CallGraphSyncResult> SyncAsync(
Guid scanId,
string artifactDigest,
CallgraphDocument document,
CancellationToken cancellationToken = default)
{
LastSyncedDocument = document;
var result = new CallGraphSyncResult(
ScanId: scanId,
NodesProjected: document.Nodes.Count,
EdgesProjected: document.Edges.Count,
EntrypointsProjected: document.Entrypoints.Count,
WasUpdated: true,
DurationMs: 1);
LastSyncResult = result;
return Task.FromResult(result);
}
public Task DeleteByScanAsync(Guid scanId, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,287 @@
// -----------------------------------------------------------------------------
// ScoreExplanationServiceTests.cs
// Sprint: SPRINT_3800_0001_0002_score_explanation_service
// Description: Unit tests for ScoreExplanationService.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Signals.Models;
using StellaOps.Signals.Options;
using StellaOps.Signals.Services;
using Xunit;
namespace StellaOps.Signals.Tests;
public class ScoreExplanationServiceTests
{
private readonly ScoreExplanationService _service;
private readonly SignalsScoringOptions _options;
public ScoreExplanationServiceTests()
{
_options = new SignalsScoringOptions();
_service = new ScoreExplanationService(
Microsoft.Extensions.Options.Options.Create(_options),
NullLogger<ScoreExplanationService>.Instance);
}
[Fact]
public void ComputeExplanation_WithCvssOnly_ReturnsCorrectContribution()
{
var request = new ScoreExplanationRequest
{
CveId = "CVE-2021-44228",
CvssScore = 10.0
};
var result = _service.ComputeExplanation(request);
Assert.Equal("stellaops_risk_v1", result.Kind);
Assert.Single(result.Contributions);
var cvssContrib = result.Contributions[0];
Assert.Equal(ScoreFactors.CvssBase, cvssContrib.Factor);
Assert.Equal(10.0, cvssContrib.RawValue);
Assert.Equal(50.0, cvssContrib.Contribution); // 10.0 * 5.0 default multiplier
Assert.Equal(50.0, result.RiskScore);
}
[Fact]
public void ComputeExplanation_WithEpss_ReturnsCorrectContribution()
{
var request = new ScoreExplanationRequest
{
CveId = "CVE-2023-12345",
EpssScore = 0.5 // 50% probability
};
var result = _service.ComputeExplanation(request);
var epssContrib = result.Contributions.Single(c => c.Factor == ScoreFactors.Epss);
Assert.Equal(0.5, epssContrib.RawValue);
Assert.Equal(5.0, epssContrib.Contribution); // 0.5 * 10.0 default multiplier
}
[Theory]
[InlineData("entrypoint", 25.0)]
[InlineData("direct", 20.0)]
[InlineData("runtime", 22.0)]
[InlineData("unknown", 12.0)]
[InlineData("unreachable", 0.0)]
public void ComputeExplanation_WithReachabilityBucket_ReturnsCorrectContribution(
string bucket, double expectedContribution)
{
var request = new ScoreExplanationRequest
{
ReachabilityBucket = bucket
};
var result = _service.ComputeExplanation(request);
var reachContrib = result.Contributions.Single(c => c.Factor == ScoreFactors.Reachability);
Assert.Equal(expectedContribution, reachContrib.Contribution);
}
[Theory]
[InlineData("http", 15.0)]
[InlineData("https", 15.0)]
[InlineData("http_handler", 15.0)]
[InlineData("grpc", 12.0)]
[InlineData("cli", 3.0)]
[InlineData("internal", 5.0)]
public void ComputeExplanation_WithEntrypointType_ReturnsCorrectExposure(
string entrypointType, double expectedContribution)
{
var request = new ScoreExplanationRequest
{
EntrypointType = entrypointType
};
var result = _service.ComputeExplanation(request);
var exposureContrib = result.Contributions.Single(c => c.Factor == ScoreFactors.ExposureSurface);
Assert.Equal(expectedContribution, exposureContrib.Contribution);
}
[Fact]
public void ComputeExplanation_WithAuthGate_AppliesDiscount()
{
var request = new ScoreExplanationRequest
{
CvssScore = 8.0,
Gates = new[] { "auth_required" }
};
var result = _service.ComputeExplanation(request);
var gateContrib = result.Contributions.Single(c => c.Factor == ScoreFactors.GateMultiplier);
Assert.Equal(-3.0, gateContrib.Contribution); // Default auth discount
Assert.Equal(37.0, result.RiskScore); // 8.0 * 5.0 - 3.0
}
[Fact]
public void ComputeExplanation_WithMultipleGates_CombinesDiscounts()
{
var request = new ScoreExplanationRequest
{
CvssScore = 10.0,
Gates = new[] { "auth_required", "admin_role", "feature_flag" }
};
var result = _service.ComputeExplanation(request);
var gateContrib = result.Contributions.Single(c => c.Factor == ScoreFactors.GateMultiplier);
// auth: -3, admin: -5, feature_flag: -2 = -10 total
Assert.Equal(-10.0, gateContrib.Contribution);
Assert.Equal(40.0, result.RiskScore); // 50 - 10
}
[Fact]
public void ComputeExplanation_WithKev_AppliesBonus()
{
var request = new ScoreExplanationRequest
{
CvssScore = 7.0,
IsKnownExploited = true
};
var result = _service.ComputeExplanation(request);
var kevContrib = result.Contributions.Single(c => c.Factor == ScoreFactors.KnownExploitation);
Assert.Equal(10.0, kevContrib.Contribution);
Assert.Equal(45.0, result.RiskScore); // 7.0 * 5.0 + 10.0
}
[Fact]
public void ComputeExplanation_WithVexNotAffected_ReducesScore()
{
var request = new ScoreExplanationRequest
{
CvssScore = 10.0,
VexStatus = "not_affected"
};
var result = _service.ComputeExplanation(request);
Assert.NotNull(result.Modifiers);
Assert.Contains(result.Modifiers, m => m.Type == "vex_reduction");
Assert.True(result.RiskScore < 50.0); // Should be significantly reduced
}
[Fact]
public void ComputeExplanation_ClampsToMaxScore()
{
var request = new ScoreExplanationRequest
{
CvssScore = 10.0,
EpssScore = 0.95,
ReachabilityBucket = "entrypoint",
EntrypointType = "http",
IsKnownExploited = true
};
var result = _service.ComputeExplanation(request);
Assert.Equal(100.0, result.RiskScore); // Clamped to max
Assert.NotNull(result.Modifiers);
Assert.Contains(result.Modifiers, m => m.Type == "cap");
}
[Fact]
public void ComputeExplanation_ContributionsSumToTotal()
{
var request = new ScoreExplanationRequest
{
CvssScore = 8.5,
EpssScore = 0.3,
ReachabilityBucket = "direct",
EntrypointType = "grpc"
};
var result = _service.ComputeExplanation(request);
var expectedSum = result.Contributions.Sum(c => c.Contribution);
Assert.Equal(expectedSum, result.RiskScore, precision: 5);
}
[Fact]
public void ComputeExplanation_GeneratesSummary()
{
var request = new ScoreExplanationRequest
{
CvssScore = 9.8,
ReachabilityBucket = "entrypoint"
};
var result = _service.ComputeExplanation(request);
Assert.NotNull(result.Summary);
Assert.Contains("risk", result.Summary, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ComputeExplanation_SetsAlgorithmVersion()
{
var request = new ScoreExplanationRequest { CvssScore = 5.0 };
var result = _service.ComputeExplanation(request);
Assert.Equal("1.0.0", result.AlgorithmVersion);
}
[Fact]
public void ComputeExplanation_PreservesEvidenceRef()
{
var request = new ScoreExplanationRequest
{
CvssScore = 5.0,
EvidenceRef = "scan:abc123"
};
var result = _service.ComputeExplanation(request);
Assert.Equal("scan:abc123", result.EvidenceRef);
}
[Fact]
public async Task ComputeExplanationAsync_ReturnsSameAsSync()
{
var request = new ScoreExplanationRequest
{
CvssScore = 7.5,
ReachabilityBucket = "runtime"
};
var syncResult = _service.ComputeExplanation(request);
var asyncResult = await _service.ComputeExplanationAsync(request);
Assert.Equal(syncResult.RiskScore, asyncResult.RiskScore);
Assert.Equal(syncResult.Contributions.Count, asyncResult.Contributions.Count);
}
[Fact]
public void ComputeExplanation_IsDeterministic()
{
var request = new ScoreExplanationRequest
{
CvssScore = 8.0,
EpssScore = 0.4,
ReachabilityBucket = "entrypoint",
EntrypointType = "http",
Gates = new[] { "auth_required" }
};
var result1 = _service.ComputeExplanation(request);
var result2 = _service.ComputeExplanation(request);
Assert.Equal(result1.RiskScore, result2.RiskScore);
Assert.Equal(result1.Contributions.Count, result2.Contributions.Count);
for (int i = 0; i < result1.Contributions.Count; i++)
{
Assert.Equal(result1.Contributions[i].Factor, result2.Contributions[i].Factor);
Assert.Equal(result1.Contributions[i].Contribution, result2.Contributions[i].Contribution);
}
}
}