Add SBOM, symbols, traces, and VEX files for CVE-2022-21661 SQLi case
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Created CycloneDX and SPDX SBOM files for both reachable and unreachable images.
- Added symbols.json detailing function entry and sink points in the WordPress code.
- Included runtime traces for function calls in both reachable and unreachable scenarios.
- Developed OpenVEX files indicating vulnerability status and justification for both cases.
- Updated README for evaluator harness to guide integration with scanner output.
This commit is contained in:
master
2025-11-08 20:53:45 +02:00
parent 515975edc5
commit 536f6249a6
837 changed files with 37279 additions and 14675 deletions

View File

@@ -0,0 +1,98 @@
using System;
using System.Collections.Generic;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Signals.Models;
public sealed class ReachabilityFactDocument
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
[BsonElement("callgraphId")]
public string CallgraphId { get; set; } = string.Empty;
[BsonElement("subject")]
public ReachabilitySubject Subject { get; set; } = new();
[BsonElement("entryPoints")]
public List<string> EntryPoints { get; set; } = new();
[BsonElement("states")]
public List<ReachabilityStateDocument> States { get; set; } = new();
[BsonElement("metadata")]
[BsonIgnoreIfNull]
public Dictionary<string, string?>? Metadata { get; set; }
[BsonElement("computedAt")]
public DateTimeOffset ComputedAt { get; set; }
[BsonElement("subjectKey")]
[BsonRequired]
public string SubjectKey { get; set; } = string.Empty;
}
public sealed class ReachabilityStateDocument
{
[BsonElement("target")]
public string Target { get; set; } = string.Empty;
[BsonElement("reachable")]
public bool Reachable { get; set; }
[BsonElement("confidence")]
public double Confidence { get; set; }
[BsonElement("path")]
public List<string> Path { get; set; } = new();
[BsonElement("evidence")]
public ReachabilityEvidenceDocument Evidence { get; set; } = new();
}
public sealed class ReachabilityEvidenceDocument
{
[BsonElement("runtimeHits")]
public List<string> RuntimeHits { get; set; } = new();
[BsonElement("blockedEdges")]
[BsonIgnoreIfNull]
public List<string>? BlockedEdges { get; set; }
}
public sealed class ReachabilitySubject
{
[BsonElement("imageDigest")]
[BsonIgnoreIfNull]
public string? ImageDigest { get; set; }
[BsonElement("component")]
[BsonIgnoreIfNull]
public string? Component { get; set; }
[BsonElement("version")]
[BsonIgnoreIfNull]
public string? Version { get; set; }
[BsonElement("scanId")]
[BsonIgnoreIfNull]
public string? ScanId { get; set; }
public string ToSubjectKey()
{
if (!string.IsNullOrWhiteSpace(ScanId))
{
return ScanId!;
}
if (!string.IsNullOrWhiteSpace(ImageDigest))
{
return ImageDigest!;
}
return string.Join('|', Component ?? string.Empty, Version ?? string.Empty).Trim('|');
}
}

View File

@@ -0,0 +1,26 @@
using System.Collections.Generic;
namespace StellaOps.Signals.Models;
public sealed class ReachabilityRecomputeRequest
{
public string CallgraphId { get; set; } = string.Empty;
public ReachabilitySubject Subject { get; set; } = new();
public List<string> EntryPoints { get; set; } = new();
public List<string> Targets { get; set; } = new();
public List<string>? RuntimeHits { get; set; }
public List<ReachabilityBlockedEdge>? BlockedEdges { get; set; }
public Dictionary<string, string?>? Metadata { get; set; }
}
public sealed class ReachabilityBlockedEdge
{
public string From { get; set; } = string.Empty;
public string To { get; set; } = string.Empty;
}

View File

@@ -17,10 +17,15 @@ public sealed class SignalsMongoOptions
/// </summary>
public string Database { get; set; } = "signals";
/// <summary>
/// Collection name storing normalized callgraphs.
/// </summary>
public string CallgraphsCollection { get; set; } = "callgraphs";
/// <summary>
/// Collection name storing normalized callgraphs.
/// </summary>
public string CallgraphsCollection { get; set; } = "callgraphs";
/// <summary>
/// Collection name storing reachability facts.
/// </summary>
public string ReachabilityFactsCollection { get; set; } = "reachability_facts";
/// <summary>
/// Validates the configured values.
@@ -37,9 +42,14 @@ public sealed class SignalsMongoOptions
throw new InvalidOperationException("Signals Mongo database name must be configured.");
}
if (string.IsNullOrWhiteSpace(CallgraphsCollection))
{
throw new InvalidOperationException("Signals callgraph collection name must be configured.");
}
}
}
if (string.IsNullOrWhiteSpace(CallgraphsCollection))
{
throw new InvalidOperationException("Signals callgraph collection name must be configured.");
}
if (string.IsNullOrWhiteSpace(ReachabilityFactsCollection))
{
throw new InvalidOperationException("Signals reachability fact collection name must be configured.");
}
}
}

