feat: add Attestation Chain and Triage Evidence API clients and models
- Implemented Attestation Chain API client with methods for verifying, fetching, and managing attestation chains. - Created models for Attestation Chain, including DSSE envelope structures and verification results. - Developed Triage Evidence API client for fetching finding evidence, including methods for evidence retrieval by CVE and component. - Added models for Triage Evidence, encapsulating evidence responses, entry points, boundary proofs, and VEX evidence. - Introduced mock implementations for both API clients to facilitate testing and development.
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Persistence;
|
||||
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
|
||||
{
|
||||
private readonly SignalsPostgresFixture _fixture;
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public CallGraphProjectionIntegrationTests(SignalsPostgresFixture fixture, ITestOutputHelper output)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_output = output;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncAsync_ProjectsNodesToRelationalTable()
|
||||
{
|
||||
// Arrange
|
||||
var dataSource = await CreateDataSourceAsync();
|
||||
var repository = new PostgresCallGraphProjectionRepository(
|
||||
dataSource,
|
||||
NullLogger<PostgresCallGraphProjectionRepository>.Instance);
|
||||
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.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()
|
||||
{
|
||||
// Arrange
|
||||
var dataSource = await CreateDataSourceAsync();
|
||||
var repository = new PostgresCallGraphProjectionRepository(
|
||||
dataSource,
|
||||
NullLogger<PostgresCallGraphProjectionRepository>.Instance);
|
||||
var service = new CallGraphSyncService(
|
||||
repository,
|
||||
TimeProvider.System,
|
||||
NullLogger<CallGraphSyncService>.Instance);
|
||||
|
||||
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()
|
||||
{
|
||||
// Arrange
|
||||
var dataSource = await CreateDataSourceAsync();
|
||||
var repository = new PostgresCallGraphProjectionRepository(
|
||||
dataSource,
|
||||
NullLogger<PostgresCallGraphProjectionRepository>.Instance);
|
||||
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 = "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()
|
||||
{
|
||||
// Arrange
|
||||
var dataSource = await CreateDataSourceAsync();
|
||||
var repository = new PostgresCallGraphProjectionRepository(
|
||||
dataSource,
|
||||
NullLogger<PostgresCallGraphProjectionRepository>.Instance);
|
||||
var queryRepository = new PostgresCallGraphQueryRepository(
|
||||
dataSource,
|
||||
NullLogger<PostgresCallGraphQueryRepository>.Instance);
|
||||
var service = new CallGraphSyncService(
|
||||
repository,
|
||||
TimeProvider.System,
|
||||
NullLogger<CallGraphSyncService>.Instance);
|
||||
|
||||
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()
|
||||
{
|
||||
// Arrange
|
||||
var dataSource = await CreateDataSourceAsync();
|
||||
var repository = new PostgresCallGraphProjectionRepository(
|
||||
dataSource,
|
||||
NullLogger<PostgresCallGraphProjectionRepository>.Instance);
|
||||
var queryRepository = new PostgresCallGraphQueryRepository(
|
||||
dataSource,
|
||||
NullLogger<PostgresCallGraphQueryRepository>.Instance);
|
||||
var service = new CallGraphSyncService(
|
||||
repository,
|
||||
TimeProvider.System,
|
||||
NullLogger<CallGraphSyncService>.Instance);
|
||||
|
||||
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 async Task<SignalsDataSource> CreateDataSourceAsync()
|
||||
{
|
||||
var connectionString = _fixture.GetConnectionString();
|
||||
var options = new Microsoft.Extensions.Options.OptionsWrapper<StellaOps.Infrastructure.Postgres.Options.PostgresOptions>(
|
||||
new StellaOps.Infrastructure.Postgres.Options.PostgresOptions { ConnectionString = connectionString });
|
||||
var dataSource = new SignalsDataSource(options);
|
||||
|
||||
// Run migration
|
||||
await _fixture.RunMigrationsAsync();
|
||||
|
||||
return dataSource;
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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) ?? 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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSingleton<IDeploymentRefsRepository, PostgresDeploymentRefsRepository>();
|
||||
services.AddSingleton<IGraphMetricsRepository, PostgresGraphMetricsRepository>();
|
||||
services.AddSingleton<ICallGraphQueryRepository, PostgresCallGraphQueryRepository>();
|
||||
services.AddSingleton<ICallGraphProjectionRepository, PostgresCallGraphProjectionRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
@@ -59,6 +60,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSingleton<IDeploymentRefsRepository, PostgresDeploymentRefsRepository>();
|
||||
services.AddSingleton<IGraphMetricsRepository, PostgresGraphMetricsRepository>();
|
||||
services.AddSingleton<ICallGraphQueryRepository, PostgresCallGraphQueryRepository>();
|
||||
services.AddSingleton<ICallGraphProjectionRepository, PostgresCallGraphProjectionRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
192
src/Signals/StellaOps.Signals/Models/ScoreExplanation.cs
Normal file
192
src/Signals/StellaOps.Signals/Models/ScoreExplanation.cs
Normal 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";
|
||||
}
|
||||
128
src/Signals/StellaOps.Signals/Options/ScoreExplanationWeights.cs
Normal file
128
src/Signals/StellaOps.Signals/Options/ScoreExplanationWeights.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,6 +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, CallGraphSyncService>();
|
||||
builder.Services.AddSingleton<IReachabilityCache>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<SignalsOptions>>().Value;
|
||||
@@ -197,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>();
|
||||
|
||||
118
src/Signals/StellaOps.Signals/Services/CallGraphSyncService.cs
Normal file
118
src/Signals/StellaOps.Signals/Services/CallGraphSyncService.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
using System;
|
||||
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>
|
||||
internal 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
|
||||
{
|
||||
// Step 1: Upsert scan record
|
||||
await _projectionRepository.UpsertScanAsync(
|
||||
scanId,
|
||||
artifactDigest,
|
||||
document.GraphHash,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Step 2: Project nodes in stable order
|
||||
var nodesProjected = await _projectionRepository.UpsertNodesAsync(
|
||||
scanId,
|
||||
document.Nodes,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Step 3: Project edges in stable order
|
||||
var edgesProjected = await _projectionRepository.UpsertEdgesAsync(
|
||||
scanId,
|
||||
document.Edges,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Step 4: Project entrypoints in stable order
|
||||
var entrypointsProjected = 0;
|
||||
if (document.Entrypoints is { Count: > 0 })
|
||||
{
|
||||
entrypointsProjected = await _projectionRepository.UpsertEntrypointsAsync(
|
||||
scanId,
|
||||
document.Entrypoints,
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService
|
||||
private readonly ICallgraphRepository repository;
|
||||
private readonly IReachabilityStoreRepository reachabilityStore;
|
||||
private readonly ICallgraphNormalizationService normalizer;
|
||||
private readonly ICallGraphSyncService callGraphSyncService;
|
||||
private readonly ILogger<CallgraphIngestionService> logger;
|
||||
private readonly SignalsOptions options;
|
||||
private readonly TimeProvider timeProvider;
|
||||
@@ -43,6 +44,7 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService
|
||||
ICallgraphRepository repository,
|
||||
IReachabilityStoreRepository reachabilityStore,
|
||||
ICallgraphNormalizationService normalizer,
|
||||
ICallGraphSyncService callGraphSyncService,
|
||||
IOptions<SignalsOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<CallgraphIngestionService> logger)
|
||||
@@ -52,6 +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.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));
|
||||
@@ -161,6 +164,38 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService
|
||||
document.Edges,
|
||||
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.",
|
||||
document.Language,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
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
|
||||
{
|
||||
/// <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);
|
||||
}
|
||||
|
||||
/// <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);
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -12,3 +12,7 @@ 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-001` | `docs/implplan/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/SPRINT_3104_0001_0001_signals_callgraph_projection_completion.md` | DONE (2025-12-18) | Implemented `CallGraphSyncService` with idempotent, transactional batch projection. |
|
||||
| `SIG-CG-3104-003` | `docs/implplan/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/SPRINT_3104_0001_0001_signals_callgraph_projection_completion.md` | DONE (2025-12-18) | Added unit tests (`CallGraphSyncServiceTests.cs`) and integration tests (`CallGraphProjectionIntegrationTests.cs`). |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user