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:
master
2025-12-18 13:15:13 +02:00
parent 7d5250238c
commit 00d2c99af9
118 changed files with 13463 additions and 151 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -83,6 +83,7 @@ builder.Services.AddRouting(options => options.LowercaseUrls = true);
builder.Services.AddSingleton<ICallgraphRepository, InMemoryCallgraphRepository>();
builder.Services.AddSingleton<ICallgraphNormalizationService, CallgraphNormalizationService>();
builder.Services.AddSingleton<ICallGraphProjectionRepository, InMemoryCallGraphProjectionRepository>();
// Configure callgraph artifact storage based on driver
if (bootstrap.Storage.IsRustFsDriver())
@@ -117,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>();

View 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);
}
}

View File

@@ -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,

View File

@@ -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);

View File

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

View File

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

View File

@@ -12,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`). |