View File

@@ -9,9 +9,9 @@ using StellaOps.Signals.Models;
namespace StellaOps.Signals.Parsing;
/// <summary>
/// Simple JSON-based callgraph parser used for initial language coverage.
/// </summary>
internal sealed class SimpleJsonCallgraphParser : ICallgraphParser
/// Simple JSON-based callgraph parser used for initial language coverage.
/// </summary>
public sealed class SimpleJsonCallgraphParser : ICallgraphParser
{
private readonly JsonSerializerOptions serializerOptions;
@@ -27,93 +27,160 @@ internal sealed class SimpleJsonCallgraphParser : ICallgraphParser
public string Language { get; }
public async Task<CallgraphParseResult> ParseAsync(Stream artifactStream, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(artifactStream);
public async Task<CallgraphParseResult> ParseAsync(Stream artifactStream, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(artifactStream);
using var document = await JsonDocument.ParseAsync(artifactStream, cancellationToken: cancellationToken).ConfigureAwait(false);
var root = document.RootElement;
if (TryParseLegacy(root, out var legacyResult))
{
return legacyResult;
}
if (TryParseSchemaV1(root, out var schemaResult))
{
return schemaResult;
}
throw new CallgraphParserValidationException("Callgraph artifact payload is empty or missing required fields.");
}
private static bool TryParseLegacy(JsonElement root, out CallgraphParseResult result)
{
result = default!;
if (!root.TryGetProperty("graph", out var graphElement))
{
return false;
}
var nodesElement = graphElement.GetProperty("nodes");
var edgesElement = graphElement.TryGetProperty("edges", out var edgesValue) ? edgesValue : default;
var nodes = new List<CallgraphNode>(nodesElement.GetArrayLength());
foreach (var nodeElement in nodesElement.EnumerateArray())
{
var id = nodeElement.GetProperty("id").GetString();
if (string.IsNullOrWhiteSpace(id))
{
throw new CallgraphParserValidationException("Callgraph node is missing an id.");
}
nodes.Add(new CallgraphNode(
Id: id.Trim(),
Name: nodeElement.TryGetProperty("name", out var nameEl) ? nameEl.GetString() ?? id.Trim() : id.Trim(),
Kind: nodeElement.TryGetProperty("kind", out var kindEl) ? kindEl.GetString() ?? "function" : "function",
Namespace: nodeElement.TryGetProperty("namespace", out var nsEl) ? nsEl.GetString() : null,
File: nodeElement.TryGetProperty("file", out var fileEl) ? fileEl.GetString() : null,
Line: nodeElement.TryGetProperty("line", out var lineEl) && lineEl.ValueKind == JsonValueKind.Number ? lineEl.GetInt32() : null));
}
var edges = new List<CallgraphEdge>();
if (edgesElement.ValueKind == JsonValueKind.Array)
{
foreach (var edgeElement in edgesElement.EnumerateArray())
{
var source = edgeElement.GetProperty("source").GetString();
var target = edgeElement.GetProperty("target").GetString();
if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(target))
{
throw new CallgraphParserValidationException("Callgraph edge requires both source and target.");
}
var type = edgeElement.TryGetProperty("type", out var typeEl) ? typeEl.GetString() ?? "call" : "call";
edges.Add(new CallgraphEdge(source.Trim(), target.Trim(), type));
}
}
var formatVersion = root.TryGetProperty("formatVersion", out var versionEl)
? versionEl.GetString()
: null;
result = new CallgraphParseResult(nodes, edges, string.IsNullOrWhiteSpace(formatVersion) ? "1.0" : formatVersion!.Trim());
return true;
}
private static bool TryParseSchemaV1(JsonElement root, out CallgraphParseResult result)
{
result = default!;
if (!root.TryGetProperty("nodes", out var nodesElement) && !root.TryGetProperty("edges", out _))
{
return false;
}
var nodes = new List<CallgraphNode>();
if (nodesElement.ValueKind == JsonValueKind.Array)
{
foreach (var nodeElement in nodesElement.EnumerateArray())
{
var id = nodeElement.TryGetProperty("sid", out var sidEl) ? sidEl.GetString() : nodeElement.GetProperty("id").GetString();
if (string.IsNullOrWhiteSpace(id))
{
throw new CallgraphParserValidationException("Callgraph node is missing an id.");
}
nodes.Add(new CallgraphNode(
Id: id.Trim(),
Name: nodeElement.TryGetProperty("name", out var nameEl) ? nameEl.GetString() ?? id.Trim() : id.Trim(),
Kind: nodeElement.TryGetProperty("kind", out var kindEl) ? kindEl.GetString() ?? "function" : "function",
Namespace: nodeElement.TryGetProperty("namespace", out var nsEl) ? nsEl.GetString() : null,
File: nodeElement.TryGetProperty("file", out var fileEl) ? fileEl.GetString() : null,
Line: nodeElement.TryGetProperty("line", out var lineEl) && lineEl.ValueKind == JsonValueKind.Number ? lineEl.GetInt32() : null));
}
}
if (!root.TryGetProperty("edges", out var edgesElement) || edgesElement.ValueKind != JsonValueKind.Array)
{
edgesElement = default;
}
var edges = new List<CallgraphEdge>();
if (edgesElement.ValueKind == JsonValueKind.Array)
{
foreach (var edgeElement in edgesElement.EnumerateArray())
{
var from = edgeElement.TryGetProperty("from", out var fromEl) ? fromEl.GetString() : edgeElement.GetProperty("source").GetString();
var to = edgeElement.TryGetProperty("to", out var toEl) ? toEl.GetString() : edgeElement.GetProperty("target").GetString();
if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(to))
{
throw new CallgraphParserValidationException("Callgraph edge requires both source and target.");
}
var kind = edgeElement.TryGetProperty("kind", out var kindEl)
? kindEl.GetString() ?? "call"
: edgeElement.TryGetProperty("type", out var typeEl)
? typeEl.GetString() ?? "call"
: "call";
edges.Add(new CallgraphEdge(from.Trim(), to.Trim(), kind));
}
}
if (nodes.Count == 0)
{
// When nodes are omitted (framework overlay), derive them from the referenced edges.
var uniqueNodeIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var edge in edges)
{
uniqueNodeIds.Add(edge.SourceId);
uniqueNodeIds.Add(edge.TargetId);
}
foreach (var nodeId in uniqueNodeIds)
{
nodes.Add(new CallgraphNode(nodeId, nodeId, "function", null, null, null));
}
}
var schemaVersion = root.TryGetProperty("schema_version", out var schemaEl)
? schemaEl.GetString()
: "1.0";
result = new CallgraphParseResult(nodes, edges, string.IsNullOrWhiteSpace(schemaVersion) ? "1.0" : schemaVersion!.Trim());
return true;
}
var payload = await JsonSerializer.DeserializeAsync<RawCallgraphPayload>(
artifactStream,
serializerOptions,
cancellationToken).ConfigureAwait(false);
if (payload is null)
{
throw new CallgraphParserValidationException("Callgraph artifact payload is empty.");
}
if (payload.Graph is null)
{
throw new CallgraphParserValidationException("Callgraph artifact is missing 'graph' section.");
}
if (payload.Graph.Nodes is null || payload.Graph.Nodes.Count == 0)
{
throw new CallgraphParserValidationException("Callgraph artifact must include at least one node.");
}
if (payload.Graph.Edges is null)
{
payload.Graph.Edges = new List<RawCallgraphEdge>();
}
var nodes = new List<CallgraphNode>(payload.Graph.Nodes.Count);
foreach (var node in payload.Graph.Nodes)
{
if (string.IsNullOrWhiteSpace(node.Id))
{
throw new CallgraphParserValidationException("Callgraph node is missing an id.");
}
nodes.Add(new CallgraphNode(
Id: node.Id.Trim(),
Name: node.Name ?? node.Id.Trim(),
Kind: node.Kind ?? "function",
Namespace: node.Namespace,
File: node.File,
Line: node.Line));
}
var edges = new List<CallgraphEdge>(payload.Graph.Edges.Count);
foreach (var edge in payload.Graph.Edges)
{
if (string.IsNullOrWhiteSpace(edge.Source) || string.IsNullOrWhiteSpace(edge.Target))
{
throw new CallgraphParserValidationException("Callgraph edge requires both source and target.");
}
edges.Add(new CallgraphEdge(edge.Source.Trim(), edge.Target.Trim(), edge.Type ?? "call"));
}
var formatVersion = string.IsNullOrWhiteSpace(payload.FormatVersion) ? "1.0" : payload.FormatVersion.Trim();
return new CallgraphParseResult(nodes, edges, formatVersion);
}
private sealed class RawCallgraphPayload
{
public string? FormatVersion { get; set; }
public RawCallgraphGraph? Graph { get; set; }
}
private sealed class RawCallgraphGraph
{
public List<RawCallgraphNode>? Nodes { get; set; }
public List<RawCallgraphEdge>? Edges { get; set; }
}
private sealed class RawCallgraphNode
{
public string? Id { get; set; }
public string? Name { get; set; }
public string? Kind { get; set; }
public string? Namespace { get; set; }
public string? File { get; set; }
public int? Line { get; set; }
}
private sealed class RawCallgraphEdge
{
public string? Source { get; set; }
public string? Target { get; set; }
public string? Type { get; set; }
}
}
}

