up
This commit is contained in:
@@ -1,34 +1,148 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Signals.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Document representing an ingested callgraph.
|
||||
/// Canonical call graph document following stella.callgraph.v1 schema.
|
||||
/// </summary>
|
||||
public sealed class CallgraphDocument
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
/// <summary>
|
||||
/// Schema identifier. Always "stella.callgraph.v1" for this version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("schema")]
|
||||
public string Schema { get; set; } = CallgraphSchemaVersions.V1;
|
||||
|
||||
public string Language { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Scan context identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("scanKey")]
|
||||
public string ScanKey { get; set; } = string.Empty;
|
||||
|
||||
public string Component { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Primary language of this call graph.
|
||||
/// </summary>
|
||||
[JsonPropertyName("language")]
|
||||
public CallgraphLanguage LanguageType { get; set; } = CallgraphLanguage.Unknown;
|
||||
|
||||
public string Version { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset IngestedAt { get; set; }
|
||||
|
||||
public CallgraphArtifactMetadata Artifact { get; set; } = new();
|
||||
/// <summary>
|
||||
/// Artifacts included in this graph (assemblies, JARs, modules).
|
||||
/// </summary>
|
||||
[JsonPropertyName("artifacts")]
|
||||
public List<CallgraphArtifact> Artifacts { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Graph nodes representing symbols (methods, functions, types).
|
||||
/// </summary>
|
||||
[JsonPropertyName("nodes")]
|
||||
public List<CallgraphNode> Nodes { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Call edges between nodes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("edges")]
|
||||
public List<CallgraphEdge> Edges { get; set; } = new();
|
||||
|
||||
public Dictionary<string, string?>? Metadata { get; set; }
|
||||
/// <summary>
|
||||
/// Discovered entrypoints with framework metadata.
|
||||
/// </summary>
|
||||
[JsonPropertyName("entrypoints")]
|
||||
public List<CallgraphEntrypoint> Entrypoints { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Graph-level metadata.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public CallgraphMetadata? GraphMetadata { get; set; }
|
||||
|
||||
// ===== LEGACY FIELDS FOR BACKWARD COMPATIBILITY =====
|
||||
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
/// <summary>
|
||||
/// Legacy language field (string). Use LanguageType for new code.
|
||||
/// </summary>
|
||||
[JsonPropertyName("languageString")]
|
||||
public string Language { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("component")]
|
||||
public string Component { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("ingestedAt")]
|
||||
public DateTimeOffset IngestedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("artifact")]
|
||||
public CallgraphArtifactMetadata Artifact { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("graphHash")]
|
||||
public string GraphHash { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("roots")]
|
||||
public List<CallgraphRoot>? Roots { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Legacy schema version field. Use Schema for new code.
|
||||
/// </summary>
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string? SchemaVersion { get; set; }
|
||||
|
||||
[JsonPropertyName("legacyMetadata")]
|
||||
public Dictionary<string, string?>? Metadata { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual artifact (assembly, JAR, module) within a call graph.
|
||||
/// </summary>
|
||||
public sealed class CallgraphArtifact
|
||||
{
|
||||
[JsonPropertyName("artifactKey")]
|
||||
public string ArtifactKey { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("kind")]
|
||||
public string Kind { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sha256")]
|
||||
public string Sha256 { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; set; }
|
||||
|
||||
[JsonPropertyName("buildId")]
|
||||
public string? BuildId { get; set; }
|
||||
|
||||
[JsonPropertyName("filePath")]
|
||||
public string? FilePath { get; set; }
|
||||
|
||||
[JsonPropertyName("sizeBytes")]
|
||||
public long? SizeBytes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Graph-level metadata.
|
||||
/// </summary>
|
||||
public sealed class CallgraphMetadata
|
||||
{
|
||||
[JsonPropertyName("toolId")]
|
||||
public string ToolId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("toolVersion")]
|
||||
public string ToolVersion { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("analysisTimestamp")]
|
||||
public DateTimeOffset AnalysisTimestamp { get; set; }
|
||||
|
||||
[JsonPropertyName("sourceCommit")]
|
||||
public string? SourceCommit { get; set; }
|
||||
|
||||
[JsonPropertyName("buildId")]
|
||||
public string? BuildId { get; set; }
|
||||
|
||||
[JsonPropertyName("custom")]
|
||||
public Dictionary<string, string>? Custom { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,16 +1,143 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Signals.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Normalized callgraph edge.
|
||||
/// Represents a call edge between two symbols.
|
||||
/// Enhanced with edge reasons for explainability.
|
||||
/// </summary>
|
||||
public sealed record CallgraphEdge(
|
||||
string SourceId,
|
||||
string TargetId,
|
||||
string Type,
|
||||
string? Purl = null,
|
||||
string? SymbolDigest = null,
|
||||
IReadOnlyList<string>? Candidates = null,
|
||||
double? Confidence = null,
|
||||
IReadOnlyList<string>? Evidence = null);
|
||||
public sealed record CallgraphEdge
|
||||
{
|
||||
public CallgraphEdge()
|
||||
{
|
||||
}
|
||||
|
||||
public CallgraphEdge(
|
||||
string SourceId,
|
||||
string TargetId,
|
||||
string Type,
|
||||
string? Purl = null,
|
||||
string? SymbolDigest = null,
|
||||
IReadOnlyList<string>? Candidates = null,
|
||||
double? Confidence = null,
|
||||
IReadOnlyList<string>? Evidence = null)
|
||||
{
|
||||
this.SourceId = SourceId;
|
||||
this.TargetId = TargetId;
|
||||
this.Type = Type;
|
||||
this.Purl = Purl;
|
||||
this.SymbolDigest = SymbolDigest;
|
||||
this.Candidates = Candidates;
|
||||
this.Confidence = Confidence;
|
||||
this.Evidence = Evidence;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source node ID (caller).
|
||||
/// </summary>
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string SourceId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Target node ID (callee).
|
||||
/// </summary>
|
||||
[JsonPropertyName("targetId")]
|
||||
public string TargetId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Edge type (legacy field).
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Package URL for external dependencies.
|
||||
/// </summary>
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol digest for deterministic matching.
|
||||
/// </summary>
|
||||
[JsonPropertyName("symbolDigest")]
|
||||
public string? SymbolDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Candidate targets for virtual/interface dispatch.
|
||||
/// </summary>
|
||||
[JsonPropertyName("candidates")]
|
||||
public IReadOnlyList<string>? Candidates { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Legacy confidence value.
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public double? Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence sources for this edge.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidence")]
|
||||
public IReadOnlyList<string>? Evidence { get; init; }
|
||||
|
||||
// ===== V1 SCHEMA ENHANCEMENTS =====
|
||||
|
||||
/// <summary>
|
||||
/// Alias for SourceId following v1 schema convention.
|
||||
/// </summary>
|
||||
[JsonPropertyName("from")]
|
||||
public string From
|
||||
{
|
||||
get => SourceId;
|
||||
init => SourceId = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alias for TargetId following v1 schema convention.
|
||||
/// </summary>
|
||||
[JsonPropertyName("to")]
|
||||
public string To
|
||||
{
|
||||
get => TargetId;
|
||||
init => TargetId = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Edge classification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("kind")]
|
||||
public EdgeKind Kind { get; init; } = EdgeKind.Static;
|
||||
|
||||
/// <summary>
|
||||
/// Reason for this edge's existence.
|
||||
/// Enables explainability in reachability analysis.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reason")]
|
||||
public EdgeReason Reason { get; init; } = EdgeReason.Unknown;
|
||||
|
||||
/// <summary>
|
||||
/// Confidence weight (0.0 to 1.0).
|
||||
/// Static edges typically 1.0, heuristic edges lower.
|
||||
/// </summary>
|
||||
[JsonPropertyName("weight")]
|
||||
public double Weight { get; init; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// IL/bytecode offset where call occurs (for source location).
|
||||
/// </summary>
|
||||
[JsonPropertyName("offset")]
|
||||
public int? Offset { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the target was fully resolved.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isResolved")]
|
||||
public bool IsResolved { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Additional provenance information.
|
||||
/// </summary>
|
||||
[JsonPropertyName("provenance")]
|
||||
public string? Provenance { get; init; }
|
||||
}
|
||||
|
||||
58
src/Signals/StellaOps.Signals/Models/CallgraphEntrypoint.cs
Normal file
58
src/Signals/StellaOps.Signals/Models/CallgraphEntrypoint.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Signals.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a discovered entrypoint into the call graph.
|
||||
/// </summary>
|
||||
public sealed class CallgraphEntrypoint
|
||||
{
|
||||
/// <summary>
|
||||
/// Reference to the node that is an entrypoint.
|
||||
/// </summary>
|
||||
[JsonPropertyName("nodeId")]
|
||||
public string NodeId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Type of entrypoint.
|
||||
/// </summary>
|
||||
[JsonPropertyName("kind")]
|
||||
public EntrypointKind Kind { get; set; } = EntrypointKind.Unknown;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP route pattern (for http/grpc kinds).
|
||||
/// Example: "/api/orders/{id}"
|
||||
/// </summary>
|
||||
[JsonPropertyName("route")]
|
||||
public string? Route { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// HTTP method (GET, POST, etc.) if applicable.
|
||||
/// </summary>
|
||||
[JsonPropertyName("httpMethod")]
|
||||
public string? HttpMethod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Framework that exposes this entrypoint.
|
||||
/// </summary>
|
||||
[JsonPropertyName("framework")]
|
||||
public EntrypointFramework Framework { get; set; } = EntrypointFramework.Unknown;
|
||||
|
||||
/// <summary>
|
||||
/// Discovery source (attribute, convention, config).
|
||||
/// </summary>
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Execution phase when this entrypoint is invoked.
|
||||
/// </summary>
|
||||
[JsonPropertyName("phase")]
|
||||
public EntrypointPhase Phase { get; set; } = EntrypointPhase.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic ordering for stable serialization.
|
||||
/// </summary>
|
||||
[JsonPropertyName("order")]
|
||||
public int Order { get; set; }
|
||||
}
|
||||
23
src/Signals/StellaOps.Signals/Models/CallgraphLanguage.cs
Normal file
23
src/Signals/StellaOps.Signals/Models/CallgraphLanguage.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Signals.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Supported languages for call graph analysis.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum CallgraphLanguage
|
||||
{
|
||||
Unknown,
|
||||
DotNet,
|
||||
Java,
|
||||
Node,
|
||||
Python,
|
||||
Go,
|
||||
Rust,
|
||||
Ruby,
|
||||
Php,
|
||||
Binary,
|
||||
Swift,
|
||||
Kotlin
|
||||
}
|
||||
@@ -1,21 +1,153 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Signals.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Normalized callgraph node.
|
||||
/// Represents a symbol node in the call graph.
|
||||
/// Enhanced with visibility and entrypoint candidate tracking.
|
||||
/// </summary>
|
||||
public sealed record CallgraphNode(
|
||||
string Id,
|
||||
string Name,
|
||||
string Kind,
|
||||
string? Namespace,
|
||||
string? File,
|
||||
int? Line,
|
||||
string? Purl = null,
|
||||
string? SymbolDigest = null,
|
||||
string? BuildId = null,
|
||||
string? Language = null,
|
||||
IReadOnlyList<string>? Evidence = null,
|
||||
IReadOnlyDictionary<string, string?>? Analyzer = null,
|
||||
string? CodeId = null);
|
||||
public sealed record CallgraphNode
|
||||
{
|
||||
public CallgraphNode()
|
||||
{
|
||||
}
|
||||
|
||||
public CallgraphNode(string Id, string Name, string Kind, string? Namespace, string? File, int? Line)
|
||||
{
|
||||
this.Id = Id;
|
||||
this.Name = Name;
|
||||
this.Kind = Kind;
|
||||
this.Namespace = Namespace;
|
||||
this.File = File;
|
||||
this.Line = Line;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier for this node within the graph.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Alias for Id following v1 schema convention.
|
||||
/// </summary>
|
||||
[JsonPropertyName("nodeId")]
|
||||
public string NodeId
|
||||
{
|
||||
get => Id;
|
||||
init => Id = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable name of the symbol.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Symbol kind (method, function, class, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("kind")]
|
||||
public string Kind { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Namespace or module path.
|
||||
/// </summary>
|
||||
[JsonPropertyName("namespace")]
|
||||
public string? Namespace { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source file path if available.
|
||||
/// </summary>
|
||||
[JsonPropertyName("file")]
|
||||
public string? File { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source line number if available.
|
||||
/// </summary>
|
||||
[JsonPropertyName("line")]
|
||||
public int? Line { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL if this symbol belongs to an external package.
|
||||
/// </summary>
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed symbol digest for deterministic matching.
|
||||
/// </summary>
|
||||
[JsonPropertyName("symbolDigest")]
|
||||
public string? SymbolDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Build identifier for provenance.
|
||||
/// </summary>
|
||||
[JsonPropertyName("buildId")]
|
||||
public string? BuildId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Language of this symbol.
|
||||
/// </summary>
|
||||
[JsonPropertyName("language")]
|
||||
public string? Language { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence sources for this node.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidence")]
|
||||
public IReadOnlyList<string>? Evidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Analyzer metadata.
|
||||
/// </summary>
|
||||
[JsonPropertyName("analyzer")]
|
||||
public IReadOnlyDictionary<string, string?>? Analyzer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Code identifier for source correlation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("codeId")]
|
||||
public string? CodeId { get; init; }
|
||||
|
||||
// ===== V1 SCHEMA ENHANCEMENTS =====
|
||||
|
||||
/// <summary>
|
||||
/// Canonical symbol key.
|
||||
/// Format: {Namespace}.{Type}[`Arity][+Nested]::{Method}[`Arity]({ParamTypes})
|
||||
/// </summary>
|
||||
[JsonPropertyName("symbolKey")]
|
||||
public string? SymbolKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to containing artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("artifactKey")]
|
||||
public string? ArtifactKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Access visibility of this symbol.
|
||||
/// </summary>
|
||||
[JsonPropertyName("visibility")]
|
||||
public SymbolVisibility Visibility { get; init; } = SymbolVisibility.Unknown;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this node is a candidate for automatic entrypoint detection.
|
||||
/// True for public methods in controllers, handlers, Main methods, etc.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isEntrypointCandidate")]
|
||||
public bool IsEntrypointCandidate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional attributes (generic arity, return type, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("attributes")]
|
||||
public IReadOnlyDictionary<string, string>? Attributes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Node flags bitmask for efficient filtering.
|
||||
/// </summary>
|
||||
[JsonPropertyName("flags")]
|
||||
public int Flags { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.Signals.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Schema version constants for call graph documents.
|
||||
/// </summary>
|
||||
public static class CallgraphSchemaVersions
|
||||
{
|
||||
/// <summary>
|
||||
/// Version 1 schema identifier.
|
||||
/// </summary>
|
||||
public const string V1 = "stella.callgraph.v1";
|
||||
}
|
||||
25
src/Signals/StellaOps.Signals/Models/EdgeKind.cs
Normal file
25
src/Signals/StellaOps.Signals/Models/EdgeKind.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Signals.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Edge classification based on analysis confidence.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum EdgeKind
|
||||
{
|
||||
/// <summary>
|
||||
/// Statically determined call (high confidence).
|
||||
/// </summary>
|
||||
Static,
|
||||
|
||||
/// <summary>
|
||||
/// Heuristically inferred (may require runtime confirmation).
|
||||
/// </summary>
|
||||
Heuristic,
|
||||
|
||||
/// <summary>
|
||||
/// Runtime-observed edge (highest confidence).
|
||||
/// </summary>
|
||||
Runtime
|
||||
}
|
||||
76
src/Signals/StellaOps.Signals/Models/EdgeReason.cs
Normal file
76
src/Signals/StellaOps.Signals/Models/EdgeReason.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Signals.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Reason codes explaining why an edge exists.
|
||||
/// Critical for explainability and debugging.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum EdgeReason
|
||||
{
|
||||
/// <summary>
|
||||
/// Direct method/function call.
|
||||
/// </summary>
|
||||
DirectCall,
|
||||
|
||||
/// <summary>
|
||||
/// Virtual/interface dispatch.
|
||||
/// </summary>
|
||||
VirtualCall,
|
||||
|
||||
/// <summary>
|
||||
/// Reflection-based invocation (Type.GetMethod, etc.).
|
||||
/// </summary>
|
||||
ReflectionString,
|
||||
|
||||
/// <summary>
|
||||
/// Dependency injection binding (AddTransient<I,T>).
|
||||
/// </summary>
|
||||
DiBinding,
|
||||
|
||||
/// <summary>
|
||||
/// Dynamic import/require in interpreted languages.
|
||||
/// </summary>
|
||||
DynamicImport,
|
||||
|
||||
/// <summary>
|
||||
/// Constructor/object instantiation.
|
||||
/// </summary>
|
||||
NewObj,
|
||||
|
||||
/// <summary>
|
||||
/// Delegate/function pointer creation.
|
||||
/// </summary>
|
||||
DelegateCreate,
|
||||
|
||||
/// <summary>
|
||||
/// Async/await continuation.
|
||||
/// </summary>
|
||||
AsyncContinuation,
|
||||
|
||||
/// <summary>
|
||||
/// Event handler subscription.
|
||||
/// </summary>
|
||||
EventHandler,
|
||||
|
||||
/// <summary>
|
||||
/// Generic type instantiation.
|
||||
/// </summary>
|
||||
GenericInstantiation,
|
||||
|
||||
/// <summary>
|
||||
/// Native interop (P/Invoke, JNI, FFI).
|
||||
/// </summary>
|
||||
NativeInterop,
|
||||
|
||||
/// <summary>
|
||||
/// Runtime-minted edge from execution evidence.
|
||||
/// </summary>
|
||||
RuntimeMinted,
|
||||
|
||||
/// <summary>
|
||||
/// Reason could not be determined.
|
||||
/// </summary>
|
||||
Unknown
|
||||
}
|
||||
30
src/Signals/StellaOps.Signals/Models/EntrypointFramework.cs
Normal file
30
src/Signals/StellaOps.Signals/Models/EntrypointFramework.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Signals.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Frameworks that expose entrypoints.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum EntrypointFramework
|
||||
{
|
||||
Unknown,
|
||||
AspNetCore,
|
||||
MinimalApi,
|
||||
Spring,
|
||||
SpringBoot,
|
||||
Express,
|
||||
Fastify,
|
||||
NestJs,
|
||||
FastApi,
|
||||
Flask,
|
||||
Django,
|
||||
Rails,
|
||||
Gin,
|
||||
Echo,
|
||||
Actix,
|
||||
Rocket,
|
||||
AzureFunctions,
|
||||
AwsLambda,
|
||||
CloudFunctions
|
||||
}
|
||||
23
src/Signals/StellaOps.Signals/Models/EntrypointKind.cs
Normal file
23
src/Signals/StellaOps.Signals/Models/EntrypointKind.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Signals.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Types of entrypoints.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum EntrypointKind
|
||||
{
|
||||
Unknown,
|
||||
Http,
|
||||
Grpc,
|
||||
Cli,
|
||||
Job,
|
||||
Event,
|
||||
MessageQueue,
|
||||
Timer,
|
||||
Test,
|
||||
Main,
|
||||
ModuleInit,
|
||||
StaticConstructor
|
||||
}
|
||||
30
src/Signals/StellaOps.Signals/Models/EntrypointPhase.cs
Normal file
30
src/Signals/StellaOps.Signals/Models/EntrypointPhase.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Signals.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Execution phase for entrypoints.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum EntrypointPhase
|
||||
{
|
||||
/// <summary>
|
||||
/// Module/assembly initialization.
|
||||
/// </summary>
|
||||
ModuleInit,
|
||||
|
||||
/// <summary>
|
||||
/// Application startup (Main, startup hooks).
|
||||
/// </summary>
|
||||
AppStart,
|
||||
|
||||
/// <summary>
|
||||
/// Runtime request handling.
|
||||
/// </summary>
|
||||
Runtime,
|
||||
|
||||
/// <summary>
|
||||
/// Shutdown/cleanup handlers.
|
||||
/// </summary>
|
||||
Shutdown
|
||||
}
|
||||
16
src/Signals/StellaOps.Signals/Models/SymbolVisibility.cs
Normal file
16
src/Signals/StellaOps.Signals/Models/SymbolVisibility.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Signals.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Symbol visibility levels.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum SymbolVisibility
|
||||
{
|
||||
Unknown,
|
||||
Public,
|
||||
Internal,
|
||||
Protected,
|
||||
Private
|
||||
}
|
||||
49
src/Signals/StellaOps.Signals/Models/UnknownFlags.cs
Normal file
49
src/Signals/StellaOps.Signals/Models/UnknownFlags.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
namespace StellaOps.Signals.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Flags indicating sources of uncertainty for an unknown.
|
||||
/// </summary>
|
||||
public sealed class UnknownFlags
|
||||
{
|
||||
/// <summary>
|
||||
/// No provenance anchor (can't verify source).
|
||||
/// Weight: +0.30
|
||||
/// </summary>
|
||||
public bool NoProvenanceAnchor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Version specified as range, not exact.
|
||||
/// Weight: +0.25
|
||||
/// </summary>
|
||||
public bool VersionRange { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Conflicting information from different feeds.
|
||||
/// Weight: +0.20
|
||||
/// </summary>
|
||||
public bool ConflictingFeeds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Missing CVSS vector for severity assessment.
|
||||
/// Weight: +0.15
|
||||
/// </summary>
|
||||
public bool MissingVector { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Source advisory URL unreachable.
|
||||
/// Weight: +0.10
|
||||
/// </summary>
|
||||
public bool UnreachableSourceAdvisory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Dynamic call target (reflection, eval).
|
||||
/// Weight: +0.25
|
||||
/// </summary>
|
||||
public bool DynamicCallTarget { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// External assembly not in analysis scope.
|
||||
/// Weight: +0.20
|
||||
/// </summary>
|
||||
public bool ExternalAssembly { get; set; }
|
||||
}
|
||||
@@ -2,6 +2,10 @@ using System;
|
||||
|
||||
namespace StellaOps.Signals.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks an unresolved symbol or edge requiring additional analysis.
|
||||
/// Enhanced with multi-factor scoring for intelligent triage.
|
||||
/// </summary>
|
||||
public sealed class UnknownSymbolDocument
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
@@ -16,11 +20,119 @@ public sealed class UnknownSymbolDocument
|
||||
|
||||
public string? Purl { get; set; }
|
||||
|
||||
public string? PurlVersion { get; set; }
|
||||
|
||||
public string? EdgeFrom { get; set; }
|
||||
|
||||
public string? EdgeTo { get; set; }
|
||||
|
||||
public string? Reason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Flags indicating sources of uncertainty.
|
||||
/// </summary>
|
||||
public UnknownFlags Flags { get; set; } = new();
|
||||
|
||||
// ===== SCORING FACTORS =====
|
||||
|
||||
/// <summary>
|
||||
/// Popularity impact score (P). Based on deployment count.
|
||||
/// Range: 0.0 - 1.0
|
||||
/// </summary>
|
||||
public double PopularityScore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of deployments referencing this package.
|
||||
/// </summary>
|
||||
public int DeploymentCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Exploit consequence potential (E). Based on CVE severity if known.
|
||||
/// Range: 0.0 - 1.0
|
||||
/// </summary>
|
||||
public double ExploitPotentialScore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Uncertainty density (U). Aggregated from flags.
|
||||
/// Range: 0.0 - 1.0
|
||||
/// </summary>
|
||||
public double UncertaintyScore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Graph centrality (C). Position importance in call graph.
|
||||
/// Range: 0.0 - 1.0
|
||||
/// </summary>
|
||||
public double CentralityScore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Degree centrality (incoming + outgoing edges).
|
||||
/// </summary>
|
||||
public int DegreeCentrality { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Betweenness centrality (paths through this node).
|
||||
/// </summary>
|
||||
public double BetweennessCentrality { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence staleness (S). Based on age since last analysis.
|
||||
/// Range: 0.0 - 1.0
|
||||
/// </summary>
|
||||
public double StalenessScore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Days since last successful analysis attempt.
|
||||
/// </summary>
|
||||
public int DaysSinceLastAnalysis { get; set; }
|
||||
|
||||
// ===== COMPOSITE SCORE =====
|
||||
|
||||
/// <summary>
|
||||
/// Final weighted score: wP*P + wE*E + wU*U + wC*C + wS*S
|
||||
/// Range: 0.0 - 1.0
|
||||
/// </summary>
|
||||
public double Score { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Triage band based on score thresholds.
|
||||
/// </summary>
|
||||
public UnknownsBand Band { get; set; } = UnknownsBand.Cold;
|
||||
|
||||
/// <summary>
|
||||
/// Hash of call graph slice containing this unknown.
|
||||
/// </summary>
|
||||
public string? GraphSliceHash { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of all evidence used in scoring.
|
||||
/// </summary>
|
||||
public string? EvidenceSetHash { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Trace of normalization steps for debugging.
|
||||
/// </summary>
|
||||
public UnknownsNormalizationTrace? NormalizationTrace { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of last call graph analysis attempt.
|
||||
/// </summary>
|
||||
public string? CallgraphAttemptHash { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of rescan attempts.
|
||||
/// </summary>
|
||||
public int RescanAttempts { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Last rescan attempt result.
|
||||
/// </summary>
|
||||
public string? LastRescanResult { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
|
||||
public DateTimeOffset? LastAnalyzedAt { get; set; }
|
||||
|
||||
public DateTimeOffset? NextScheduledRescan { get; set; }
|
||||
}
|
||||
|
||||
22
src/Signals/StellaOps.Signals/Models/UnknownsBand.cs
Normal file
22
src/Signals/StellaOps.Signals/Models/UnknownsBand.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace StellaOps.Signals.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Triage bands for unknowns based on scoring.
|
||||
/// </summary>
|
||||
public enum UnknownsBand
|
||||
{
|
||||
/// <summary>
|
||||
/// Score >= 0.70. Immediate rescan + VEX escalation.
|
||||
/// </summary>
|
||||
Hot,
|
||||
|
||||
/// <summary>
|
||||
/// 0.40 <= Score < 0.70. Scheduled rescan 12-72h.
|
||||
/// </summary>
|
||||
Warm,
|
||||
|
||||
/// <summary>
|
||||
/// Score < 0.40. Weekly batch processing.
|
||||
/// </summary>
|
||||
Cold
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Signals.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Detailed trace of score normalization for debugging and auditing.
|
||||
/// </summary>
|
||||
public sealed class UnknownsNormalizationTrace
|
||||
{
|
||||
public double RawPopularity { get; set; }
|
||||
public double NormalizedPopularity { get; set; }
|
||||
public string PopularityFormula { get; set; } = string.Empty;
|
||||
|
||||
public double RawExploitPotential { get; set; }
|
||||
public double NormalizedExploitPotential { get; set; }
|
||||
|
||||
public double RawUncertainty { get; set; }
|
||||
public double NormalizedUncertainty { get; set; }
|
||||
public List<string> ActiveFlags { get; set; } = new();
|
||||
|
||||
public double RawCentrality { get; set; }
|
||||
public double NormalizedCentrality { get; set; }
|
||||
|
||||
public double RawStaleness { get; set; }
|
||||
public double NormalizedStaleness { get; set; }
|
||||
|
||||
public Dictionary<string, double> Weights { get; set; } = new();
|
||||
public double FinalScore { get; set; }
|
||||
public string AssignedBand { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset ComputedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
namespace StellaOps.Signals.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for unknowns scoring algorithm.
|
||||
/// </summary>
|
||||
public sealed class UnknownsScoringOptions
|
||||
{
|
||||
public const string SectionName = "Signals:UnknownsScoring";
|
||||
|
||||
// ===== FACTOR WEIGHTS =====
|
||||
// Must sum to 1.0
|
||||
|
||||
/// <summary>
|
||||
/// Weight for popularity factor (wP). Default: 0.25
|
||||
/// </summary>
|
||||
public double WeightPopularity { get; set; } = 0.25;
|
||||
|
||||
/// <summary>
|
||||
/// Weight for exploit potential factor (wE). Default: 0.25
|
||||
/// </summary>
|
||||
public double WeightExploitPotential { get; set; } = 0.25;
|
||||
|
||||
/// <summary>
|
||||
/// Weight for uncertainty density factor (wU). Default: 0.25
|
||||
/// </summary>
|
||||
public double WeightUncertainty { get; set; } = 0.25;
|
||||
|
||||
/// <summary>
|
||||
/// Weight for graph centrality factor (wC). Default: 0.15
|
||||
/// </summary>
|
||||
public double WeightCentrality { get; set; } = 0.15;
|
||||
|
||||
/// <summary>
|
||||
/// Weight for evidence staleness factor (wS). Default: 0.10
|
||||
/// </summary>
|
||||
public double WeightStaleness { get; set; } = 0.10;
|
||||
|
||||
// ===== POPULARITY NORMALIZATION =====
|
||||
|
||||
/// <summary>
|
||||
/// Maximum deployments for normalization. Default: 100
|
||||
/// </summary>
|
||||
public int PopularityMaxDeployments { get; set; } = 100;
|
||||
|
||||
// ===== UNCERTAINTY FLAG WEIGHTS =====
|
||||
|
||||
public double FlagWeightNoProvenance { get; set; } = 0.30;
|
||||
public double FlagWeightVersionRange { get; set; } = 0.25;
|
||||
public double FlagWeightConflictingFeeds { get; set; } = 0.20;
|
||||
public double FlagWeightMissingVector { get; set; } = 0.15;
|
||||
public double FlagWeightUnreachableSource { get; set; } = 0.10;
|
||||
public double FlagWeightDynamicTarget { get; set; } = 0.25;
|
||||
public double FlagWeightExternalAssembly { get; set; } = 0.20;
|
||||
|
||||
// ===== CENTRALITY NORMALIZATION =====
|
||||
|
||||
/// <summary>
|
||||
/// Maximum betweenness for normalization. Default: 1000
|
||||
/// </summary>
|
||||
public double CentralityMaxBetweenness { get; set; } = 1000.0;
|
||||
|
||||
// ===== STALENESS NORMALIZATION =====
|
||||
|
||||
/// <summary>
|
||||
/// Maximum days for staleness normalization. Default: 14
|
||||
/// </summary>
|
||||
public int StalenessMaxDays { get; set; } = 14;
|
||||
|
||||
// ===== BAND THRESHOLDS =====
|
||||
|
||||
/// <summary>
|
||||
/// Score threshold for HOT band. Default: 0.70
|
||||
/// </summary>
|
||||
public double HotThreshold { get; set; } = 0.70;
|
||||
|
||||
/// <summary>
|
||||
/// Score threshold for WARM band. Default: 0.40
|
||||
/// </summary>
|
||||
public double WarmThreshold { get; set; } = 0.40;
|
||||
|
||||
// ===== RESCAN SCHEDULING =====
|
||||
|
||||
/// <summary>
|
||||
/// Hours until WARM items are rescanned. Default: 24
|
||||
/// </summary>
|
||||
public int WarmRescanHours { get; set; } = 24;
|
||||
|
||||
/// <summary>
|
||||
/// Days until COLD items are rescanned. Default: 7
|
||||
/// </summary>
|
||||
public int ColdRescanDays { get; set; } = 7;
|
||||
}
|
||||
@@ -12,4 +12,5 @@ public sealed record CallgraphParseResult(
|
||||
IReadOnlyList<CallgraphRoot> Roots,
|
||||
string FormatVersion,
|
||||
string SchemaVersion,
|
||||
IReadOnlyDictionary<string, string?>? Analyzer = null);
|
||||
IReadOnlyDictionary<string, string?>? Analyzer = null,
|
||||
IReadOnlyList<CallgraphEntrypoint>? Entrypoints = null);
|
||||
|
||||
382
src/Signals/StellaOps.Signals/Parsing/CallgraphSchemaMigrator.cs
Normal file
382
src/Signals/StellaOps.Signals/Parsing/CallgraphSchemaMigrator.cs
Normal file
@@ -0,0 +1,382 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Signals.Models;
|
||||
|
||||
namespace StellaOps.Signals.Parsing;
|
||||
|
||||
/// <summary>
|
||||
/// Migrates call graphs from legacy formats to stella.callgraph.v1.
|
||||
/// </summary>
|
||||
public static class CallgraphSchemaMigrator
|
||||
{
|
||||
/// <summary>
|
||||
/// Ensures document conforms to v1 schema, migrating if necessary.
|
||||
/// </summary>
|
||||
public static CallgraphDocument EnsureV1(CallgraphDocument document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
if (!string.Equals(document.Schema, CallgraphSchemaVersions.V1, StringComparison.Ordinal))
|
||||
{
|
||||
document.Schema = CallgraphSchemaVersions.V1;
|
||||
}
|
||||
|
||||
// Migrate language string to enum
|
||||
if (document.LanguageType == CallgraphLanguage.Unknown && !string.IsNullOrWhiteSpace(document.Language))
|
||||
{
|
||||
document.LanguageType = ParseLanguage(document.Language);
|
||||
}
|
||||
|
||||
// Ensure all nodes have visibility inferred if not set
|
||||
var updatedNodes = new List<CallgraphNode>(document.Nodes.Count);
|
||||
foreach (var node in document.Nodes)
|
||||
{
|
||||
var visibility = node.Visibility == SymbolVisibility.Unknown
|
||||
? InferVisibility(node.Name, node.Namespace)
|
||||
: node.Visibility;
|
||||
|
||||
var symbolKey = string.IsNullOrWhiteSpace(node.SymbolKey)
|
||||
? BuildSymbolKey(node)
|
||||
: node.SymbolKey;
|
||||
|
||||
var isEntrypointCandidate = node.IsEntrypointCandidate || IsEntrypointCandidate(node);
|
||||
|
||||
if (visibility != node.Visibility ||
|
||||
!string.Equals(symbolKey, node.SymbolKey, StringComparison.Ordinal) ||
|
||||
isEntrypointCandidate != node.IsEntrypointCandidate)
|
||||
{
|
||||
var updatedNode = node with
|
||||
{
|
||||
Visibility = visibility,
|
||||
SymbolKey = symbolKey,
|
||||
IsEntrypointCandidate = isEntrypointCandidate
|
||||
};
|
||||
updatedNodes.Add(updatedNode);
|
||||
}
|
||||
else
|
||||
{
|
||||
updatedNodes.Add(node);
|
||||
}
|
||||
}
|
||||
document.Nodes = updatedNodes
|
||||
.OrderBy(n => n.Id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// Ensure all edges have reasons inferred if not set
|
||||
var updatedEdges = new List<CallgraphEdge>(document.Edges.Count);
|
||||
foreach (var edge in document.Edges)
|
||||
{
|
||||
if (edge.Reason == EdgeReason.Unknown)
|
||||
{
|
||||
var reason = InferEdgeReason(edge);
|
||||
var updatedEdge = edge with { Reason = reason };
|
||||
updatedEdges.Add(updatedEdge);
|
||||
}
|
||||
else
|
||||
{
|
||||
updatedEdges.Add(edge);
|
||||
}
|
||||
}
|
||||
document.Edges = updatedEdges
|
||||
.OrderBy(e => e.SourceId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.TargetId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Type, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Offset ?? -1)
|
||||
.ToList();
|
||||
|
||||
// Build entrypoints from nodes if not present
|
||||
if (document.Entrypoints.Count == 0)
|
||||
{
|
||||
document.Entrypoints = InferEntrypoints(document.Nodes, document.LanguageType, document.Roots);
|
||||
}
|
||||
else
|
||||
{
|
||||
document.Entrypoints = NormalizeEntrypoints(document.Entrypoints);
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
private static CallgraphLanguage ParseLanguage(string language)
|
||||
{
|
||||
return language.ToLowerInvariant() switch
|
||||
{
|
||||
"dotnet" or ".net" or "csharp" or "c#" => CallgraphLanguage.DotNet,
|
||||
"java" => CallgraphLanguage.Java,
|
||||
"node" or "nodejs" or "javascript" or "typescript" => CallgraphLanguage.Node,
|
||||
"python" => CallgraphLanguage.Python,
|
||||
"go" or "golang" => CallgraphLanguage.Go,
|
||||
"rust" => CallgraphLanguage.Rust,
|
||||
"ruby" => CallgraphLanguage.Ruby,
|
||||
"php" => CallgraphLanguage.Php,
|
||||
"binary" or "native" or "elf" => CallgraphLanguage.Binary,
|
||||
"swift" => CallgraphLanguage.Swift,
|
||||
"kotlin" => CallgraphLanguage.Kotlin,
|
||||
_ => CallgraphLanguage.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
private static SymbolVisibility InferVisibility(string name, string? ns)
|
||||
{
|
||||
// Heuristic: symbols with "Internal" in namespace are internal
|
||||
if (!string.IsNullOrEmpty(ns) && ns.Contains("Internal", StringComparison.OrdinalIgnoreCase))
|
||||
return SymbolVisibility.Internal;
|
||||
|
||||
// Heuristic: private symbols often have underscore prefix or specific patterns
|
||||
if (name.StartsWith('_') || name.StartsWith("<"))
|
||||
return SymbolVisibility.Private;
|
||||
|
||||
// Default to public for exposed symbols
|
||||
return SymbolVisibility.Public;
|
||||
}
|
||||
|
||||
private static string BuildSymbolKey(CallgraphNode node)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
if (!string.IsNullOrEmpty(node.Namespace))
|
||||
parts.Add(node.Namespace);
|
||||
|
||||
parts.Add(node.Name);
|
||||
|
||||
return string.Join(".", parts);
|
||||
}
|
||||
|
||||
private static bool IsEntrypointCandidate(CallgraphNode node)
|
||||
{
|
||||
var name = node.Name;
|
||||
var kind = node.Kind;
|
||||
|
||||
// Main methods
|
||||
if (name.Equals("Main", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
// Controller methods
|
||||
if (name.Contains("Controller") || name.Contains("Handler"))
|
||||
return true;
|
||||
|
||||
// Test methods
|
||||
if (node.Analyzer?.ContainsKey("test") == true)
|
||||
return true;
|
||||
|
||||
// Module initializers
|
||||
if (name.Contains(".cctor") || name.Contains("ModuleInitializer"))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static EdgeReason InferEdgeReason(CallgraphEdge edge)
|
||||
{
|
||||
// Heuristic based on edge kind and type
|
||||
if (edge.Kind == EdgeKind.Runtime)
|
||||
return EdgeReason.RuntimeMinted;
|
||||
|
||||
if (edge.Kind == EdgeKind.Heuristic)
|
||||
return EdgeReason.DynamicImport;
|
||||
|
||||
// Infer from legacy type field
|
||||
var type = edge.Type.ToLowerInvariant();
|
||||
return type switch
|
||||
{
|
||||
"call" or "direct" => EdgeReason.DirectCall,
|
||||
"virtual" or "callvirt" => EdgeReason.VirtualCall,
|
||||
"newobj" or "new" => EdgeReason.NewObj,
|
||||
"ldftn" or "delegate" => EdgeReason.DelegateCreate,
|
||||
"reflection" => EdgeReason.ReflectionString,
|
||||
"di" or "injection" => EdgeReason.DiBinding,
|
||||
"async" or "continuation" => EdgeReason.AsyncContinuation,
|
||||
"event" => EdgeReason.EventHandler,
|
||||
"generic" => EdgeReason.GenericInstantiation,
|
||||
"native" or "pinvoke" or "ffi" => EdgeReason.NativeInterop,
|
||||
_ => EdgeReason.DirectCall
|
||||
};
|
||||
}
|
||||
|
||||
private static List<CallgraphEntrypoint> InferEntrypoints(
|
||||
List<CallgraphNode> nodes,
|
||||
CallgraphLanguage language,
|
||||
List<CallgraphRoot>? roots)
|
||||
{
|
||||
var entrypoints = new List<CallgraphEntrypoint>();
|
||||
var order = 0;
|
||||
|
||||
// First, add any explicitly declared roots
|
||||
if (roots != null)
|
||||
{
|
||||
foreach (var root in roots)
|
||||
{
|
||||
var node = nodes.FirstOrDefault(n => n.Id == root.Id);
|
||||
if (node == null) continue;
|
||||
|
||||
var kind = InferEntrypointKindFromPhase(root.Phase);
|
||||
var phase = ParsePhase(root.Phase);
|
||||
var framework = InferFramework(node.Name, language);
|
||||
|
||||
entrypoints.Add(new CallgraphEntrypoint
|
||||
{
|
||||
NodeId = root.Id,
|
||||
Kind = kind,
|
||||
Framework = framework,
|
||||
Source = root.Source ?? "root_declaration",
|
||||
Phase = phase,
|
||||
Order = order++
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Then, add inferred entrypoint candidates
|
||||
foreach (var node in nodes.Where(n => n.IsEntrypointCandidate))
|
||||
{
|
||||
// Skip if already added from roots
|
||||
if (entrypoints.Any(e => e.NodeId == node.Id))
|
||||
continue;
|
||||
|
||||
var kind = InferEntrypointKind(node.Name, language);
|
||||
var framework = InferFramework(node.Name, language);
|
||||
|
||||
entrypoints.Add(new CallgraphEntrypoint
|
||||
{
|
||||
NodeId = node.Id,
|
||||
Kind = kind,
|
||||
Framework = framework,
|
||||
Source = "inference",
|
||||
Phase = kind == EntrypointKind.ModuleInit ? EntrypointPhase.ModuleInit : EntrypointPhase.Runtime,
|
||||
Order = order++
|
||||
});
|
||||
}
|
||||
|
||||
return entrypoints
|
||||
.OrderBy(e => (int)e.Phase)
|
||||
.ThenBy(e => e.Order)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static List<CallgraphEntrypoint> NormalizeEntrypoints(List<CallgraphEntrypoint> entrypoints)
|
||||
{
|
||||
var normalized = new List<CallgraphEntrypoint>(entrypoints.Count);
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var entrypoint in entrypoints)
|
||||
{
|
||||
var nodeId = entrypoint.NodeId?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(nodeId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalizedEntrypoint = new CallgraphEntrypoint
|
||||
{
|
||||
NodeId = nodeId,
|
||||
Kind = entrypoint.Kind,
|
||||
Route = string.IsNullOrWhiteSpace(entrypoint.Route) ? null : entrypoint.Route.Trim(),
|
||||
HttpMethod = string.IsNullOrWhiteSpace(entrypoint.HttpMethod) ? null : entrypoint.HttpMethod.Trim().ToUpperInvariant(),
|
||||
Framework = entrypoint.Framework,
|
||||
Source = string.IsNullOrWhiteSpace(entrypoint.Source) ? null : entrypoint.Source.Trim(),
|
||||
Phase = entrypoint.Phase,
|
||||
Order = 0
|
||||
};
|
||||
|
||||
var key = $"{normalizedEntrypoint.NodeId}|{normalizedEntrypoint.Kind}|{normalizedEntrypoint.Framework}|{normalizedEntrypoint.Phase}|{normalizedEntrypoint.Route}|{normalizedEntrypoint.HttpMethod}|{normalizedEntrypoint.Source}";
|
||||
if (seen.Add(key))
|
||||
{
|
||||
normalized.Add(normalizedEntrypoint);
|
||||
}
|
||||
}
|
||||
|
||||
var sorted = normalized
|
||||
.OrderBy(e => (int)e.Phase)
|
||||
.ThenBy(e => e.NodeId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Kind)
|
||||
.ThenBy(e => e.Framework)
|
||||
.ThenBy(e => e.Route, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.HttpMethod, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Source, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var ordered = new List<CallgraphEntrypoint>(sorted.Count);
|
||||
foreach (var group in sorted.GroupBy(e => e.Phase).OrderBy(g => (int)g.Key))
|
||||
{
|
||||
var order = 0;
|
||||
foreach (var entrypoint in group)
|
||||
{
|
||||
entrypoint.Order = order++;
|
||||
ordered.Add(entrypoint);
|
||||
}
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private static EntrypointKind InferEntrypointKindFromPhase(string phase)
|
||||
{
|
||||
return phase.ToLowerInvariant() switch
|
||||
{
|
||||
"init" or "module_init" or "static_init" => EntrypointKind.ModuleInit,
|
||||
"main" or "entry" => EntrypointKind.Main,
|
||||
"http" or "request" => EntrypointKind.Http,
|
||||
"grpc" => EntrypointKind.Grpc,
|
||||
"cli" or "command" => EntrypointKind.Cli,
|
||||
"job" or "background" => EntrypointKind.Job,
|
||||
"event" => EntrypointKind.Event,
|
||||
"queue" or "message" => EntrypointKind.MessageQueue,
|
||||
"timer" or "scheduled" => EntrypointKind.Timer,
|
||||
"test" => EntrypointKind.Test,
|
||||
_ => EntrypointKind.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
private static EntrypointPhase ParsePhase(string phase)
|
||||
{
|
||||
return phase.ToLowerInvariant() switch
|
||||
{
|
||||
"init" or "module_init" or "static_init" => EntrypointPhase.ModuleInit,
|
||||
"startup" or "main" or "entry" => EntrypointPhase.AppStart,
|
||||
"shutdown" or "cleanup" => EntrypointPhase.Shutdown,
|
||||
_ => EntrypointPhase.Runtime
|
||||
};
|
||||
}
|
||||
|
||||
private static EntrypointKind InferEntrypointKind(string name, CallgraphLanguage language)
|
||||
{
|
||||
if (name.Contains("Controller", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Contains("Handler", StringComparison.OrdinalIgnoreCase))
|
||||
return EntrypointKind.Http;
|
||||
|
||||
if (name.Equals("Main", StringComparison.OrdinalIgnoreCase))
|
||||
return EntrypointKind.Main;
|
||||
|
||||
if (name.Contains(".cctor") || name.Contains("ModuleInitializer"))
|
||||
return EntrypointKind.ModuleInit;
|
||||
|
||||
if (name.Contains("Test", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Contains("Fact", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Contains("Theory", StringComparison.OrdinalIgnoreCase))
|
||||
return EntrypointKind.Test;
|
||||
|
||||
return EntrypointKind.Unknown;
|
||||
}
|
||||
|
||||
private static EntrypointFramework InferFramework(string name, CallgraphLanguage language)
|
||||
{
|
||||
return language switch
|
||||
{
|
||||
CallgraphLanguage.DotNet when name.Contains("Controller") => EntrypointFramework.AspNetCore,
|
||||
CallgraphLanguage.DotNet when name.Contains("Function") => EntrypointFramework.AzureFunctions,
|
||||
CallgraphLanguage.Java when name.Contains("Controller") => EntrypointFramework.Spring,
|
||||
CallgraphLanguage.Java when name.Contains("Handler") => EntrypointFramework.AwsLambda,
|
||||
CallgraphLanguage.Node when name.Contains("express") => EntrypointFramework.Express,
|
||||
CallgraphLanguage.Node when name.Contains("fastify") => EntrypointFramework.Fastify,
|
||||
CallgraphLanguage.Python when name.Contains("fastapi") => EntrypointFramework.FastApi,
|
||||
CallgraphLanguage.Python when name.Contains("flask") => EntrypointFramework.Flask,
|
||||
CallgraphLanguage.Python when name.Contains("django") => EntrypointFramework.Django,
|
||||
CallgraphLanguage.Ruby => EntrypointFramework.Rails,
|
||||
CallgraphLanguage.Go when name.Contains("gin") => EntrypointFramework.Gin,
|
||||
CallgraphLanguage.Go when name.Contains("echo") => EntrypointFramework.Echo,
|
||||
CallgraphLanguage.Rust when name.Contains("actix") => EntrypointFramework.Actix,
|
||||
CallgraphLanguage.Rust when name.Contains("rocket") => EntrypointFramework.Rocket,
|
||||
_ => EntrypointFramework.Unknown
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,11 @@ public sealed class SimpleJsonCallgraphParser : ICallgraphParser
|
||||
return schemaResult;
|
||||
}
|
||||
|
||||
if (TryParseFlatGraph(root, out var flatResult))
|
||||
{
|
||||
return flatResult;
|
||||
}
|
||||
|
||||
throw new CallgraphParserValidationException("Callgraph artifact payload is empty or missing required fields.");
|
||||
}
|
||||
|
||||
@@ -68,20 +73,22 @@ public sealed class SimpleJsonCallgraphParser : ICallgraphParser
|
||||
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,
|
||||
Purl: GetString(nodeElement, "purl"),
|
||||
SymbolDigest: GetString(nodeElement, "symbol_digest", "symbolDigest"),
|
||||
BuildId: GetString(nodeElement, "build_id", "buildId"),
|
||||
Language: GetString(nodeElement, "language"),
|
||||
Evidence: GetStringArray(nodeElement, "evidence"),
|
||||
Analyzer: GetStringDictionary(nodeElement, "analyzer"),
|
||||
CodeId: GetString(nodeElement, "code_id", "codeId")));
|
||||
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,
|
||||
Purl = GetString(nodeElement, "purl"),
|
||||
SymbolDigest = GetString(nodeElement, "symbol_digest", "symbolDigest"),
|
||||
BuildId = GetString(nodeElement, "build_id", "buildId"),
|
||||
Language = GetString(nodeElement, "language"),
|
||||
Evidence = GetStringArray(nodeElement, "evidence"),
|
||||
Analyzer = GetStringDictionary(nodeElement, "analyzer"),
|
||||
CodeId = GetString(nodeElement, "code_id", "codeId")
|
||||
});
|
||||
}
|
||||
|
||||
var edges = new List<CallgraphEdge>();
|
||||
@@ -97,15 +104,17 @@ public sealed class SimpleJsonCallgraphParser : ICallgraphParser
|
||||
}
|
||||
|
||||
var type = edgeElement.TryGetProperty("type", out var typeEl) ? typeEl.GetString() ?? "call" : "call";
|
||||
edges.Add(new CallgraphEdge(
|
||||
source.Trim(),
|
||||
target.Trim(),
|
||||
type,
|
||||
Purl: GetString(edgeElement, "purl"),
|
||||
SymbolDigest: GetString(edgeElement, "symbol_digest", "symbolDigest"),
|
||||
Candidates: GetStringArray(edgeElement, "candidates"),
|
||||
Confidence: GetNullableDouble(edgeElement, "confidence"),
|
||||
Evidence: GetStringArray(edgeElement, "evidence")));
|
||||
edges.Add(new CallgraphEdge
|
||||
{
|
||||
SourceId = source.Trim(),
|
||||
TargetId = target.Trim(),
|
||||
Type = type,
|
||||
Purl = GetString(edgeElement, "purl"),
|
||||
SymbolDigest = GetString(edgeElement, "symbol_digest", "symbolDigest"),
|
||||
Candidates = GetStringArray(edgeElement, "candidates"),
|
||||
Confidence = GetNullableDouble(edgeElement, "confidence"),
|
||||
Evidence = GetStringArray(edgeElement, "evidence")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,15 +126,18 @@ public sealed class SimpleJsonCallgraphParser : ICallgraphParser
|
||||
? schemaEl.GetString()
|
||||
: formatVersion;
|
||||
|
||||
var roots = ParseRoots(root);
|
||||
var entrypoints = ParseEntrypoints(root);
|
||||
var analyzer = GetStringDictionary(root, "analyzer") ?? GetStringDictionary(root, "toolchain");
|
||||
|
||||
result = new CallgraphParseResult(
|
||||
nodes,
|
||||
edges,
|
||||
Array.Empty<CallgraphRoot>(),
|
||||
roots,
|
||||
string.IsNullOrWhiteSpace(formatVersion) ? "1.0" : formatVersion!.Trim(),
|
||||
string.IsNullOrWhiteSpace(schemaVersion) ? "1.0" : schemaVersion!.Trim(),
|
||||
analyzer);
|
||||
analyzer,
|
||||
entrypoints);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -149,20 +161,22 @@ public sealed class SimpleJsonCallgraphParser : ICallgraphParser
|
||||
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,
|
||||
Purl: GetString(nodeElement, "purl"),
|
||||
SymbolDigest: GetString(nodeElement, "symbol_digest", "symbolDigest"),
|
||||
BuildId: GetString(nodeElement, "build_id", "buildId"),
|
||||
Language: GetString(nodeElement, "language"),
|
||||
Evidence: GetStringArray(nodeElement, "evidence"),
|
||||
Analyzer: GetStringDictionary(nodeElement, "analyzer"),
|
||||
CodeId: GetString(nodeElement, "code_id", "codeId")));
|
||||
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,
|
||||
Purl = GetString(nodeElement, "purl"),
|
||||
SymbolDigest = GetString(nodeElement, "symbol_digest", "symbolDigest"),
|
||||
BuildId = GetString(nodeElement, "build_id", "buildId"),
|
||||
Language = GetString(nodeElement, "language"),
|
||||
Evidence = GetStringArray(nodeElement, "evidence"),
|
||||
Analyzer = GetStringDictionary(nodeElement, "analyzer"),
|
||||
CodeId = GetString(nodeElement, "code_id", "codeId")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,15 +203,17 @@ public sealed class SimpleJsonCallgraphParser : ICallgraphParser
|
||||
? typeEl.GetString() ?? "call"
|
||||
: "call";
|
||||
|
||||
edges.Add(new CallgraphEdge(
|
||||
from.Trim(),
|
||||
to.Trim(),
|
||||
kind,
|
||||
Purl: GetString(edgeElement, "purl"),
|
||||
SymbolDigest: GetString(edgeElement, "symbol_digest", "symbolDigest"),
|
||||
Candidates: GetStringArray(edgeElement, "candidates"),
|
||||
Confidence: GetNullableDouble(edgeElement, "confidence"),
|
||||
Evidence: GetStringArray(edgeElement, "evidence")));
|
||||
edges.Add(new CallgraphEdge
|
||||
{
|
||||
SourceId = from.Trim(),
|
||||
TargetId = to.Trim(),
|
||||
Type = kind,
|
||||
Purl = GetString(edgeElement, "purl"),
|
||||
SymbolDigest = GetString(edgeElement, "symbol_digest", "symbolDigest"),
|
||||
Candidates = GetStringArray(edgeElement, "candidates"),
|
||||
Confidence = GetNullableDouble(edgeElement, "confidence"),
|
||||
Evidence = GetStringArray(edgeElement, "evidence")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,7 +229,7 @@ public sealed class SimpleJsonCallgraphParser : ICallgraphParser
|
||||
|
||||
foreach (var nodeId in uniqueNodeIds)
|
||||
{
|
||||
nodes.Add(new CallgraphNode(nodeId, nodeId, "function", null, null, null, null, null, null, null, null, null, null));
|
||||
nodes.Add(new CallgraphNode { Id = nodeId, Name = nodeId, Kind = "function" });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Signals.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for deployment reference lookups used in popularity scoring.
|
||||
/// </summary>
|
||||
public interface IDeploymentRefsRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Counts distinct deployments referencing a package.
|
||||
/// </summary>
|
||||
Task<int> CountDeploymentsAsync(string purl, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Signals.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for graph metrics used in centrality scoring.
|
||||
/// </summary>
|
||||
public interface IGraphMetricsRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets centrality metrics for a symbol in a call graph.
|
||||
/// </summary>
|
||||
Task<GraphMetrics?> GetMetricsAsync(
|
||||
string symbolId,
|
||||
string callgraphId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Centrality metrics for a symbol.
|
||||
/// </summary>
|
||||
public sealed record GraphMetrics(
|
||||
int Degree,
|
||||
double Betweenness);
|
||||
@@ -10,4 +10,17 @@ public interface IUnknownsRepository
|
||||
Task UpsertAsync(string subjectKey, IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<UnknownSymbolDocument>> GetBySubjectAsync(string subjectKey, CancellationToken cancellationToken);
|
||||
Task<int> CountBySubjectAsync(string subjectKey, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Bulk update unknowns after scoring.
|
||||
/// </summary>
|
||||
Task BulkUpdateAsync(IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets unknowns due for rescan in a specific band.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<UnknownSymbolDocument>> GetDueForRescanAsync(
|
||||
UnknownsBand band,
|
||||
int limit,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,13 @@ internal sealed class InMemoryCallgraphRepository : ICallgraphRepository
|
||||
Metadata = source.Metadata is null ? null : new Dictionary<string, string?>(source.Metadata, StringComparer.OrdinalIgnoreCase),
|
||||
GraphHash = source.GraphHash,
|
||||
Roots = source.Roots?.Select(r => new CallgraphRoot(r.Id, r.Phase, r.Source)).ToList(),
|
||||
SchemaVersion = source.SchemaVersion
|
||||
SchemaVersion = source.SchemaVersion,
|
||||
Schema = source.Schema,
|
||||
ScanKey = source.ScanKey,
|
||||
LanguageType = source.LanguageType,
|
||||
Artifacts = source.Artifacts.Select(CloneArtifactV1).ToList(),
|
||||
Entrypoints = source.Entrypoints.Select(CloneEntrypoint).ToList(),
|
||||
GraphMetadata = source.GraphMetadata
|
||||
};
|
||||
|
||||
private static CallgraphArtifactMetadata CloneArtifact(CallgraphArtifactMetadata source) => new()
|
||||
@@ -60,28 +66,67 @@ internal sealed class InMemoryCallgraphRepository : ICallgraphRepository
|
||||
Length = source.Length
|
||||
};
|
||||
|
||||
private static CallgraphNode CloneNode(CallgraphNode source) => new(
|
||||
source.Id,
|
||||
source.Name,
|
||||
source.Kind,
|
||||
source.Namespace,
|
||||
source.File,
|
||||
source.Line,
|
||||
source.Purl,
|
||||
source.SymbolDigest,
|
||||
source.BuildId,
|
||||
source.Language,
|
||||
source.Evidence?.ToList(),
|
||||
source.Analyzer is null ? null : new Dictionary<string, string?>(source.Analyzer, StringComparer.OrdinalIgnoreCase),
|
||||
source.CodeId);
|
||||
private static CallgraphArtifact CloneArtifactV1(CallgraphArtifact source) => new()
|
||||
{
|
||||
ArtifactKey = source.ArtifactKey,
|
||||
Kind = source.Kind,
|
||||
Sha256 = source.Sha256,
|
||||
Purl = source.Purl,
|
||||
BuildId = source.BuildId,
|
||||
FilePath = source.FilePath,
|
||||
SizeBytes = source.SizeBytes
|
||||
};
|
||||
|
||||
private static CallgraphEdge CloneEdge(CallgraphEdge source) => new(
|
||||
source.SourceId,
|
||||
source.TargetId,
|
||||
source.Type,
|
||||
source.Purl,
|
||||
source.SymbolDigest,
|
||||
source.Candidates?.ToList(),
|
||||
source.Confidence,
|
||||
source.Evidence?.ToList());
|
||||
private static CallgraphEntrypoint CloneEntrypoint(CallgraphEntrypoint source) => new()
|
||||
{
|
||||
NodeId = source.NodeId,
|
||||
Kind = source.Kind,
|
||||
Route = source.Route,
|
||||
HttpMethod = source.HttpMethod,
|
||||
Framework = source.Framework,
|
||||
Source = source.Source,
|
||||
Phase = source.Phase,
|
||||
Order = source.Order
|
||||
};
|
||||
|
||||
private static CallgraphNode CloneNode(CallgraphNode source) => new()
|
||||
{
|
||||
Id = source.Id,
|
||||
Name = source.Name,
|
||||
Kind = source.Kind,
|
||||
Namespace = source.Namespace,
|
||||
File = source.File,
|
||||
Line = source.Line,
|
||||
Purl = source.Purl,
|
||||
SymbolDigest = source.SymbolDigest,
|
||||
BuildId = source.BuildId,
|
||||
Language = source.Language,
|
||||
Evidence = source.Evidence?.ToList(),
|
||||
Analyzer = source.Analyzer is null ? null : new Dictionary<string, string?>(source.Analyzer, StringComparer.OrdinalIgnoreCase),
|
||||
CodeId = source.CodeId,
|
||||
SymbolKey = source.SymbolKey,
|
||||
ArtifactKey = source.ArtifactKey,
|
||||
Visibility = source.Visibility,
|
||||
IsEntrypointCandidate = source.IsEntrypointCandidate,
|
||||
Attributes = source.Attributes,
|
||||
Flags = source.Flags
|
||||
};
|
||||
|
||||
private static CallgraphEdge CloneEdge(CallgraphEdge source) => new()
|
||||
{
|
||||
SourceId = source.SourceId,
|
||||
TargetId = source.TargetId,
|
||||
Type = source.Type,
|
||||
Purl = source.Purl,
|
||||
SymbolDigest = source.SymbolDigest,
|
||||
Candidates = source.Candidates?.ToList(),
|
||||
Confidence = source.Confidence,
|
||||
Evidence = source.Evidence?.ToList(),
|
||||
Kind = source.Kind,
|
||||
Reason = source.Reason,
|
||||
Weight = source.Weight,
|
||||
Offset = source.Offset,
|
||||
IsResolved = source.IsResolved,
|
||||
Provenance = source.Provenance
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,6 +37,46 @@ public sealed class InMemoryUnknownsRepository : IUnknownsRepository
|
||||
return Task.FromResult(_store.TryGetValue(subjectKey, out var items) ? items.Count : 0);
|
||||
}
|
||||
|
||||
public Task BulkUpdateAsync(IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.SubjectKey))
|
||||
continue;
|
||||
|
||||
if (_store.TryGetValue(item.SubjectKey, out var existing))
|
||||
{
|
||||
var index = existing.FindIndex(x => x.Id == item.Id);
|
||||
if (index >= 0)
|
||||
{
|
||||
existing[index] = Clone(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<UnknownSymbolDocument>> GetDueForRescanAsync(
|
||||
UnknownsBand band,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var results = _store.Values
|
||||
.SelectMany(x => x)
|
||||
.Where(u => u.Band == band && u.NextScheduledRescan.HasValue && u.NextScheduledRescan <= now)
|
||||
.OrderBy(u => u.NextScheduledRescan)
|
||||
.Take(limit)
|
||||
.Select(Clone)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<UnknownSymbolDocument>>(results);
|
||||
}
|
||||
|
||||
private static UnknownSymbolDocument Clone(UnknownSymbolDocument source) => new()
|
||||
{
|
||||
Id = source.Id,
|
||||
@@ -45,9 +85,31 @@ public sealed class InMemoryUnknownsRepository : IUnknownsRepository
|
||||
SymbolId = source.SymbolId,
|
||||
CodeId = source.CodeId,
|
||||
Purl = source.Purl,
|
||||
PurlVersion = source.PurlVersion,
|
||||
EdgeFrom = source.EdgeFrom,
|
||||
EdgeTo = source.EdgeTo,
|
||||
Reason = source.Reason,
|
||||
CreatedAt = source.CreatedAt
|
||||
Flags = source.Flags,
|
||||
PopularityScore = source.PopularityScore,
|
||||
DeploymentCount = source.DeploymentCount,
|
||||
ExploitPotentialScore = source.ExploitPotentialScore,
|
||||
UncertaintyScore = source.UncertaintyScore,
|
||||
CentralityScore = source.CentralityScore,
|
||||
DegreeCentrality = source.DegreeCentrality,
|
||||
BetweennessCentrality = source.BetweennessCentrality,
|
||||
StalenessScore = source.StalenessScore,
|
||||
DaysSinceLastAnalysis = source.DaysSinceLastAnalysis,
|
||||
Score = source.Score,
|
||||
Band = source.Band,
|
||||
GraphSliceHash = source.GraphSliceHash,
|
||||
EvidenceSetHash = source.EvidenceSetHash,
|
||||
NormalizationTrace = source.NormalizationTrace,
|
||||
CallgraphAttemptHash = source.CallgraphAttemptHash,
|
||||
RescanAttempts = source.RescanAttempts,
|
||||
LastRescanResult = source.LastRescanResult,
|
||||
CreatedAt = source.CreatedAt,
|
||||
UpdatedAt = source.UpdatedAt,
|
||||
LastAnalyzedAt = source.LastAnalyzedAt,
|
||||
NextScheduledRescan = source.NextScheduledRescan
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -71,9 +72,46 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService
|
||||
: normalized.SchemaVersion;
|
||||
var analyzerMeta = request.Analyzer ?? normalized.Analyzer;
|
||||
|
||||
parseStream.Position = 0;
|
||||
var artifactHash = ComputeSha256(artifactBytes);
|
||||
var graphHash = ComputeGraphHash(normalized);
|
||||
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Language = parser.Language,
|
||||
LanguageType = CallgraphLanguage.Unknown,
|
||||
Component = request.Component,
|
||||
Version = request.Version,
|
||||
Nodes = new List<CallgraphNode>(normalized.Nodes),
|
||||
Edges = new List<CallgraphEdge>(normalized.Edges),
|
||||
Roots = new List<CallgraphRoot>(normalized.Roots),
|
||||
Entrypoints = normalized.Entrypoints is null
|
||||
? new List<CallgraphEntrypoint>()
|
||||
: new List<CallgraphEntrypoint>(normalized.Entrypoints),
|
||||
Metadata = request.Metadata is null
|
||||
? null
|
||||
: new Dictionary<string, string?>(request.Metadata, StringComparer.OrdinalIgnoreCase),
|
||||
Artifact = new CallgraphArtifactMetadata
|
||||
{
|
||||
ContentType = request.ArtifactContentType
|
||||
},
|
||||
IngestedAt = timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
document.Metadata ??= new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
document.Metadata["formatVersion"] = normalized.FormatVersion;
|
||||
document.Metadata["schemaVersion"] = schemaVersion;
|
||||
if (analyzerMeta is not null)
|
||||
{
|
||||
foreach (var kv in analyzerMeta)
|
||||
{
|
||||
document.Metadata[$"analyzer.{kv.Key}"] = kv.Value;
|
||||
}
|
||||
}
|
||||
document.SchemaVersion = schemaVersion;
|
||||
|
||||
document = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
var graphHash = ComputeGraphHash(document);
|
||||
document.GraphHash = graphHash;
|
||||
|
||||
var manifest = new CallgraphManifest
|
||||
{
|
||||
@@ -83,9 +121,9 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService
|
||||
ArtifactHash = artifactHash,
|
||||
GraphHash = graphHash,
|
||||
SchemaVersion = schemaVersion,
|
||||
NodeCount = normalized.Nodes.Count,
|
||||
EdgeCount = normalized.Edges.Count,
|
||||
RootCount = normalized.Roots.Count,
|
||||
NodeCount = document.Nodes.Count,
|
||||
EdgeCount = document.Edges.Count,
|
||||
RootCount = document.Roots?.Count ?? 0,
|
||||
CreatedAt = timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
@@ -106,43 +144,14 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService
|
||||
parseStream,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Language = parser.Language,
|
||||
Component = request.Component,
|
||||
Version = request.Version,
|
||||
Nodes = new List<CallgraphNode>(normalized.Nodes),
|
||||
Edges = new List<CallgraphEdge>(normalized.Edges),
|
||||
Roots = new List<CallgraphRoot>(normalized.Roots),
|
||||
Metadata = request.Metadata is null
|
||||
? null
|
||||
: new Dictionary<string, string?>(request.Metadata, StringComparer.OrdinalIgnoreCase),
|
||||
Artifact = new CallgraphArtifactMetadata
|
||||
{
|
||||
Path = artifactMetadata.Path,
|
||||
Hash = artifactMetadata.Hash,
|
||||
CasUri = artifactMetadata.CasUri,
|
||||
ManifestPath = artifactMetadata.ManifestPath,
|
||||
ManifestCasUri = artifactMetadata.ManifestCasUri,
|
||||
GraphHash = graphHash,
|
||||
ContentType = artifactMetadata.ContentType,
|
||||
Length = artifactMetadata.Length
|
||||
},
|
||||
IngestedAt = timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
document.Metadata ??= new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
document.Metadata["formatVersion"] = normalized.FormatVersion;
|
||||
document.Metadata["schemaVersion"] = schemaVersion;
|
||||
if (analyzerMeta is not null)
|
||||
{
|
||||
foreach (var kv in analyzerMeta)
|
||||
{
|
||||
document.Metadata[$"analyzer.{kv.Key}"] = kv.Value;
|
||||
}
|
||||
}
|
||||
document.GraphHash = graphHash;
|
||||
document.SchemaVersion = schemaVersion;
|
||||
document.Artifact.Path = artifactMetadata.Path;
|
||||
document.Artifact.Hash = artifactMetadata.Hash;
|
||||
document.Artifact.CasUri = artifactMetadata.CasUri;
|
||||
document.Artifact.ManifestPath = artifactMetadata.ManifestPath;
|
||||
document.Artifact.ManifestCasUri = artifactMetadata.ManifestCasUri;
|
||||
document.Artifact.GraphHash = graphHash;
|
||||
document.Artifact.ContentType = artifactMetadata.ContentType;
|
||||
document.Artifact.Length = artifactMetadata.Length;
|
||||
|
||||
document = await repository.UpsertAsync(document, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -166,7 +175,7 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService
|
||||
document.Artifact.Path,
|
||||
document.Artifact.Hash,
|
||||
document.Artifact.CasUri,
|
||||
graphHash,
|
||||
document.GraphHash,
|
||||
document.Artifact.ManifestCasUri,
|
||||
schemaVersion,
|
||||
document.Nodes.Count,
|
||||
@@ -216,13 +225,14 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
|
||||
private static string ComputeGraphHash(CallgraphParseResult result)
|
||||
private static string ComputeGraphHash(CallgraphDocument document)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
|
||||
builder.Append("schema|").Append(result.SchemaVersion).AppendLine();
|
||||
builder.Append("schema|").Append(document.Schema).AppendLine();
|
||||
builder.Append("language|").Append(document.LanguageType).Append('|').Append(document.Language).AppendLine();
|
||||
|
||||
foreach (var node in result.Nodes.OrderBy(n => n.Id, StringComparer.Ordinal))
|
||||
foreach (var node in document.Nodes.OrderBy(n => n.Id, StringComparer.Ordinal))
|
||||
{
|
||||
builder
|
||||
.Append(node.Id).Append('|')
|
||||
@@ -236,29 +246,62 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService
|
||||
.Append(node.BuildId).Append('|')
|
||||
.Append(node.CodeId).Append('|')
|
||||
.Append(node.Language).Append('|')
|
||||
.Append(node.SymbolKey).Append('|')
|
||||
.Append(node.ArtifactKey).Append('|')
|
||||
.Append(node.Visibility).Append('|')
|
||||
.Append(node.IsEntrypointCandidate).Append('|')
|
||||
.Append(node.Flags).Append('|')
|
||||
.Append(Join(node.Evidence)).Append('|')
|
||||
.Append(JoinDict(node.Analyzer))
|
||||
.Append(JoinDict(node.Analyzer)).Append('|')
|
||||
.Append(JoinDict(node.Attributes))
|
||||
.AppendLine();
|
||||
}
|
||||
|
||||
foreach (var edge in result.Edges.OrderBy(e => e.SourceId, StringComparer.Ordinal).ThenBy(e => e.TargetId, StringComparer.Ordinal))
|
||||
foreach (var edge in document.Edges
|
||||
.OrderBy(e => e.SourceId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.TargetId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Type, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Offset ?? -1))
|
||||
{
|
||||
builder
|
||||
.Append(edge.SourceId).Append("->").Append(edge.TargetId).Append('|')
|
||||
.Append(edge.Type).Append('|')
|
||||
.Append(edge.Kind).Append('|')
|
||||
.Append(edge.Reason).Append('|')
|
||||
.Append(edge.Weight.ToString("G17", CultureInfo.InvariantCulture)).Append('|')
|
||||
.Append(edge.Offset?.ToString(CultureInfo.InvariantCulture) ?? string.Empty).Append('|')
|
||||
.Append(edge.IsResolved).Append('|')
|
||||
.Append(edge.Provenance).Append('|')
|
||||
.Append(edge.Purl).Append('|')
|
||||
.Append(edge.SymbolDigest).Append('|')
|
||||
.Append(edge.Confidence?.ToString() ?? string.Empty).Append('|')
|
||||
.Append(edge.Confidence?.ToString("G17", CultureInfo.InvariantCulture) ?? string.Empty).Append('|')
|
||||
.Append(Join(edge.Candidates)).Append('|')
|
||||
.Append(Join(edge.Evidence))
|
||||
.AppendLine();
|
||||
}
|
||||
|
||||
foreach (var root in result.Roots.OrderBy(r => r.Id, StringComparer.Ordinal))
|
||||
foreach (var root in (document.Roots ?? new List<CallgraphRoot>()).OrderBy(r => r.Id, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append("root|").Append(root.Id).Append('|').Append(root.Phase).Append('|').Append(root.Source).AppendLine();
|
||||
}
|
||||
|
||||
foreach (var entrypoint in document.Entrypoints
|
||||
.OrderBy(e => (int)e.Phase)
|
||||
.ThenBy(e => e.Order)
|
||||
.ThenBy(e => e.NodeId, StringComparer.Ordinal))
|
||||
{
|
||||
builder
|
||||
.Append("entrypoint|").Append(entrypoint.NodeId).Append('|')
|
||||
.Append(entrypoint.Kind).Append('|')
|
||||
.Append(entrypoint.Framework).Append('|')
|
||||
.Append(entrypoint.Phase).Append('|')
|
||||
.Append(entrypoint.Route).Append('|')
|
||||
.Append(entrypoint.HttpMethod).Append('|')
|
||||
.Append(entrypoint.Source).Append('|')
|
||||
.Append(entrypoint.Order.ToString(CultureInfo.InvariantCulture))
|
||||
.AppendLine();
|
||||
}
|
||||
|
||||
return ComputeSha256(Encoding.UTF8.GetBytes(builder.ToString()));
|
||||
}
|
||||
|
||||
@@ -286,6 +329,21 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService
|
||||
}
|
||||
return ordered.ToString();
|
||||
}
|
||||
|
||||
private static string JoinDict(IReadOnlyDictionary<string, string>? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var ordered = new StringBuilder();
|
||||
foreach (var kv in values.OrderBy(k => k.Key, StringComparer.Ordinal))
|
||||
{
|
||||
ordered.Append(kv.Key).Append('=').Append(kv.Value).Append(';');
|
||||
}
|
||||
return ordered.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -35,6 +35,7 @@ internal sealed class CallgraphNormalizationService : ICallgraphNormalizationSer
|
||||
|
||||
var edges = NormalizeEdges(result.Edges, nodesById);
|
||||
var roots = NormalizeRoots(result.Roots);
|
||||
var entrypoints = NormalizeEntrypoints(result.Entrypoints, nodesById);
|
||||
|
||||
return new CallgraphParseResult(
|
||||
Nodes: nodesById.Values.OrderBy(n => n.Id, StringComparer.Ordinal).ToList(),
|
||||
@@ -42,7 +43,8 @@ internal sealed class CallgraphNormalizationService : ICallgraphNormalizationSer
|
||||
Roots: roots,
|
||||
FormatVersion: string.IsNullOrWhiteSpace(result.FormatVersion) ? "1.0" : result.FormatVersion.Trim(),
|
||||
SchemaVersion: string.IsNullOrWhiteSpace(result.SchemaVersion) ? "1.0" : result.SchemaVersion.Trim(),
|
||||
Analyzer: result.Analyzer);
|
||||
Analyzer: result.Analyzer,
|
||||
Entrypoints: entrypoints);
|
||||
}
|
||||
|
||||
private static CallgraphNode NormalizeNode(CallgraphNode node, string language)
|
||||
@@ -154,6 +156,79 @@ internal sealed class CallgraphNormalizationService : ICallgraphNormalizationSer
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CallgraphEntrypoint>? NormalizeEntrypoints(
|
||||
IReadOnlyList<CallgraphEntrypoint>? entrypoints,
|
||||
IReadOnlyDictionary<string, CallgraphNode> nodes)
|
||||
{
|
||||
if (entrypoints is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var list = new List<CallgraphEntrypoint>(entrypoints.Count);
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var entrypoint in entrypoints)
|
||||
{
|
||||
var nodeId = entrypoint.NodeId?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(nodeId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!nodes.ContainsKey(nodeId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = new CallgraphEntrypoint
|
||||
{
|
||||
NodeId = nodeId,
|
||||
Kind = entrypoint.Kind,
|
||||
Route = string.IsNullOrWhiteSpace(entrypoint.Route) ? null : entrypoint.Route.Trim(),
|
||||
HttpMethod = string.IsNullOrWhiteSpace(entrypoint.HttpMethod) ? null : entrypoint.HttpMethod.Trim().ToUpperInvariant(),
|
||||
Framework = entrypoint.Framework,
|
||||
Source = string.IsNullOrWhiteSpace(entrypoint.Source) ? null : entrypoint.Source.Trim(),
|
||||
Phase = entrypoint.Phase,
|
||||
Order = 0
|
||||
};
|
||||
|
||||
var key = $"{normalized.NodeId}|{normalized.Kind}|{normalized.Framework}|{normalized.Phase}|{normalized.Route}|{normalized.HttpMethod}|{normalized.Source}";
|
||||
if (seen.Add(key))
|
||||
{
|
||||
list.Add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
var sorted = list
|
||||
.OrderBy(e => (int)e.Phase)
|
||||
.ThenBy(e => e.NodeId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Kind)
|
||||
.ThenBy(e => e.Framework)
|
||||
.ThenBy(e => e.Route, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.HttpMethod, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Source, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var orderByPhase = sorted
|
||||
.GroupBy(e => e.Phase)
|
||||
.OrderBy(g => (int)g.Key)
|
||||
.ToList();
|
||||
|
||||
var ordered = new List<CallgraphEntrypoint>(sorted.Count);
|
||||
foreach (var group in orderByPhase)
|
||||
{
|
||||
var order = 0;
|
||||
foreach (var entrypoint in group)
|
||||
{
|
||||
entrypoint.Order = order++;
|
||||
ordered.Add(entrypoint);
|
||||
}
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private static string? DeriveNamespace(string id, string? file, string language)
|
||||
{
|
||||
if (string.Equals(language, "java", StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Options;
|
||||
|
||||
namespace StellaOps.Signals.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for computing multi-factor scores for unknowns.
|
||||
/// </summary>
|
||||
public interface IUnknownsScoringService
|
||||
{
|
||||
/// <summary>
|
||||
/// Recomputes scores for all unknowns in a subject.
|
||||
/// </summary>
|
||||
Task<UnknownsScoringResult> RecomputeAsync(
|
||||
string subjectKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Scores a single unknown using the 5-factor formula.
|
||||
/// </summary>
|
||||
Task<UnknownSymbolDocument> ScoreUnknownAsync(
|
||||
UnknownSymbolDocument unknown,
|
||||
UnknownsScoringOptions options,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of scoring computation.
|
||||
/// </summary>
|
||||
public sealed record UnknownsScoringResult(
|
||||
string SubjectKey,
|
||||
int TotalUnknowns,
|
||||
int HotCount,
|
||||
int WarmCount,
|
||||
int ColdCount,
|
||||
System.DateTimeOffset ComputedAt);
|
||||
279
src/Signals/StellaOps.Signals/Services/UnknownsScoringService.cs
Normal file
279
src/Signals/StellaOps.Signals/Services/UnknownsScoringService.cs
Normal file
@@ -0,0 +1,279 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Options;
|
||||
using StellaOps.Signals.Persistence;
|
||||
|
||||
namespace StellaOps.Signals.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Computes multi-factor scores for unknowns and assigns triage bands.
|
||||
/// </summary>
|
||||
public sealed class UnknownsScoringService : IUnknownsScoringService
|
||||
{
|
||||
private readonly IUnknownsRepository _repository;
|
||||
private readonly IDeploymentRefsRepository _deploymentRefs;
|
||||
private readonly IGraphMetricsRepository _graphMetrics;
|
||||
private readonly IOptions<UnknownsScoringOptions> _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<UnknownsScoringService> _logger;
|
||||
|
||||
public UnknownsScoringService(
|
||||
IUnknownsRepository repository,
|
||||
IDeploymentRefsRepository deploymentRefs,
|
||||
IGraphMetricsRepository graphMetrics,
|
||||
IOptions<UnknownsScoringOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<UnknownsScoringService> logger)
|
||||
{
|
||||
_repository = repository;
|
||||
_deploymentRefs = deploymentRefs;
|
||||
_graphMetrics = graphMetrics;
|
||||
_options = options;
|
||||
_timeProvider = timeProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recomputes scores for all unknowns in a subject.
|
||||
/// </summary>
|
||||
public async Task<UnknownsScoringResult> RecomputeAsync(
|
||||
string subjectKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var unknowns = await _repository.GetBySubjectAsync(subjectKey, cancellationToken);
|
||||
var updated = new List<UnknownSymbolDocument>();
|
||||
var opts = _options.Value;
|
||||
|
||||
foreach (var unknown in unknowns)
|
||||
{
|
||||
var scored = await ScoreUnknownAsync(unknown, opts, cancellationToken);
|
||||
updated.Add(scored);
|
||||
}
|
||||
|
||||
await _repository.BulkUpdateAsync(updated, cancellationToken);
|
||||
|
||||
return new UnknownsScoringResult(
|
||||
SubjectKey: subjectKey,
|
||||
TotalUnknowns: updated.Count,
|
||||
HotCount: updated.Count(u => u.Band == UnknownsBand.Hot),
|
||||
WarmCount: updated.Count(u => u.Band == UnknownsBand.Warm),
|
||||
ColdCount: updated.Count(u => u.Band == UnknownsBand.Cold),
|
||||
ComputedAt: _timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scores a single unknown using the 5-factor formula.
|
||||
/// </summary>
|
||||
public async Task<UnknownSymbolDocument> ScoreUnknownAsync(
|
||||
UnknownSymbolDocument unknown,
|
||||
UnknownsScoringOptions opts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var trace = new UnknownsNormalizationTrace
|
||||
{
|
||||
ComputedAt = _timeProvider.GetUtcNow(),
|
||||
Weights = new Dictionary<string, double>
|
||||
{
|
||||
["wP"] = opts.WeightPopularity,
|
||||
["wE"] = opts.WeightExploitPotential,
|
||||
["wU"] = opts.WeightUncertainty,
|
||||
["wC"] = opts.WeightCentrality,
|
||||
["wS"] = opts.WeightStaleness
|
||||
}
|
||||
};
|
||||
|
||||
// Factor P: Popularity (deployment impact)
|
||||
var (popularityScore, deploymentCount) = await ComputePopularityAsync(
|
||||
unknown.Purl, opts, cancellationToken);
|
||||
unknown.PopularityScore = popularityScore;
|
||||
unknown.DeploymentCount = deploymentCount;
|
||||
trace.RawPopularity = deploymentCount;
|
||||
trace.NormalizedPopularity = popularityScore;
|
||||
trace.PopularityFormula = $"min(1, log10(1 + {deploymentCount}) / log10(1 + {opts.PopularityMaxDeployments}))";
|
||||
|
||||
// Factor E: Exploit potential (CVE severity)
|
||||
var exploitScore = ComputeExploitPotential(unknown);
|
||||
unknown.ExploitPotentialScore = exploitScore;
|
||||
trace.RawExploitPotential = exploitScore;
|
||||
trace.NormalizedExploitPotential = exploitScore;
|
||||
|
||||
// Factor U: Uncertainty density (from flags)
|
||||
var (uncertaintyScore, activeFlags) = ComputeUncertainty(unknown.Flags, opts);
|
||||
unknown.UncertaintyScore = uncertaintyScore;
|
||||
trace.RawUncertainty = uncertaintyScore;
|
||||
trace.NormalizedUncertainty = Math.Min(1.0, uncertaintyScore);
|
||||
trace.ActiveFlags = activeFlags;
|
||||
|
||||
// Factor C: Graph centrality
|
||||
var (centralityScore, degree, betweenness) = await ComputeCentralityAsync(
|
||||
unknown.SymbolId, unknown.CallgraphId, opts, cancellationToken);
|
||||
unknown.CentralityScore = centralityScore;
|
||||
unknown.DegreeCentrality = degree;
|
||||
unknown.BetweennessCentrality = betweenness;
|
||||
trace.RawCentrality = betweenness;
|
||||
trace.NormalizedCentrality = centralityScore;
|
||||
|
||||
// Factor S: Evidence staleness
|
||||
var (stalenessScore, daysSince) = ComputeStaleness(unknown.LastAnalyzedAt, opts);
|
||||
unknown.StalenessScore = stalenessScore;
|
||||
unknown.DaysSinceLastAnalysis = daysSince;
|
||||
trace.RawStaleness = daysSince;
|
||||
trace.NormalizedStaleness = stalenessScore;
|
||||
|
||||
// Composite score
|
||||
var score = Math.Clamp(
|
||||
opts.WeightPopularity * unknown.PopularityScore +
|
||||
opts.WeightExploitPotential * unknown.ExploitPotentialScore +
|
||||
opts.WeightUncertainty * unknown.UncertaintyScore +
|
||||
opts.WeightCentrality * unknown.CentralityScore +
|
||||
opts.WeightStaleness * unknown.StalenessScore,
|
||||
0.0, 1.0);
|
||||
|
||||
unknown.Score = score;
|
||||
trace.FinalScore = score;
|
||||
|
||||
// Band assignment
|
||||
unknown.Band = score switch
|
||||
{
|
||||
>= 0.70 => UnknownsBand.Hot,
|
||||
>= 0.40 => UnknownsBand.Warm,
|
||||
_ => UnknownsBand.Cold
|
||||
};
|
||||
trace.AssignedBand = unknown.Band.ToString();
|
||||
|
||||
// Schedule next rescan based on band
|
||||
unknown.NextScheduledRescan = unknown.Band switch
|
||||
{
|
||||
UnknownsBand.Hot => _timeProvider.GetUtcNow().AddMinutes(15),
|
||||
UnknownsBand.Warm => _timeProvider.GetUtcNow().AddHours(opts.WarmRescanHours),
|
||||
_ => _timeProvider.GetUtcNow().AddDays(opts.ColdRescanDays)
|
||||
};
|
||||
|
||||
unknown.NormalizationTrace = trace;
|
||||
unknown.UpdatedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
_logger.LogDebug(
|
||||
"Scored unknown {UnknownId}: P={P:F2} E={E:F2} U={U:F2} C={C:F2} S={S:F2} → Score={Score:F2} Band={Band}",
|
||||
unknown.Id,
|
||||
unknown.PopularityScore,
|
||||
unknown.ExploitPotentialScore,
|
||||
unknown.UncertaintyScore,
|
||||
unknown.CentralityScore,
|
||||
unknown.StalenessScore,
|
||||
unknown.Score,
|
||||
unknown.Band);
|
||||
|
||||
return unknown;
|
||||
}
|
||||
|
||||
private async Task<(double Score, int DeploymentCount)> ComputePopularityAsync(
|
||||
string? purl,
|
||||
UnknownsScoringOptions opts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(purl))
|
||||
return (0.0, 0);
|
||||
|
||||
var deployments = await _deploymentRefs.CountDeploymentsAsync(purl, cancellationToken);
|
||||
|
||||
// Formula: P = min(1, log10(1 + deployments) / log10(1 + maxDeployments))
|
||||
var score = Math.Min(1.0,
|
||||
Math.Log10(1 + deployments) / Math.Log10(1 + opts.PopularityMaxDeployments));
|
||||
|
||||
return (score, deployments);
|
||||
}
|
||||
|
||||
private static double ComputeExploitPotential(UnknownSymbolDocument unknown)
|
||||
{
|
||||
// If we have associated CVE severity, use it
|
||||
// Otherwise, assume medium potential (0.5)
|
||||
// This could be enhanced with KEV lookup, exploit DB, etc.
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
private static (double Score, List<string> ActiveFlags) ComputeUncertainty(
|
||||
UnknownFlags flags,
|
||||
UnknownsScoringOptions opts)
|
||||
{
|
||||
var score = 0.0;
|
||||
var activeFlags = new List<string>();
|
||||
|
||||
if (flags.NoProvenanceAnchor)
|
||||
{
|
||||
score += opts.FlagWeightNoProvenance;
|
||||
activeFlags.Add("NoProvenanceAnchor");
|
||||
}
|
||||
if (flags.VersionRange)
|
||||
{
|
||||
score += opts.FlagWeightVersionRange;
|
||||
activeFlags.Add("VersionRange");
|
||||
}
|
||||
if (flags.ConflictingFeeds)
|
||||
{
|
||||
score += opts.FlagWeightConflictingFeeds;
|
||||
activeFlags.Add("ConflictingFeeds");
|
||||
}
|
||||
if (flags.MissingVector)
|
||||
{
|
||||
score += opts.FlagWeightMissingVector;
|
||||
activeFlags.Add("MissingVector");
|
||||
}
|
||||
if (flags.UnreachableSourceAdvisory)
|
||||
{
|
||||
score += opts.FlagWeightUnreachableSource;
|
||||
activeFlags.Add("UnreachableSourceAdvisory");
|
||||
}
|
||||
if (flags.DynamicCallTarget)
|
||||
{
|
||||
score += opts.FlagWeightDynamicTarget;
|
||||
activeFlags.Add("DynamicCallTarget");
|
||||
}
|
||||
if (flags.ExternalAssembly)
|
||||
{
|
||||
score += opts.FlagWeightExternalAssembly;
|
||||
activeFlags.Add("ExternalAssembly");
|
||||
}
|
||||
|
||||
return (Math.Min(1.0, score), activeFlags);
|
||||
}
|
||||
|
||||
private async Task<(double Score, int Degree, double Betweenness)> ComputeCentralityAsync(
|
||||
string? symbolId,
|
||||
string? callgraphId,
|
||||
UnknownsScoringOptions opts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(symbolId) || string.IsNullOrWhiteSpace(callgraphId))
|
||||
return (0.0, 0, 0.0);
|
||||
|
||||
var metrics = await _graphMetrics.GetMetricsAsync(symbolId, callgraphId, cancellationToken);
|
||||
if (metrics is null)
|
||||
return (0.0, 0, 0.0);
|
||||
|
||||
// Normalize betweenness to 0-1 range
|
||||
var normalizedBetweenness = Math.Min(1.0, metrics.Betweenness / opts.CentralityMaxBetweenness);
|
||||
|
||||
return (normalizedBetweenness, metrics.Degree, metrics.Betweenness);
|
||||
}
|
||||
|
||||
private (double Score, int DaysSince) ComputeStaleness(
|
||||
DateTimeOffset? lastAnalyzedAt,
|
||||
UnknownsScoringOptions opts)
|
||||
{
|
||||
if (lastAnalyzedAt is null)
|
||||
return (1.0, opts.StalenessMaxDays); // Never analyzed = maximum staleness
|
||||
|
||||
var daysSince = (int)(_timeProvider.GetUtcNow() - lastAnalyzedAt.Value).TotalDays;
|
||||
|
||||
// Formula: S = min(1, age_days / max_days)
|
||||
var score = Math.Min(1.0, (double)daysSince / opts.StalenessMaxDays);
|
||||
|
||||
return (score, daysSince);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user