This commit is contained in:
StellaOps Bot
2025-12-14 23:20:14 +02:00
parent 3411e825cd
commit b058dbe031
356 changed files with 68310 additions and 1108 deletions

View File

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

View File

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

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

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

View File

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

View File

@@ -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";
}

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

View 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&lt;I,T&gt;).
/// </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
}

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

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

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

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

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

View File

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

View 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 &lt;= Score &lt; 0.70. Scheduled rescan 12-72h.
/// </summary>
Warm,
/// <summary>
/// Score &lt; 0.40. Weekly batch processing.
/// </summary>
Cold
}

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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