View File

@@ -7,7 +7,9 @@ namespace StellaOps.Signals.Persistence;
/// <summary>
/// Persists normalized callgraphs.
/// </summary>
public interface ICallgraphRepository
{
Task<CallgraphDocument> UpsertAsync(CallgraphDocument document, CancellationToken cancellationToken);
}
public interface ICallgraphRepository
{
Task<CallgraphDocument> UpsertAsync(CallgraphDocument document, CancellationToken cancellationToken);
Task<CallgraphDocument?> GetByIdAsync(string id, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,12 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Signals.Models;
namespace StellaOps.Signals.Persistence;
public interface IReachabilityFactRepository
{
Task<ReachabilityFactDocument> UpsertAsync(ReachabilityFactDocument document, CancellationToken cancellationToken);
Task<ReachabilityFactDocument?> GetBySubjectAsync(string subjectKey, CancellationToken cancellationToken);
}

View File

@@ -19,11 +19,11 @@ internal sealed class MongoCallgraphRepository : ICallgraphRepository
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<CallgraphDocument> UpsertAsync(CallgraphDocument document, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(document);
var filter = Builders<CallgraphDocument>.Filter.Eq(d => d.Component, document.Component)
public async Task<CallgraphDocument> UpsertAsync(CallgraphDocument document, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(document);
var filter = Builders<CallgraphDocument>.Filter.Eq(d => d.Component, document.Component)
& Builders<CallgraphDocument>.Filter.Eq(d => d.Version, document.Version)
& Builders<CallgraphDocument>.Filter.Eq(d => d.Language, document.Language);
@@ -42,7 +42,18 @@ internal sealed class MongoCallgraphRepository : ICallgraphRepository
document.Id = result.UpsertedId.AsObjectId.ToString();
}
logger.LogInformation("Upserted callgraph {Language}:{Component}:{Version} (id={Id}).", document.Language, document.Component, document.Version, document.Id);
return document;
}
}
logger.LogInformation("Upserted callgraph {Language}:{Component}:{Version} (id={Id}).", document.Language, document.Component, document.Version, document.Id);
return document;
}
public async Task<CallgraphDocument?> GetByIdAsync(string id, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(id))
{
throw new ArgumentException("Callgraph id is required.", nameof(id));
}
var filter = Builders<CallgraphDocument>.Filter.Eq(d => d.Id, id);
return await collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,53 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using StellaOps.Signals.Models;
namespace StellaOps.Signals.Persistence;
internal sealed class MongoReachabilityFactRepository : IReachabilityFactRepository
{
private readonly IMongoCollection<ReachabilityFactDocument> collection;
private readonly ILogger<MongoReachabilityFactRepository> logger;
public MongoReachabilityFactRepository(
IMongoCollection<ReachabilityFactDocument> collection,
ILogger<MongoReachabilityFactRepository> logger)
{
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<ReachabilityFactDocument> UpsertAsync(ReachabilityFactDocument document, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(document);
if (string.IsNullOrWhiteSpace(document.SubjectKey))
{
throw new ArgumentException("Subject key is required.", nameof(document));
}
var filter = Builders<ReachabilityFactDocument>.Filter.Eq(d => d.SubjectKey, document.SubjectKey);
var options = new ReplaceOptions { IsUpsert = true };
var result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
if (result.UpsertedId != null)
{
document.Id = result.UpsertedId.AsObjectId.ToString();
}
logger.LogInformation("Upserted reachability fact for subject {SubjectKey} (callgraph={CallgraphId}).", document.SubjectKey, document.CallgraphId);
return document;
}
public async Task<ReachabilityFactDocument?> GetBySubjectAsync(string subjectKey, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(subjectKey))
{
throw new ArgumentException("Subject key is required.", nameof(subjectKey));
}
var filter = Builders<ReachabilityFactDocument>.Filter.Eq(d => d.SubjectKey, subjectKey);
return await collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -1,4 +1,5 @@
using System.IO;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
@@ -93,23 +94,34 @@ builder.Services.AddSingleton<IMongoDatabase>(sp =>
return mongoClient.GetDatabase(databaseName);
});
builder.Services.AddSingleton<IMongoCollection<CallgraphDocument>>(sp =>
{
var opts = sp.GetRequiredService<IOptions<SignalsOptions>>().Value;
var database = sp.GetRequiredService<IMongoDatabase>();
var collection = database.GetCollection<CallgraphDocument>(opts.Mongo.CallgraphsCollection);
EnsureCallgraphIndexes(collection);
return collection;
});
builder.Services.AddSingleton<ICallgraphRepository, MongoCallgraphRepository>();
builder.Services.AddSingleton<ICallgraphArtifactStore, FileSystemCallgraphArtifactStore>();
builder.Services.AddSingleton<ICallgraphParser>(new SimpleJsonCallgraphParser("java"));
builder.Services.AddSingleton<ICallgraphParser>(new SimpleJsonCallgraphParser("nodejs"));
builder.Services.AddSingleton<ICallgraphParser>(new SimpleJsonCallgraphParser("python"));
builder.Services.AddSingleton<ICallgraphParser>(new SimpleJsonCallgraphParser("go"));
builder.Services.AddSingleton<ICallgraphParserResolver, CallgraphParserResolver>();
builder.Services.AddSingleton<ICallgraphIngestionService, CallgraphIngestionService>();
builder.Services.AddSingleton<IMongoCollection<CallgraphDocument>>(sp =>
{
var opts = sp.GetRequiredService<IOptions<SignalsOptions>>().Value;
var database = sp.GetRequiredService<IMongoDatabase>();
var collection = database.GetCollection<CallgraphDocument>(opts.Mongo.CallgraphsCollection);
EnsureCallgraphIndexes(collection);
return collection;
});
builder.Services.AddSingleton<IMongoCollection<ReachabilityFactDocument>>(sp =>
{
var opts = sp.GetRequiredService<IOptions<SignalsOptions>>().Value;
var database = sp.GetRequiredService<IMongoDatabase>();
var collection = database.GetCollection<ReachabilityFactDocument>(opts.Mongo.ReachabilityFactsCollection);
EnsureReachabilityFactIndexes(collection);
return collection;
});
builder.Services.AddSingleton<ICallgraphRepository, MongoCallgraphRepository>();
builder.Services.AddSingleton<ICallgraphArtifactStore, FileSystemCallgraphArtifactStore>();
builder.Services.AddSingleton<ICallgraphParser>(new SimpleJsonCallgraphParser("java"));
builder.Services.AddSingleton<ICallgraphParser>(new SimpleJsonCallgraphParser("nodejs"));
builder.Services.AddSingleton<ICallgraphParser>(new SimpleJsonCallgraphParser("python"));
builder.Services.AddSingleton<ICallgraphParser>(new SimpleJsonCallgraphParser("go"));
builder.Services.AddSingleton<ICallgraphParserResolver, CallgraphParserResolver>();
builder.Services.AddSingleton<ICallgraphIngestionService, CallgraphIngestionService>();
builder.Services.AddSingleton<IReachabilityFactRepository, MongoReachabilityFactRepository>();
builder.Services.AddSingleton<IReachabilityScoringService, ReachabilityScoringService>();
if (bootstrap.Authority.Enabled)
{
@@ -239,10 +251,40 @@ signalsGroup.MapPost("/runtime-facts", (HttpContext context, SignalsOptions opti
? Results.StatusCode(StatusCodes.Status501NotImplemented)
: failure ?? Results.Unauthorized()).WithName("SignalsRuntimeIngest");
signalsGroup.MapPost("/reachability/recompute", (HttpContext context, SignalsOptions options) =>
Program.TryAuthorize(context, SignalsPolicies.Admin, options.Authority.AllowAnonymousFallback, out var failure)
? Results.StatusCode(StatusCodes.Status501NotImplemented)
: failure ?? Results.Unauthorized()).WithName("SignalsReachabilityRecompute");
signalsGroup.MapPost("/reachability/recompute", async Task<IResult> (
HttpContext context,
SignalsOptions options,
ReachabilityRecomputeRequest request,
IReachabilityScoringService scoringService,
CancellationToken cancellationToken) =>
{
if (!Program.TryAuthorize(context, SignalsPolicies.Admin, options.Authority.AllowAnonymousFallback, out var failure))
{
return failure ?? Results.Unauthorized();
}
try
{
var fact = await scoringService.RecomputeAsync(request, cancellationToken).ConfigureAwait(false);
return Results.Ok(new
{
fact.Id,
fact.CallgraphId,
subject = fact.Subject,
fact.EntryPoints,
fact.States,
fact.ComputedAt
});
}
catch (ReachabilityScoringValidationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
catch (ReachabilityCallgraphNotFoundException ex)
{
return Results.NotFound(new { error = ex.Message });
}
}).WithName("SignalsReachabilityRecompute");
app.Run();
@@ -286,11 +328,11 @@ public partial class Program
return false;
}
internal static void EnsureCallgraphIndexes(IMongoCollection<CallgraphDocument> collection)
{
ArgumentNullException.ThrowIfNull(collection);
try
internal static void EnsureCallgraphIndexes(IMongoCollection<CallgraphDocument> collection)
{
ArgumentNullException.ThrowIfNull(collection);
try
{
var indexKeys = Builders<CallgraphDocument>.IndexKeys
.Ascending(document => document.Component)
@@ -307,7 +349,31 @@ public partial class Program
}
catch (MongoCommandException ex) when (string.Equals(ex.CodeName, "IndexOptionsConflict", StringComparison.Ordinal))
{
// Index already exists with different options ignore to keep startup idempotent.
}
}
}
// Index already exists with different options ignore to keep startup idempotent.
}
}
internal static void EnsureReachabilityFactIndexes(IMongoCollection<ReachabilityFactDocument> collection)
{
ArgumentNullException.ThrowIfNull(collection);
try
{
var subjectIndex = new CreateIndexModel<ReachabilityFactDocument>(
Builders<ReachabilityFactDocument>.IndexKeys.Ascending(doc => doc.SubjectKey),
new CreateIndexOptions { Name = "reachability_subject_key_unique", Unique = true });
collection.Indexes.CreateOne(subjectIndex);
var callgraphIndex = new CreateIndexModel<ReachabilityFactDocument>(
Builders<ReachabilityFactDocument>.IndexKeys.Ascending(doc => doc.CallgraphId),
new CreateIndexOptions { Name = "reachability_callgraph_lookup" });
collection.Indexes.CreateOne(callgraphIndex);
}
catch (MongoCommandException ex) when (string.Equals(ex.CodeName, "IndexOptionsConflict", StringComparison.Ordinal))
{
// Ignore when indexes already exist with different options to keep startup idempotent.
}
}
}

