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:
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`). |
|
||||
|
||||
Reference in New Issue
Block a user