View File

@@ -0,0 +1,4 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Signals.Reachability.Tests")]
[assembly: InternalsVisibleTo("StellaOps.ScannerSignals.IntegrationTests")]

View File

@@ -0,0 +1,10 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Signals.Models;
namespace StellaOps.Signals.Services;
public interface IReachabilityScoringService
{
Task<ReachabilityFactDocument> RecomputeAsync(ReachabilityRecomputeRequest request, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,284 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Signals.Models;
using StellaOps.Signals.Persistence;
namespace StellaOps.Signals.Services;
public sealed class ReachabilityScoringService : IReachabilityScoringService
{
private const double ReachableConfidence = 0.75;
private const double UnreachableConfidence = 0.25;
private const double RuntimeBonus = 0.15;
private const double MaxConfidence = 0.99;
private const double MinConfidence = 0.05;
private readonly ICallgraphRepository callgraphRepository;
private readonly IReachabilityFactRepository factRepository;
private readonly TimeProvider timeProvider;
private readonly ILogger<ReachabilityScoringService> logger;
public ReachabilityScoringService(
ICallgraphRepository callgraphRepository,
IReachabilityFactRepository factRepository,
TimeProvider timeProvider,
ILogger<ReachabilityScoringService> logger)
{
this.callgraphRepository = callgraphRepository ?? throw new ArgumentNullException(nameof(callgraphRepository));
this.factRepository = factRepository ?? throw new ArgumentNullException(nameof(factRepository));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<ReachabilityFactDocument> RecomputeAsync(ReachabilityRecomputeRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ValidateRequest(request);
var callgraph = await callgraphRepository.GetByIdAsync(request.CallgraphId, cancellationToken).ConfigureAwait(false);
if (callgraph is null)
{
throw new ReachabilityCallgraphNotFoundException(request.CallgraphId);
}
IEnumerable<ReachabilityBlockedEdge> blockedEdges = request.BlockedEdges is { Count: > 0 } list
? list
: Array.Empty<ReachabilityBlockedEdge>();
var graph = BuildGraph(callgraph, blockedEdges);
var entryPoints = NormalizeEntryPoints(request.EntryPoints, graph.Nodes, graph.Inbound);
var targets = request.Targets.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim()).Distinct(StringComparer.Ordinal).ToList();
if (targets.Count == 0)
{
throw new ReachabilityScoringValidationException("At least one target symbol is required.");
}
var runtimeHits = request.RuntimeHits?.Where(hit => !string.IsNullOrWhiteSpace(hit))
.Select(hit => hit.Trim())
.Distinct(StringComparer.Ordinal)
.ToList() ?? new List<string>();
var states = new List<ReachabilityStateDocument>(targets.Count);
foreach (var target in targets)
{
var path = FindPath(entryPoints, target, graph.Adjacency);
var reachable = path is not null;
var confidence = reachable ? ReachableConfidence : UnreachableConfidence;
var runtimeEvidence = runtimeHits.Where(hit => path?.Contains(hit, StringComparer.Ordinal) == true)
.ToList();
if (runtimeEvidence.Count > 0)
{
confidence = Math.Min(MaxConfidence, confidence + RuntimeBonus);
}
confidence = Math.Clamp(confidence, MinConfidence, MaxConfidence);
states.Add(new ReachabilityStateDocument
{
Target = target,
Reachable = reachable,
Confidence = confidence,
Path = path ?? new List<string>(),
Evidence = new ReachabilityEvidenceDocument
{
RuntimeHits = runtimeEvidence,
BlockedEdges = request.BlockedEdges?.Select(edge => $"{edge.From} -> {edge.To}").ToList()
}
});
}
var document = new ReachabilityFactDocument
{
CallgraphId = request.CallgraphId,
Subject = request.Subject,
EntryPoints = entryPoints,
States = states,
Metadata = request.Metadata,
ComputedAt = timeProvider.GetUtcNow(),
SubjectKey = request.Subject.ToSubjectKey()
};
logger.LogInformation("Computed reachability fact for subject {SubjectKey} with {StateCount} targets.", document.SubjectKey, states.Count);
return await factRepository.UpsertAsync(document, cancellationToken).ConfigureAwait(false);
}
private static void ValidateRequest(ReachabilityRecomputeRequest request)
{
if (string.IsNullOrWhiteSpace(request.CallgraphId))
{
throw new ReachabilityScoringValidationException("Callgraph id is required.");
}
if (request.Subject is null)
{
throw new ReachabilityScoringValidationException("Subject is required.");
}
}
private static ReachabilityGraph BuildGraph(CallgraphDocument document, IEnumerable<ReachabilityBlockedEdge> blockedEdges)
{
var adjacency = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
var inbound = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
var blocked = new HashSet<(string From, string To)>(new ReachabilityBlockedEdgeComparer());
foreach (var blockedEdge in blockedEdges)
{
if (!string.IsNullOrWhiteSpace(blockedEdge.From) && !string.IsNullOrWhiteSpace(blockedEdge.To))
{
blocked.Add((blockedEdge.From.Trim(), blockedEdge.To.Trim()));
}
}
foreach (var edge in document.Edges)
{
if (blocked.Contains((edge.SourceId, edge.TargetId)))
{
continue;
}
if (!adjacency.TryGetValue(edge.SourceId, out var targets))
{
targets = new HashSet<string>(StringComparer.Ordinal);
adjacency[edge.SourceId] = targets;
}
if (targets.Add(edge.TargetId) && !inbound.TryGetValue(edge.TargetId, out var sources))
{
sources = new HashSet<string>(StringComparer.Ordinal);
inbound[edge.TargetId] = sources;
}
inbound[edge.TargetId].Add(edge.SourceId);
}
var nodes = new HashSet<string>(document.Nodes?.Select(n => n.Id) ?? Array.Empty<string>(), StringComparer.Ordinal);
foreach (var pair in adjacency)
{
nodes.Add(pair.Key);
foreach (var neighbor in pair.Value)
{
nodes.Add(neighbor);
}
}
return new ReachabilityGraph(nodes, adjacency, inbound);
}
private static List<string> NormalizeEntryPoints(IEnumerable<string> requestedEntries, HashSet<string> nodes, Dictionary<string, HashSet<string>> inbound)
{
var entries = requestedEntries?
.Where(entry => !string.IsNullOrWhiteSpace(entry))
.Select(entry => entry.Trim())
.Distinct(StringComparer.Ordinal)
.Where(nodes.Contains)
.ToList() ?? new List<string>();
if (entries.Count > 0)
{
return entries;
}
var inferred = nodes.Where(node => !inbound.ContainsKey(node)).ToList();
if (inferred.Count == 0)
{
inferred.AddRange(nodes);
}
return inferred;
}
private static List<string>? FindPath(IEnumerable<string> entryPoints, string target, Dictionary<string, HashSet<string>> adjacency)
{
var queue = new Queue<string>();
var parents = new Dictionary<string, string>(StringComparer.Ordinal);
var visited = new HashSet<string>(StringComparer.Ordinal);
foreach (var entry in entryPoints)
{
if (visited.Add(entry))
{
queue.Enqueue(entry);
}
}
while (queue.Count > 0)
{
var current = queue.Dequeue();
if (string.Equals(current, target, StringComparison.Ordinal))
{
return BuildPath(current, parents);
}
if (!adjacency.TryGetValue(current, out var neighbors))
{
continue;
}
foreach (var neighbor in neighbors)
{
if (visited.Add(neighbor))
{
parents[neighbor] = current;
queue.Enqueue(neighbor);
}
}
}
return null;
}
private static List<string> BuildPath(string target, Dictionary<string, string> parents)
{
var path = new List<string>();
var current = target;
path.Add(current);
while (parents.TryGetValue(current, out var prev))
{
path.Add(prev);
current = prev;
}
path.Reverse();
return path;
}
private sealed record ReachabilityGraph(
HashSet<string> Nodes,
Dictionary<string, HashSet<string>> Adjacency,
Dictionary<string, HashSet<string>> Inbound);
private sealed class ReachabilityBlockedEdgeComparer : IEqualityComparer<(string From, string To)>
{
public bool Equals((string From, string To) x, (string From, string To) y)
=> string.Equals(x.From, y.From, StringComparison.Ordinal)
&& string.Equals(x.To, y.To, StringComparison.Ordinal);
public int GetHashCode((string From, string To) obj)
{
unchecked
{
return (StringComparer.Ordinal.GetHashCode(obj.From) * 397)
^ StringComparer.Ordinal.GetHashCode(obj.To);
}
}
}
}
public sealed class ReachabilityScoringValidationException : Exception
{
public ReachabilityScoringValidationException(string message) : base(message)
{
}
}
public sealed class ReachabilityCallgraphNotFoundException : Exception
{
public ReachabilityCallgraphNotFoundException(string callgraphId) : base($"Callgraph '{callgraphId}' was not found.")
{
}
}

View File

@@ -1,6 +1,10 @@
# Signals Service Task Board — Reachability v1
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SIGNALS-24-001 | DOING (2025-11-07) | Signals Guild, Authority Guild | AUTH-SIG-26-001 | Stand up Signals API skeleton with RBAC + health checks, sealed-mode config, and DPoP/mTLS plumbing; seed config/tests for `/facts` ingestion. | Host scaffold deployed; `/healthz` and `/facts` endpoints respond with tenant-enforced RBAC; integration tests cover token binding; docs outline bootstrap steps. |
> 2025-11-07: DPoP nonce store wired to Authority preview tenants; `/healthz` + `/facts` smoke tests passing in CI with sealed-mode env.
| SIGNALS-24-002 | DOING (2025-11-07) | Signals Guild | SIGNALS-24-001 | Implement callgraph ingestion/normalisation pipeline (Java/Node/Python/Go), persist artifacts to CAS, and expose retrieval APIs. | Parser fixtures recorded; storage writes deterministic; retries/backoff documented; integration tests cover dedupe and failure paths. |
> 2025-11-07: Java/Node ingestion harness writing CAS blobs locally; Python/Go parsers next along with Mongo upserts.
> 2025-10-29: Skeleton live with scope policies, stub endpoints, integration tests. Sample config added under `etc/signals.yaml.sample`.
> 2025-10-29: JSON parsers for java/nodejs/python/go implemented; artifacts stored on filesystem with SHA-256, callgraphs upserted into Mongo with unique index; integration tests cover success + malformed requests.
| SIGNALS-24-003 | BLOCKED (2025-10-27) | Signals Guild, Runtime Guild | SIGNALS-24-001 | Implement runtime facts ingestion endpoint and normalizer (process, sockets, container metadata) populating `context_facts` with AOC provenance. | Endpoint ingests fixture batches; duplicates deduped; schema enforced; tests cover privacy filters. |
@@ -9,3 +13,5 @@
> 2025-10-27: Upstream ingestion pipelines (SIGNALS-24-002/003) blocked; scoring engine cannot proceed.
| SIGNALS-24-005 | BLOCKED (2025-10-27) | Signals Guild, Platform Events Guild | SIGNALS-24-004 | Implement Redis caches (`reachability_cache:*`), invalidation on new facts, and publish `signals.fact.updated` events. | Cache hit rate tracked; invalidations working; events delivered with idempotent ids; integration tests pass. |
> 2025-10-27: Awaiting scoring engine and ingestion layers before wiring cache/events.
| SIGNALS-REACH-201-003 | DOING (2025-11-08) | Signals Guild | SIGNALS-24-002 | Normalize multi-language callgraphs + runtime facts into `reachability_graphs` CAS layout, expose `/graphs/{scanId}` APIs, and document schema validations. | Parser fixtures for JVM/.NET/Go/Node/Rust/Swift pass; CAS manifests stored; API integration tests cover RBAC/tenancy. |
| SIGNALS-REACH-201-004 | DOING (2025-11-08) | Signals Guild, Policy Guild | SIGNALS-24-004 | Build reachability scoring + cache pipeline (state/score/confidence), emit `signals.fact.updated` events, and provide policy-ready projections with reachability weights. | Engine produces deterministic outputs; Redis cache hit metrics tracked; Policy integration tests consume signals successfully. |