Add Canonical JSON serialization library with tests and documentation
- Implemented CanonJson class for deterministic JSON serialization and hashing. - Added unit tests for CanonJson functionality, covering various scenarios including key sorting, handling of nested objects, arrays, and special characters. - Created project files for the Canonical JSON library and its tests, including necessary package references. - Added README.md for library usage and API reference. - Introduced RabbitMqIntegrationFactAttribute for conditional RabbitMQ integration tests.
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for <see cref="ReachabilityAnalyzer"/>.
|
||||
/// Defines limits and ordering rules for deterministic path output.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Sprint: SPRINT_3700_0001_0001 (WIT-007A, WIT-007B)
|
||||
/// Contract: ReachabilityAnalyzer → PathWitnessBuilder output contract
|
||||
///
|
||||
/// Determinism guarantees:
|
||||
/// - Paths are ordered by (SinkId ASC, EntrypointId ASC, PathLength ASC)
|
||||
/// - Node IDs within paths are ordered from entrypoint to sink (caller → callee)
|
||||
/// - Maximum caps prevent unbounded output
|
||||
/// </remarks>
|
||||
public sealed record ReachabilityAnalysisOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Default options with sensible limits.
|
||||
/// </summary>
|
||||
public static ReachabilityAnalysisOptions Default { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Maximum depth for BFS traversal (0 = unlimited, default = 256).
|
||||
/// Prevents infinite loops in cyclic graphs.
|
||||
/// </summary>
|
||||
public int MaxDepth { get; init; } = 256;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of paths to return per sink (default = 10).
|
||||
/// Limits witness explosion when many entrypoints reach the same sink.
|
||||
/// </summary>
|
||||
public int MaxPathsPerSink { get; init; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum total paths to return (default = 100).
|
||||
/// Hard cap to prevent memory issues with highly connected graphs.
|
||||
/// </summary>
|
||||
public int MaxTotalPaths { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include node metadata in path reconstruction (default = true).
|
||||
/// When false, paths only contain node IDs without additional context.
|
||||
/// </summary>
|
||||
public bool IncludeNodeMetadata { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Explicit list of sink node IDs to target (default = null, meaning use snapshot.SinkIds).
|
||||
/// When set, analysis will only find paths to these specific sinks.
|
||||
/// This enables targeted witness generation for specific vulnerabilities.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Sprint: SPRINT_3700_0001_0001 (WIT-007B)
|
||||
/// Enables: PathWitnessBuilder can request paths to specific trigger methods.
|
||||
/// </remarks>
|
||||
public ImmutableArray<string>? ExplicitSinks { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validates options and returns sanitized values.
|
||||
/// </summary>
|
||||
public ReachabilityAnalysisOptions Validated()
|
||||
{
|
||||
// Normalize explicit sinks: trim, dedupe, order
|
||||
ImmutableArray<string>? normalizedSinks = null;
|
||||
if (ExplicitSinks.HasValue && !ExplicitSinks.Value.IsDefaultOrEmpty)
|
||||
{
|
||||
normalizedSinks = ExplicitSinks.Value
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(s => s.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(s => s, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
return new ReachabilityAnalysisOptions
|
||||
{
|
||||
MaxDepth = MaxDepth <= 0 ? 256 : Math.Min(MaxDepth, 1024),
|
||||
MaxPathsPerSink = MaxPathsPerSink <= 0 ? 10 : Math.Min(MaxPathsPerSink, 100),
|
||||
MaxTotalPaths = MaxTotalPaths <= 0 ? 100 : Math.Min(MaxTotalPaths, 1000),
|
||||
IncludeNodeMetadata = IncludeNodeMetadata,
|
||||
ExplicitSinks = normalizedSinks
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,53 @@ using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph;
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes call graph reachability from entrypoints to sinks using BFS traversal.
|
||||
/// Provides deterministically-ordered paths suitable for witness generation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Sprint: SPRINT_3700_0001_0001 (WIT-007A, WIT-007B)
|
||||
/// Contract: Paths are ordered by (SinkId ASC, EntrypointId ASC, PathLength ASC).
|
||||
/// Node IDs within paths are ordered from entrypoint to sink (caller → callee).
|
||||
/// </remarks>
|
||||
public sealed class ReachabilityAnalyzer
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly int _maxDepth;
|
||||
private readonly ReachabilityAnalysisOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new ReachabilityAnalyzer with default options.
|
||||
/// </summary>
|
||||
public ReachabilityAnalyzer(TimeProvider? timeProvider = null, int maxDepth = 256)
|
||||
: this(timeProvider, new ReachabilityAnalysisOptions { MaxDepth = maxDepth })
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_maxDepth = maxDepth <= 0 ? 256 : maxDepth;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new ReachabilityAnalyzer with specified options.
|
||||
/// </summary>
|
||||
public ReachabilityAnalyzer(TimeProvider? timeProvider, ReachabilityAnalysisOptions options)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_options = (options ?? ReachabilityAnalysisOptions.Default).Validated();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes reachability using default options.
|
||||
/// </summary>
|
||||
public ReachabilityAnalysisResult Analyze(CallGraphSnapshot snapshot)
|
||||
=> Analyze(snapshot, _options);
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes reachability with explicit options for this invocation.
|
||||
/// </summary>
|
||||
/// <param name="snapshot">The call graph snapshot to analyze.</param>
|
||||
/// <param name="options">Options controlling limits and output format.</param>
|
||||
/// <returns>Analysis result with deterministically-ordered paths.</returns>
|
||||
public ReachabilityAnalysisResult Analyze(CallGraphSnapshot snapshot, ReachabilityAnalysisOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
var opts = (options ?? _options).Validated();
|
||||
var trimmed = snapshot.Trimmed();
|
||||
|
||||
var adjacency = BuildAdjacency(trimmed);
|
||||
@@ -47,7 +80,7 @@ public sealed class ReachabilityAnalyzer
|
||||
continue;
|
||||
}
|
||||
|
||||
if (depth >= _maxDepth)
|
||||
if (depth >= opts.MaxDepth)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -72,12 +105,18 @@ public sealed class ReachabilityAnalyzer
|
||||
}
|
||||
|
||||
var reachableNodes = origins.Keys.OrderBy(id => id, StringComparer.Ordinal).ToImmutableArray();
|
||||
var reachableSinks = trimmed.SinkIds
|
||||
|
||||
// WIT-007B: Use explicit sinks if specified, otherwise use snapshot sinks
|
||||
var targetSinks = opts.ExplicitSinks.HasValue && !opts.ExplicitSinks.Value.IsDefaultOrEmpty
|
||||
? opts.ExplicitSinks.Value
|
||||
: trimmed.SinkIds;
|
||||
|
||||
var reachableSinks = targetSinks
|
||||
.Where(origins.ContainsKey)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var paths = BuildPaths(reachableSinks, origins, parents);
|
||||
var paths = BuildPaths(reachableSinks, origins, parents, opts);
|
||||
|
||||
var computedAt = _timeProvider.GetUtcNow();
|
||||
var provisional = new ReachabilityAnalysisResult(
|
||||
@@ -136,9 +175,12 @@ public sealed class ReachabilityAnalyzer
|
||||
private static ImmutableArray<ReachabilityPath> BuildPaths(
|
||||
ImmutableArray<string> reachableSinks,
|
||||
Dictionary<string, string> origins,
|
||||
Dictionary<string, string?> parents)
|
||||
Dictionary<string, string?> parents,
|
||||
ReachabilityAnalysisOptions options)
|
||||
{
|
||||
var paths = new List<ReachabilityPath>(reachableSinks.Length);
|
||||
var pathCountPerSink = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var sinkId in reachableSinks)
|
||||
{
|
||||
if (!origins.TryGetValue(sinkId, out var origin))
|
||||
@@ -146,13 +188,29 @@ public sealed class ReachabilityAnalyzer
|
||||
continue;
|
||||
}
|
||||
|
||||
// Enforce per-sink limit
|
||||
pathCountPerSink.TryGetValue(sinkId, out var currentCount);
|
||||
if (currentCount >= options.MaxPathsPerSink)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
pathCountPerSink[sinkId] = currentCount + 1;
|
||||
|
||||
var nodeIds = ReconstructPathNodeIds(sinkId, parents);
|
||||
paths.Add(new ReachabilityPath(origin, sinkId, nodeIds));
|
||||
|
||||
// Enforce total path limit
|
||||
if (paths.Count >= options.MaxTotalPaths)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic ordering: SinkId ASC, EntrypointId ASC, PathLength ASC
|
||||
return paths
|
||||
.OrderBy(p => p.SinkId, StringComparer.Ordinal)
|
||||
.ThenBy(p => p.EntrypointId, StringComparer.Ordinal)
|
||||
.ThenBy(p => p.NodeIds.Length)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ComponentIdentity.cs
|
||||
// Sprint: SPRINT_3850_0001_0001 (Competitive Gap Closure)
|
||||
// Task: SBOM-L-001 - Define component identity schema
|
||||
// Description: Component identity with source, digest, and build recipe hash.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a unique component identity in the SBOM ledger.
|
||||
/// Combines source reference, content digest, and build recipe for
|
||||
/// deterministic identification across builds and environments.
|
||||
/// </summary>
|
||||
public sealed record ComponentIdentity
|
||||
{
|
||||
/// <summary>
|
||||
/// Package URL (PURL) identifying the component.
|
||||
/// Example: pkg:npm/lodash@4.17.21
|
||||
/// </summary>
|
||||
[JsonPropertyName("purl")]
|
||||
public required string Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content digest of the component artifact.
|
||||
/// Format: algorithm:hex (e.g., sha256:abc123...)
|
||||
/// </summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Build recipe hash capturing build-time configuration.
|
||||
/// Includes compiler flags, environment, and reproducibility markers.
|
||||
/// </summary>
|
||||
[JsonPropertyName("buildRecipeHash")]
|
||||
public string? BuildRecipeHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source repository reference.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sourceRef")]
|
||||
public SourceReference? SourceRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Layer index where component was introduced (for container images).
|
||||
/// </summary>
|
||||
[JsonPropertyName("layerIndex")]
|
||||
public int? LayerIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Layer digest where component was introduced.
|
||||
/// </summary>
|
||||
[JsonPropertyName("layerDigest")]
|
||||
public string? LayerDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Loader that resolved this component (npm, pip, maven, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("loader")]
|
||||
public string? Loader { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this component is a direct dependency or transitive.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isDirect")]
|
||||
public bool IsDirect { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Parent component identities (for dependency graph).
|
||||
/// </summary>
|
||||
[JsonPropertyName("parentIds")]
|
||||
public ImmutableArray<string> ParentIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Scope of the dependency (runtime, dev, test, optional).
|
||||
/// </summary>
|
||||
[JsonPropertyName("scope")]
|
||||
public DependencyScope Scope { get; init; } = DependencyScope.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// Computes the canonical identity hash.
|
||||
/// </summary>
|
||||
public string ComputeIdentityHash()
|
||||
{
|
||||
var canonical = StellaOps.Canonical.Json.CanonJson.Canonicalize(this);
|
||||
return StellaOps.Canonical.Json.CanonJson.Sha256Prefixed(canonical);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source code repository reference.
|
||||
/// </summary>
|
||||
public sealed record SourceReference
|
||||
{
|
||||
/// <summary>
|
||||
/// Repository URL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("repositoryUrl")]
|
||||
public required string RepositoryUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Commit SHA or tag.
|
||||
/// </summary>
|
||||
[JsonPropertyName("revision")]
|
||||
public string? Revision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path within the repository.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path")]
|
||||
public string? Path { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VCS type (git, svn, hg).
|
||||
/// </summary>
|
||||
[JsonPropertyName("vcsType")]
|
||||
public string VcsType { get; init; } = "git";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dependency scope.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum DependencyScope
|
||||
{
|
||||
/// <summary>Runtime dependency.</summary>
|
||||
Runtime,
|
||||
|
||||
/// <summary>Development dependency.</summary>
|
||||
Development,
|
||||
|
||||
/// <summary>Test dependency.</summary>
|
||||
Test,
|
||||
|
||||
/// <summary>Optional/peer dependency.</summary>
|
||||
Optional,
|
||||
|
||||
/// <summary>Build-time only dependency.</summary>
|
||||
Build
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build recipe capturing reproducibility information.
|
||||
/// </summary>
|
||||
public sealed record BuildRecipe
|
||||
{
|
||||
/// <summary>
|
||||
/// Builder image or tool version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("builder")]
|
||||
public required string Builder { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Build command or entrypoint.
|
||||
/// </summary>
|
||||
[JsonPropertyName("buildCommand")]
|
||||
public string? BuildCommand { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Environment variables affecting the build (sanitized).
|
||||
/// </summary>
|
||||
[JsonPropertyName("buildEnv")]
|
||||
public ImmutableDictionary<string, string> BuildEnv { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Compiler/interpreter version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("compilerVersion")]
|
||||
public string? CompilerVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Build timestamp (if reproducible builds are not used).
|
||||
/// </summary>
|
||||
[JsonPropertyName("buildTimestamp")]
|
||||
public DateTimeOffset? BuildTimestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether build is reproducible (hermetic).
|
||||
/// </summary>
|
||||
[JsonPropertyName("reproducible")]
|
||||
public bool Reproducible { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SLSA provenance level (1-4).
|
||||
/// </summary>
|
||||
[JsonPropertyName("slsaLevel")]
|
||||
public int? SlsaLevel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Computes the recipe hash.
|
||||
/// </summary>
|
||||
public string ComputeHash()
|
||||
{
|
||||
var canonical = StellaOps.Canonical.Json.CanonJson.Canonicalize(this);
|
||||
return StellaOps.Canonical.Json.CanonJson.Sha256Prefixed(canonical);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// FalsificationConditions.cs
|
||||
// Sprint: SPRINT_3850_0001_0001 (Competitive Gap Closure)
|
||||
// Task: EXP-F-004 - Falsification conditions per finding
|
||||
// Description: Models for specifying conditions that would falsify a finding.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Conditions that would falsify (invalidate) a vulnerability finding.
|
||||
/// Inspired by Popperian falsifiability - what evidence would disprove this finding?
|
||||
/// </summary>
|
||||
public sealed record FalsificationConditions
|
||||
{
|
||||
/// <summary>
|
||||
/// Finding identifier these conditions apply to.
|
||||
/// </summary>
|
||||
[JsonPropertyName("findingId")]
|
||||
public required string FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability ID (CVE, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component PURL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("componentPurl")]
|
||||
public required string ComponentPurl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Conditions that would falsify the finding.
|
||||
/// </summary>
|
||||
[JsonPropertyName("conditions")]
|
||||
public required ImmutableArray<FalsificationCondition> Conditions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Logical operator for combining conditions.
|
||||
/// </summary>
|
||||
[JsonPropertyName("operator")]
|
||||
public FalsificationOperator Operator { get; init; } = FalsificationOperator.Any;
|
||||
|
||||
/// <summary>
|
||||
/// When these conditions were generated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Generator that produced these conditions.
|
||||
/// </summary>
|
||||
[JsonPropertyName("generator")]
|
||||
public required string Generator { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single falsification condition.
|
||||
/// </summary>
|
||||
public sealed record FalsificationCondition
|
||||
{
|
||||
/// <summary>
|
||||
/// Condition identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of condition.
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public required FalsificationConditionType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Machine-readable predicate (SPL, Rego, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate")]
|
||||
public string? Predicate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected evidence type that would satisfy this condition.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidenceType")]
|
||||
public required string EvidenceType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this condition has been evaluated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evaluated")]
|
||||
public bool Evaluated { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evaluation result if evaluated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("result")]
|
||||
public FalsificationResult? Result { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence in the condition evaluation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public double Confidence { get; init; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Effort required to verify this condition.
|
||||
/// </summary>
|
||||
[JsonPropertyName("effort")]
|
||||
public VerificationEffort Effort { get; init; } = VerificationEffort.Low;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of falsification conditions.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum FalsificationConditionType
|
||||
{
|
||||
/// <summary>Code path is unreachable.</summary>
|
||||
CodePathUnreachable,
|
||||
|
||||
/// <summary>Vulnerable function is not called.</summary>
|
||||
FunctionNotCalled,
|
||||
|
||||
/// <summary>Component is not present.</summary>
|
||||
ComponentNotPresent,
|
||||
|
||||
/// <summary>Version is not affected.</summary>
|
||||
VersionNotAffected,
|
||||
|
||||
/// <summary>Dependency is dev-only.</summary>
|
||||
DevDependencyOnly,
|
||||
|
||||
/// <summary>Required precondition is false.</summary>
|
||||
PreconditionFalse,
|
||||
|
||||
/// <summary>Compensating control exists.</summary>
|
||||
CompensatingControl,
|
||||
|
||||
/// <summary>VEX from vendor says not affected.</summary>
|
||||
VendorVexNotAffected,
|
||||
|
||||
/// <summary>Runtime environment prevents exploit.</summary>
|
||||
RuntimePrevents,
|
||||
|
||||
/// <summary>Network isolation prevents exploit.</summary>
|
||||
NetworkIsolated,
|
||||
|
||||
/// <summary>Input validation prevents exploit.</summary>
|
||||
InputValidated,
|
||||
|
||||
/// <summary>Fix already applied.</summary>
|
||||
FixApplied,
|
||||
|
||||
/// <summary>Backport fixes the issue.</summary>
|
||||
BackportApplied,
|
||||
|
||||
/// <summary>Custom condition.</summary>
|
||||
Custom
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Operator for combining conditions.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum FalsificationOperator
|
||||
{
|
||||
/// <summary>Any condition falsifies (OR).</summary>
|
||||
Any,
|
||||
|
||||
/// <summary>All conditions required (AND).</summary>
|
||||
All
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of evaluating a falsification condition.
|
||||
/// </summary>
|
||||
public sealed record FalsificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the condition is satisfied (finding is falsified).
|
||||
/// </summary>
|
||||
[JsonPropertyName("satisfied")]
|
||||
public required bool Satisfied { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence supporting the result.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidence")]
|
||||
public string? Evidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence digest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidenceDigest")]
|
||||
public string? EvidenceDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When evaluated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evaluatedAt")]
|
||||
public required DateTimeOffset EvaluatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evaluator that produced the result.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evaluator")]
|
||||
public required string Evaluator { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence in the result.
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Explanation of the result.
|
||||
/// </summary>
|
||||
[JsonPropertyName("explanation")]
|
||||
public string? Explanation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Effort levels for verification.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum VerificationEffort
|
||||
{
|
||||
/// <summary>Automatic, no human effort.</summary>
|
||||
Automatic,
|
||||
|
||||
/// <summary>Low effort (quick check).</summary>
|
||||
Low,
|
||||
|
||||
/// <summary>Medium effort (investigation needed).</summary>
|
||||
Medium,
|
||||
|
||||
/// <summary>High effort (significant analysis).</summary>
|
||||
High,
|
||||
|
||||
/// <summary>Expert required.</summary>
|
||||
Expert
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generator for falsification conditions.
|
||||
/// </summary>
|
||||
public interface IFalsificationConditionGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates falsification conditions for a finding.
|
||||
/// </summary>
|
||||
FalsificationConditions Generate(FindingContext context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context for generating falsification conditions.
|
||||
/// </summary>
|
||||
public sealed record FindingContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Finding identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("findingId")]
|
||||
public required string FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component PURL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("componentPurl")]
|
||||
public required string ComponentPurl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Affected versions.
|
||||
/// </summary>
|
||||
[JsonPropertyName("affectedVersions")]
|
||||
public ImmutableArray<string> AffectedVersions { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Fixed versions.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fixedVersions")]
|
||||
public ImmutableArray<string> FixedVersions { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// CWE IDs.
|
||||
/// </summary>
|
||||
[JsonPropertyName("cweIds")]
|
||||
public ImmutableArray<string> CweIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Attack vector from CVSS.
|
||||
/// </summary>
|
||||
[JsonPropertyName("attackVector")]
|
||||
public string? AttackVector { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether reachability data is available.
|
||||
/// </summary>
|
||||
[JsonPropertyName("hasReachabilityData")]
|
||||
public bool HasReachabilityData { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Dependency scope (runtime, dev, test).
|
||||
/// </summary>
|
||||
[JsonPropertyName("dependencyScope")]
|
||||
public string? DependencyScope { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default falsification condition generator.
|
||||
/// </summary>
|
||||
public sealed class DefaultFalsificationConditionGenerator : IFalsificationConditionGenerator
|
||||
{
|
||||
public FalsificationConditions Generate(FindingContext context)
|
||||
{
|
||||
var conditions = new List<FalsificationCondition>();
|
||||
var id = 0;
|
||||
|
||||
// Always add: component not present
|
||||
conditions.Add(new FalsificationCondition
|
||||
{
|
||||
Id = $"FC-{++id:D3}",
|
||||
Type = FalsificationConditionType.ComponentNotPresent,
|
||||
Description = $"Component {context.ComponentPurl} is not actually present in the artifact",
|
||||
EvidenceType = "sbom-verification",
|
||||
Effort = VerificationEffort.Automatic
|
||||
});
|
||||
|
||||
// Version check if fixed versions known
|
||||
if (context.FixedVersions.Length > 0)
|
||||
{
|
||||
conditions.Add(new FalsificationCondition
|
||||
{
|
||||
Id = $"FC-{++id:D3}",
|
||||
Type = FalsificationConditionType.VersionNotAffected,
|
||||
Description = $"Installed version is >= {string.Join(" or ", context.FixedVersions)}",
|
||||
EvidenceType = "version-verification",
|
||||
Effort = VerificationEffort.Low
|
||||
});
|
||||
}
|
||||
|
||||
// Reachability condition
|
||||
conditions.Add(new FalsificationCondition
|
||||
{
|
||||
Id = $"FC-{++id:D3}",
|
||||
Type = FalsificationConditionType.CodePathUnreachable,
|
||||
Description = "Vulnerable code path is not reachable from application entry points",
|
||||
EvidenceType = "reachability-analysis",
|
||||
Effort = context.HasReachabilityData ? VerificationEffort.Automatic : VerificationEffort.Medium
|
||||
});
|
||||
|
||||
// Dev dependency check
|
||||
if (context.DependencyScope == "Development" || context.DependencyScope == "Test")
|
||||
{
|
||||
conditions.Add(new FalsificationCondition
|
||||
{
|
||||
Id = $"FC-{++id:D3}",
|
||||
Type = FalsificationConditionType.DevDependencyOnly,
|
||||
Description = "Component is only used in development/test and not in production artifact",
|
||||
EvidenceType = "scope-verification",
|
||||
Effort = VerificationEffort.Low
|
||||
});
|
||||
}
|
||||
|
||||
// Network isolation for network-based attacks
|
||||
if (context.AttackVector == "Network" || context.AttackVector == "N")
|
||||
{
|
||||
conditions.Add(new FalsificationCondition
|
||||
{
|
||||
Id = $"FC-{++id:D3}",
|
||||
Type = FalsificationConditionType.NetworkIsolated,
|
||||
Description = "Component is not exposed to network traffic (air-gapped or internal only)",
|
||||
EvidenceType = "network-topology",
|
||||
Effort = VerificationEffort.Medium
|
||||
});
|
||||
}
|
||||
|
||||
// VEX from vendor
|
||||
conditions.Add(new FalsificationCondition
|
||||
{
|
||||
Id = $"FC-{++id:D3}",
|
||||
Type = FalsificationConditionType.VendorVexNotAffected,
|
||||
Description = "Vendor VEX statement indicates not_affected for this deployment",
|
||||
EvidenceType = "vex-statement",
|
||||
Effort = VerificationEffort.Low
|
||||
});
|
||||
|
||||
// Compensating control
|
||||
conditions.Add(new FalsificationCondition
|
||||
{
|
||||
Id = $"FC-{++id:D3}",
|
||||
Type = FalsificationConditionType.CompensatingControl,
|
||||
Description = "Compensating control (WAF, sandbox, etc.) mitigates the vulnerability",
|
||||
EvidenceType = "control-documentation",
|
||||
Effort = VerificationEffort.Medium
|
||||
});
|
||||
|
||||
return new FalsificationConditions
|
||||
{
|
||||
FindingId = context.FindingId,
|
||||
VulnerabilityId = context.VulnerabilityId,
|
||||
ComponentPurl = context.ComponentPurl,
|
||||
Conditions = conditions.ToImmutableArray(),
|
||||
Operator = FalsificationOperator.Any,
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
Generator = "StellaOps.DefaultFalsificationGenerator/1.0"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// LayerDependencyGraph.cs
|
||||
// Sprint: SPRINT_3850_0001_0001 (Competitive Gap Closure)
|
||||
// Task: SBOM-L-003 - Layer-aware dependency graphs with loader resolution
|
||||
// Description: Dependency graph that tracks layer provenance and loader info.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Layer-aware dependency graph for container images.
|
||||
/// Tracks which layer introduced each dependency and which loader resolved it.
|
||||
/// </summary>
|
||||
public sealed class LayerDependencyGraph
|
||||
{
|
||||
private readonly Dictionary<string, DependencyNode> _nodes = new();
|
||||
private readonly Dictionary<int, LayerInfo> _layers = new();
|
||||
|
||||
/// <summary>
|
||||
/// All dependency nodes in the graph.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, DependencyNode> Nodes => _nodes;
|
||||
|
||||
/// <summary>
|
||||
/// Layer information indexed by layer index.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<int, LayerInfo> Layers => _layers;
|
||||
|
||||
/// <summary>
|
||||
/// Root nodes (direct dependencies with no parents in this graph).
|
||||
/// </summary>
|
||||
public IEnumerable<DependencyNode> Roots =>
|
||||
_nodes.Values.Where(n => n.ParentIds.Length == 0 || n.IsDirect);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a layer to the graph.
|
||||
/// </summary>
|
||||
public void AddLayer(LayerInfo layer)
|
||||
{
|
||||
_layers[layer.Index] = layer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a dependency node to the graph.
|
||||
/// </summary>
|
||||
public void AddNode(DependencyNode node)
|
||||
{
|
||||
_nodes[node.Id] = node;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all dependencies introduced in a specific layer.
|
||||
/// </summary>
|
||||
public IEnumerable<DependencyNode> GetDependenciesInLayer(int layerIndex)
|
||||
{
|
||||
return _nodes.Values.Where(n => n.LayerIndex == layerIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all dependencies resolved by a specific loader.
|
||||
/// </summary>
|
||||
public IEnumerable<DependencyNode> GetDependenciesByLoader(string loader)
|
||||
{
|
||||
return _nodes.Values.Where(n =>
|
||||
string.Equals(n.Loader, loader, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the transitive closure of dependencies for a node.
|
||||
/// </summary>
|
||||
public IEnumerable<DependencyNode> GetTransitiveDependencies(string nodeId)
|
||||
{
|
||||
var visited = new HashSet<string>();
|
||||
var result = new List<DependencyNode>();
|
||||
CollectTransitive(nodeId, visited, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void CollectTransitive(string nodeId, HashSet<string> visited, List<DependencyNode> result)
|
||||
{
|
||||
if (!visited.Add(nodeId)) return;
|
||||
if (!_nodes.TryGetValue(nodeId, out var node)) return;
|
||||
|
||||
result.Add(node);
|
||||
foreach (var childId in node.ChildIds)
|
||||
{
|
||||
CollectTransitive(childId, visited, result);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the graph digest for integrity verification.
|
||||
/// </summary>
|
||||
public string ComputeGraphDigest()
|
||||
{
|
||||
var sortedNodes = _nodes.Values
|
||||
.OrderBy(n => n.Id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var canonical = StellaOps.Canonical.Json.CanonJson.Canonicalize(sortedNodes);
|
||||
return StellaOps.Canonical.Json.CanonJson.Sha256Prefixed(canonical);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a diff between this graph and another.
|
||||
/// </summary>
|
||||
public GraphDiff ComputeDiff(LayerDependencyGraph other)
|
||||
{
|
||||
var added = other._nodes.Keys.Except(_nodes.Keys).ToImmutableArray();
|
||||
var removed = _nodes.Keys.Except(other._nodes.Keys).ToImmutableArray();
|
||||
|
||||
var modified = new List<string>();
|
||||
foreach (var key in _nodes.Keys.Intersect(other._nodes.Keys))
|
||||
{
|
||||
if (_nodes[key].Digest != other._nodes[key].Digest)
|
||||
{
|
||||
modified.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return new GraphDiff
|
||||
{
|
||||
AddedNodeIds = added,
|
||||
RemovedNodeIds = removed,
|
||||
ModifiedNodeIds = modified.ToImmutableArray(),
|
||||
BaseGraphDigest = ComputeGraphDigest(),
|
||||
HeadGraphDigest = other.ComputeGraphDigest()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a container layer.
|
||||
/// </summary>
|
||||
public sealed record LayerInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Layer index (0-based, from base).
|
||||
/// </summary>
|
||||
[JsonPropertyName("index")]
|
||||
public required int Index { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Layer digest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Layer command (e.g., RUN, COPY).
|
||||
/// </summary>
|
||||
[JsonPropertyName("command")]
|
||||
public string? Command { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Layer size in bytes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("size")]
|
||||
public long? Size { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this layer is from the base image.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isBaseImage")]
|
||||
public bool IsBaseImage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base image reference if this is a base layer.
|
||||
/// </summary>
|
||||
[JsonPropertyName("baseImageRef")]
|
||||
public string? BaseImageRef { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dependency node in the graph.
|
||||
/// </summary>
|
||||
public sealed record DependencyNode
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique node ID (typically the identity hash).
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("purl")]
|
||||
public required string Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content digest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Loader that resolved this dependency.
|
||||
/// </summary>
|
||||
[JsonPropertyName("loader")]
|
||||
public required string Loader { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Layer index where introduced.
|
||||
/// </summary>
|
||||
[JsonPropertyName("layerIndex")]
|
||||
public int? LayerIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a direct dependency.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isDirect")]
|
||||
public bool IsDirect { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Dependency scope.
|
||||
/// </summary>
|
||||
[JsonPropertyName("scope")]
|
||||
public DependencyScope Scope { get; init; } = DependencyScope.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// Parent node IDs.
|
||||
/// </summary>
|
||||
[JsonPropertyName("parentIds")]
|
||||
public ImmutableArray<string> ParentIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Child node IDs.
|
||||
/// </summary>
|
||||
[JsonPropertyName("childIds")]
|
||||
public ImmutableArray<string> ChildIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Build recipe hash if available.
|
||||
/// </summary>
|
||||
[JsonPropertyName("buildRecipeHash")]
|
||||
public string? BuildRecipeHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerabilities associated with this node.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilities")]
|
||||
public ImmutableArray<string> Vulnerabilities { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diff between two dependency graphs.
|
||||
/// </summary>
|
||||
public sealed record GraphDiff
|
||||
{
|
||||
/// <summary>
|
||||
/// Node IDs added in the head graph.
|
||||
/// </summary>
|
||||
[JsonPropertyName("addedNodeIds")]
|
||||
public ImmutableArray<string> AddedNodeIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Node IDs removed from the base graph.
|
||||
/// </summary>
|
||||
[JsonPropertyName("removedNodeIds")]
|
||||
public ImmutableArray<string> RemovedNodeIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Node IDs with modified content.
|
||||
/// </summary>
|
||||
[JsonPropertyName("modifiedNodeIds")]
|
||||
public ImmutableArray<string> ModifiedNodeIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Base graph digest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("baseGraphDigest")]
|
||||
public required string BaseGraphDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Head graph digest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("headGraphDigest")]
|
||||
public required string HeadGraphDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether there are any changes.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool HasChanges =>
|
||||
AddedNodeIds.Length > 0 ||
|
||||
RemovedNodeIds.Length > 0 ||
|
||||
ModifiedNodeIds.Length > 0;
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SbomVersioning.cs
|
||||
// Sprint: SPRINT_3850_0001_0001 (Competitive Gap Closure)
|
||||
// Task: SBOM-L-004 - SBOM versioning and merge semantics API
|
||||
// Description: SBOM version control and merge operations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Versioned SBOM with lineage tracking.
|
||||
/// </summary>
|
||||
public sealed record VersionedSbom
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique SBOM identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version number (monotonically increasing).
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required int Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Parent SBOM ID (for lineage).
|
||||
/// </summary>
|
||||
[JsonPropertyName("parentId")]
|
||||
public string? ParentId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Parent version number.
|
||||
/// </summary>
|
||||
[JsonPropertyName("parentVersion")]
|
||||
public int? ParentVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content digest of the SBOM.
|
||||
/// </summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SBOM format (spdx, cyclonedx).
|
||||
/// </summary>
|
||||
[JsonPropertyName("format")]
|
||||
public required SbomFormat Format { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Format version (e.g., "3.0.1" for SPDX).
|
||||
/// </summary>
|
||||
[JsonPropertyName("formatVersion")]
|
||||
public required string FormatVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creation timestamp.
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tool that generated this SBOM.
|
||||
/// </summary>
|
||||
[JsonPropertyName("generatorTool")]
|
||||
public required string GeneratorTool { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Generator tool version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("generatorVersion")]
|
||||
public required string GeneratorVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject artifact digest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("subjectDigest")]
|
||||
public required string SubjectDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component count.
|
||||
/// </summary>
|
||||
[JsonPropertyName("componentCount")]
|
||||
public int ComponentCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Merge metadata if this SBOM was created by merging others.
|
||||
/// </summary>
|
||||
[JsonPropertyName("mergeMetadata")]
|
||||
public SbomMergeMetadata? MergeMetadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM format types.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum SbomFormat
|
||||
{
|
||||
/// <summary>SPDX format.</summary>
|
||||
Spdx,
|
||||
|
||||
/// <summary>CycloneDX format.</summary>
|
||||
CycloneDx,
|
||||
|
||||
/// <summary>SWID format.</summary>
|
||||
Swid
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata about an SBOM merge operation.
|
||||
/// </summary>
|
||||
public sealed record SbomMergeMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Source SBOM references that were merged.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sources")]
|
||||
public required ImmutableArray<SbomMergeSource> Sources { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Merge strategy used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("strategy")]
|
||||
public required SbomMergeStrategy Strategy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp of the merge.
|
||||
/// </summary>
|
||||
[JsonPropertyName("mergedAt")]
|
||||
public required DateTimeOffset MergedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Conflicts encountered and how they were resolved.
|
||||
/// </summary>
|
||||
[JsonPropertyName("conflicts")]
|
||||
public ImmutableArray<SbomMergeConflict> Conflicts { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to an SBOM that was merged.
|
||||
/// </summary>
|
||||
public sealed record SbomMergeSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Source SBOM ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source SBOM version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required int Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source SBOM digest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public required string Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merge strategy for SBOMs.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum SbomMergeStrategy
|
||||
{
|
||||
/// <summary>Union: include all components from all sources.</summary>
|
||||
Union,
|
||||
|
||||
/// <summary>Intersection: only components present in all sources.</summary>
|
||||
Intersection,
|
||||
|
||||
/// <summary>Latest: prefer components from most recent SBOM.</summary>
|
||||
Latest,
|
||||
|
||||
/// <summary>Priority: use explicit priority ordering.</summary>
|
||||
Priority
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Conflict encountered during SBOM merge.
|
||||
/// </summary>
|
||||
public sealed record SbomMergeConflict
|
||||
{
|
||||
/// <summary>
|
||||
/// Component PURL that had a conflict.
|
||||
/// </summary>
|
||||
[JsonPropertyName("purl")]
|
||||
public required string Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of conflict.
|
||||
/// </summary>
|
||||
[JsonPropertyName("conflictType")]
|
||||
public required SbomConflictType ConflictType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Values from different sources.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sourceValues")]
|
||||
public required ImmutableDictionary<string, string> SourceValues { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Resolved value.
|
||||
/// </summary>
|
||||
[JsonPropertyName("resolvedValue")]
|
||||
public required string ResolvedValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Resolution reason.
|
||||
/// </summary>
|
||||
[JsonPropertyName("resolutionReason")]
|
||||
public string? ResolutionReason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of SBOM merge conflicts.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum SbomConflictType
|
||||
{
|
||||
/// <summary>Different versions of the same package.</summary>
|
||||
VersionMismatch,
|
||||
|
||||
/// <summary>Different digests for same version.</summary>
|
||||
DigestMismatch,
|
||||
|
||||
/// <summary>Different license declarations.</summary>
|
||||
LicenseMismatch,
|
||||
|
||||
/// <summary>Different supplier information.</summary>
|
||||
SupplierMismatch
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for SBOM versioning and merge operations.
|
||||
/// </summary>
|
||||
public interface ISbomVersioningService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new version of an SBOM.
|
||||
/// </summary>
|
||||
Task<VersionedSbom> CreateVersionAsync(
|
||||
string parentId,
|
||||
int parentVersion,
|
||||
ReadOnlyMemory<byte> sbomContent,
|
||||
SbomFormat format,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the version history of an SBOM.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<VersionedSbom>> GetVersionHistoryAsync(
|
||||
string sbomId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Merges multiple SBOMs into one.
|
||||
/// </summary>
|
||||
Task<VersionedSbom> MergeAsync(
|
||||
IReadOnlyList<SbomMergeSource> sources,
|
||||
SbomMergeStrategy strategy,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Computes the diff between two SBOM versions.
|
||||
/// </summary>
|
||||
Task<SbomDiff> ComputeDiffAsync(
|
||||
string sbomId,
|
||||
int baseVersion,
|
||||
int headVersion,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diff between two SBOM versions.
|
||||
/// </summary>
|
||||
public sealed record SbomDiff
|
||||
{
|
||||
/// <summary>
|
||||
/// Base SBOM reference.
|
||||
/// </summary>
|
||||
[JsonPropertyName("base")]
|
||||
public required SbomMergeSource Base { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Head SBOM reference.
|
||||
/// </summary>
|
||||
[JsonPropertyName("head")]
|
||||
public required SbomMergeSource Head { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Components added in head.
|
||||
/// </summary>
|
||||
[JsonPropertyName("added")]
|
||||
public ImmutableArray<string> Added { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Components removed from base.
|
||||
/// </summary>
|
||||
[JsonPropertyName("removed")]
|
||||
public ImmutableArray<string> Removed { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Components with version changes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("versionChanged")]
|
||||
public ImmutableArray<ComponentVersionChange> VersionChanged { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Component version change in a diff.
|
||||
/// </summary>
|
||||
public sealed record ComponentVersionChange
|
||||
{
|
||||
/// <summary>
|
||||
/// Component PURL (without version).
|
||||
/// </summary>
|
||||
[JsonPropertyName("purl")]
|
||||
public required string Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version in base.
|
||||
/// </summary>
|
||||
[JsonPropertyName("baseVersion")]
|
||||
public required string BaseVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version in head.
|
||||
/// </summary>
|
||||
[JsonPropertyName("headVersion")]
|
||||
public required string HeadVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is an upgrade or downgrade.
|
||||
/// </summary>
|
||||
[JsonPropertyName("direction")]
|
||||
public required VersionChangeDirection Direction { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Direction of version change.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum VersionChangeDirection
|
||||
{
|
||||
/// <summary>Version increased.</summary>
|
||||
Upgrade,
|
||||
|
||||
/// <summary>Version decreased.</summary>
|
||||
Downgrade,
|
||||
|
||||
/// <summary>Cannot determine (non-semver).</summary>
|
||||
Unknown
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ZeroDayWindowTracking.cs
|
||||
// Sprint: SPRINT_3850_0001_0001 (Competitive Gap Closure)
|
||||
// Task: UNK-005 - Zero-day window tracking
|
||||
// Description: Track exposure window for zero-day vulnerabilities.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks the zero-day exposure window for a vulnerability.
|
||||
/// The window is the time between exploit availability and patch/mitigation.
|
||||
/// </summary>
|
||||
public sealed record ZeroDayWindow
|
||||
{
|
||||
/// <summary>
|
||||
/// Vulnerability identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the vulnerability was first disclosed publicly.
|
||||
/// </summary>
|
||||
[JsonPropertyName("disclosedAt")]
|
||||
public DateTimeOffset? DisclosedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When an exploit was first seen in the wild.
|
||||
/// </summary>
|
||||
[JsonPropertyName("exploitSeenAt")]
|
||||
public DateTimeOffset? ExploitSeenAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When a patch was first available.
|
||||
/// </summary>
|
||||
[JsonPropertyName("patchAvailableAt")]
|
||||
public DateTimeOffset? PatchAvailableAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When we first detected this in the artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("detectedAt")]
|
||||
public required DateTimeOffset DetectedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the artifact was remediated (patched/mitigated).
|
||||
/// </summary>
|
||||
[JsonPropertyName("remediatedAt")]
|
||||
public DateTimeOffset? RemediatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current window status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public required ZeroDayWindowStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Exposure duration in hours (time we were exposed).
|
||||
/// </summary>
|
||||
[JsonPropertyName("exposureHours")]
|
||||
public double? ExposureHours { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Pre-disclosure exposure (time between exploit seen and disclosure).
|
||||
/// </summary>
|
||||
[JsonPropertyName("preDisclosureHours")]
|
||||
public double? PreDisclosureHours { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Time from disclosure to patch availability.
|
||||
/// </summary>
|
||||
[JsonPropertyName("disclosureToPatchHours")]
|
||||
public double? DisclosureToPatchHours { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Time from patch availability to our remediation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("patchToRemediationHours")]
|
||||
public double? PatchToRemediationHours { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this was a true zero-day (exploit before patch).
|
||||
/// </summary>
|
||||
[JsonPropertyName("isTrueZeroDay")]
|
||||
public bool IsTrueZeroDay { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Risk score based on exposure window (0-100).
|
||||
/// </summary>
|
||||
[JsonPropertyName("windowRiskScore")]
|
||||
public int WindowRiskScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timeline events.
|
||||
/// </summary>
|
||||
[JsonPropertyName("timeline")]
|
||||
public ImmutableArray<WindowTimelineEvent> Timeline { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of the zero-day window.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ZeroDayWindowStatus
|
||||
{
|
||||
/// <summary>Actively exposed with no patch.</summary>
|
||||
ActiveNoPatch,
|
||||
|
||||
/// <summary>Actively exposed, patch available but not applied.</summary>
|
||||
ActivePatchAvailable,
|
||||
|
||||
/// <summary>Actively exposed, mitigated by controls.</summary>
|
||||
ActiveMitigated,
|
||||
|
||||
/// <summary>Remediated - no longer exposed.</summary>
|
||||
Remediated,
|
||||
|
||||
/// <summary>Unknown - insufficient data.</summary>
|
||||
Unknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Timeline event for window tracking.
|
||||
/// </summary>
|
||||
public sealed record WindowTimelineEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Event timestamp.
|
||||
/// </summary>
|
||||
[JsonPropertyName("timestamp")]
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event type.
|
||||
/// </summary>
|
||||
[JsonPropertyName("eventType")]
|
||||
public required WindowEventType EventType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source of the event.
|
||||
/// </summary>
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of window timeline events.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum WindowEventType
|
||||
{
|
||||
/// <summary>Vulnerability disclosed.</summary>
|
||||
Disclosed,
|
||||
|
||||
/// <summary>Exploit seen in the wild.</summary>
|
||||
ExploitSeen,
|
||||
|
||||
/// <summary>Patch released.</summary>
|
||||
PatchReleased,
|
||||
|
||||
/// <summary>Detected in our artifact.</summary>
|
||||
Detected,
|
||||
|
||||
/// <summary>Mitigation applied.</summary>
|
||||
Mitigated,
|
||||
|
||||
/// <summary>Patch applied.</summary>
|
||||
Patched,
|
||||
|
||||
/// <summary>Added to KEV.</summary>
|
||||
AddedToKev,
|
||||
|
||||
/// <summary>CISA deadline set.</summary>
|
||||
CisaDeadline
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate statistics for zero-day windows.
|
||||
/// </summary>
|
||||
public sealed record ZeroDayWindowStats
|
||||
{
|
||||
/// <summary>
|
||||
/// Artifact digest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("artifactDigest")]
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When stats were computed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("computedAt")]
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total zero-day windows tracked.
|
||||
/// </summary>
|
||||
[JsonPropertyName("totalWindows")]
|
||||
public int TotalWindows { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Currently active windows.
|
||||
/// </summary>
|
||||
[JsonPropertyName("activeWindows")]
|
||||
public int ActiveWindows { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// True zero-day count (exploit before patch).
|
||||
/// </summary>
|
||||
[JsonPropertyName("trueZeroDays")]
|
||||
public int TrueZeroDays { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Average exposure hours across all windows.
|
||||
/// </summary>
|
||||
[JsonPropertyName("avgExposureHours")]
|
||||
public double AvgExposureHours { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum exposure hours.
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxExposureHours")]
|
||||
public double MaxExposureHours { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Average time from patch to remediation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("avgPatchToRemediationHours")]
|
||||
public double AvgPatchToRemediationHours { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Windows by status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("byStatus")]
|
||||
public ImmutableDictionary<ZeroDayWindowStatus, int> ByStatus { get; init; } =
|
||||
ImmutableDictionary<ZeroDayWindowStatus, int>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate risk score (0-100).
|
||||
/// </summary>
|
||||
[JsonPropertyName("aggregateRiskScore")]
|
||||
public int AggregateRiskScore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for tracking zero-day windows.
|
||||
/// </summary>
|
||||
public interface IZeroDayWindowTracker
|
||||
{
|
||||
/// <summary>
|
||||
/// Records a detection event.
|
||||
/// </summary>
|
||||
Task<ZeroDayWindow> RecordDetectionAsync(
|
||||
string vulnerabilityId,
|
||||
string artifactDigest,
|
||||
DateTimeOffset detectedAt,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Records a remediation event.
|
||||
/// </summary>
|
||||
Task<ZeroDayWindow> RecordRemediationAsync(
|
||||
string vulnerabilityId,
|
||||
string artifactDigest,
|
||||
DateTimeOffset remediatedAt,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current window for a vulnerability.
|
||||
/// </summary>
|
||||
Task<ZeroDayWindow?> GetWindowAsync(
|
||||
string vulnerabilityId,
|
||||
string artifactDigest,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets aggregate stats for an artifact.
|
||||
/// </summary>
|
||||
Task<ZeroDayWindowStats> GetStatsAsync(
|
||||
string artifactDigest,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculator for zero-day window metrics.
|
||||
/// </summary>
|
||||
public sealed class ZeroDayWindowCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the risk score for a window.
|
||||
/// </summary>
|
||||
public int ComputeRiskScore(ZeroDayWindow window)
|
||||
{
|
||||
var score = 0.0;
|
||||
|
||||
// Base score from exposure hours
|
||||
if (window.ExposureHours.HasValue)
|
||||
{
|
||||
score = window.ExposureHours.Value switch
|
||||
{
|
||||
< 24 => 20,
|
||||
< 72 => 40,
|
||||
< 168 => 60, // 1 week
|
||||
< 720 => 80, // 30 days
|
||||
_ => 100
|
||||
};
|
||||
}
|
||||
else if (window.Status == ZeroDayWindowStatus.ActiveNoPatch)
|
||||
{
|
||||
// Unknown duration but still exposed with no patch
|
||||
score = 90;
|
||||
}
|
||||
else if (window.Status == ZeroDayWindowStatus.ActivePatchAvailable)
|
||||
{
|
||||
// Patch available but not applied
|
||||
var hoursSincePatch = window.PatchAvailableAt.HasValue
|
||||
? (DateTimeOffset.UtcNow - window.PatchAvailableAt.Value).TotalHours
|
||||
: 0;
|
||||
|
||||
score = hoursSincePatch switch
|
||||
{
|
||||
< 24 => 30,
|
||||
< 72 => 50,
|
||||
< 168 => 70,
|
||||
_ => 85
|
||||
};
|
||||
}
|
||||
|
||||
// Boost for true zero-day
|
||||
if (window.IsTrueZeroDay)
|
||||
{
|
||||
score *= 1.2;
|
||||
}
|
||||
|
||||
return Math.Clamp((int)score, 0, 100);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes aggregate stats from a collection of windows.
|
||||
/// </summary>
|
||||
public ZeroDayWindowStats ComputeStats(string artifactDigest, IEnumerable<ZeroDayWindow> windows)
|
||||
{
|
||||
var windowList = windows.ToList();
|
||||
|
||||
if (windowList.Count == 0)
|
||||
{
|
||||
return new ZeroDayWindowStats
|
||||
{
|
||||
ArtifactDigest = artifactDigest,
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
TotalWindows = 0,
|
||||
AggregateRiskScore = 0
|
||||
};
|
||||
}
|
||||
|
||||
var exposureHours = windowList
|
||||
.Where(w => w.ExposureHours.HasValue)
|
||||
.Select(w => w.ExposureHours!.Value)
|
||||
.ToList();
|
||||
|
||||
var patchToRemediation = windowList
|
||||
.Where(w => w.PatchToRemediationHours.HasValue)
|
||||
.Select(w => w.PatchToRemediationHours!.Value)
|
||||
.ToList();
|
||||
|
||||
var byStatus = windowList
|
||||
.GroupBy(w => w.Status)
|
||||
.ToImmutableDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
// Aggregate risk is max of individual risks, with boost for multiple high-risk windows
|
||||
var riskScores = windowList.Select(w => w.WindowRiskScore).OrderDescending().ToList();
|
||||
var aggregateRisk = riskScores.FirstOrDefault();
|
||||
if (riskScores.Count(r => r >= 70) > 1)
|
||||
{
|
||||
aggregateRisk = Math.Min(100, aggregateRisk + 10);
|
||||
}
|
||||
|
||||
return new ZeroDayWindowStats
|
||||
{
|
||||
ArtifactDigest = artifactDigest,
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
TotalWindows = windowList.Count,
|
||||
ActiveWindows = windowList.Count(w =>
|
||||
w.Status == ZeroDayWindowStatus.ActiveNoPatch ||
|
||||
w.Status == ZeroDayWindowStatus.ActivePatchAvailable),
|
||||
TrueZeroDays = windowList.Count(w => w.IsTrueZeroDay),
|
||||
AvgExposureHours = exposureHours.Count > 0 ? exposureHours.Average() : 0,
|
||||
MaxExposureHours = exposureHours.Count > 0 ? exposureHours.Max() : 0,
|
||||
AvgPatchToRemediationHours = patchToRemediation.Count > 0 ? patchToRemediation.Average() : 0,
|
||||
ByStatus = byStatus,
|
||||
AggregateRiskScore = aggregateRisk
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a window with computed metrics.
|
||||
/// </summary>
|
||||
public ZeroDayWindow BuildWindow(
|
||||
string vulnerabilityId,
|
||||
DateTimeOffset detectedAt,
|
||||
DateTimeOffset? disclosedAt = null,
|
||||
DateTimeOffset? exploitSeenAt = null,
|
||||
DateTimeOffset? patchAvailableAt = null,
|
||||
DateTimeOffset? remediatedAt = null)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var timeline = new List<WindowTimelineEvent>();
|
||||
|
||||
if (disclosedAt.HasValue)
|
||||
{
|
||||
timeline.Add(new WindowTimelineEvent
|
||||
{
|
||||
Timestamp = disclosedAt.Value,
|
||||
EventType = WindowEventType.Disclosed,
|
||||
Description = "Vulnerability publicly disclosed"
|
||||
});
|
||||
}
|
||||
|
||||
if (exploitSeenAt.HasValue)
|
||||
{
|
||||
timeline.Add(new WindowTimelineEvent
|
||||
{
|
||||
Timestamp = exploitSeenAt.Value,
|
||||
EventType = WindowEventType.ExploitSeen,
|
||||
Description = "Exploit observed in the wild"
|
||||
});
|
||||
}
|
||||
|
||||
if (patchAvailableAt.HasValue)
|
||||
{
|
||||
timeline.Add(new WindowTimelineEvent
|
||||
{
|
||||
Timestamp = patchAvailableAt.Value,
|
||||
EventType = WindowEventType.PatchReleased,
|
||||
Description = "Patch released by vendor"
|
||||
});
|
||||
}
|
||||
|
||||
timeline.Add(new WindowTimelineEvent
|
||||
{
|
||||
Timestamp = detectedAt,
|
||||
EventType = WindowEventType.Detected,
|
||||
Description = "Detected in artifact"
|
||||
});
|
||||
|
||||
if (remediatedAt.HasValue)
|
||||
{
|
||||
timeline.Add(new WindowTimelineEvent
|
||||
{
|
||||
Timestamp = remediatedAt.Value,
|
||||
EventType = WindowEventType.Patched,
|
||||
Description = "Remediation applied"
|
||||
});
|
||||
}
|
||||
|
||||
// Compute metrics
|
||||
double? exposureHours = null;
|
||||
if (remediatedAt.HasValue)
|
||||
{
|
||||
var exposureStart = exploitSeenAt ?? disclosedAt ?? detectedAt;
|
||||
exposureHours = (remediatedAt.Value - exposureStart).TotalHours;
|
||||
}
|
||||
else
|
||||
{
|
||||
var exposureStart = exploitSeenAt ?? disclosedAt ?? detectedAt;
|
||||
exposureHours = (now - exposureStart).TotalHours;
|
||||
}
|
||||
|
||||
double? preDisclosureHours = null;
|
||||
if (exploitSeenAt.HasValue && disclosedAt.HasValue && exploitSeenAt < disclosedAt)
|
||||
{
|
||||
preDisclosureHours = (disclosedAt.Value - exploitSeenAt.Value).TotalHours;
|
||||
}
|
||||
|
||||
double? disclosureToPatchHours = null;
|
||||
if (disclosedAt.HasValue && patchAvailableAt.HasValue)
|
||||
{
|
||||
disclosureToPatchHours = (patchAvailableAt.Value - disclosedAt.Value).TotalHours;
|
||||
}
|
||||
|
||||
double? patchToRemediationHours = null;
|
||||
if (patchAvailableAt.HasValue && remediatedAt.HasValue)
|
||||
{
|
||||
patchToRemediationHours = (remediatedAt.Value - patchAvailableAt.Value).TotalHours;
|
||||
}
|
||||
|
||||
var isTrueZeroDay = exploitSeenAt.HasValue &&
|
||||
(!patchAvailableAt.HasValue || exploitSeenAt < patchAvailableAt);
|
||||
|
||||
var status = (remediatedAt.HasValue, patchAvailableAt.HasValue) switch
|
||||
{
|
||||
(true, _) => ZeroDayWindowStatus.Remediated,
|
||||
(false, true) => ZeroDayWindowStatus.ActivePatchAvailable,
|
||||
(false, false) => ZeroDayWindowStatus.ActiveNoPatch,
|
||||
};
|
||||
|
||||
var window = new ZeroDayWindow
|
||||
{
|
||||
VulnerabilityId = vulnerabilityId,
|
||||
DisclosedAt = disclosedAt,
|
||||
ExploitSeenAt = exploitSeenAt,
|
||||
PatchAvailableAt = patchAvailableAt,
|
||||
DetectedAt = detectedAt,
|
||||
RemediatedAt = remediatedAt,
|
||||
Status = status,
|
||||
ExposureHours = exposureHours,
|
||||
PreDisclosureHours = preDisclosureHours,
|
||||
DisclosureToPatchHours = disclosureToPatchHours,
|
||||
PatchToRemediationHours = patchToRemediationHours,
|
||||
IsTrueZeroDay = isTrueZeroDay,
|
||||
Timeline = timeline.OrderBy(e => e.Timestamp).ToImmutableArray()
|
||||
};
|
||||
|
||||
return window with { WindowRiskScore = ComputeRiskScore(window) };
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Auth.Security/StellaOps.Auth.Security.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Canonical.Json/StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Scanner.ProofSpine/StellaOps.Scanner.ProofSpine.csproj" />
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// GraphDeltaComputer.cs
|
||||
// Sprint: SPRINT_3700_0006_0001_incremental_cache (CACHE-006)
|
||||
// Description: Implementation of graph delta computation.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Computes deltas between call graph versions for incremental reachability.
|
||||
/// </summary>
|
||||
public sealed class GraphDeltaComputer : IGraphDeltaComputer
|
||||
{
|
||||
private readonly IGraphSnapshotStore? _snapshotStore;
|
||||
private readonly ILogger<GraphDeltaComputer> _logger;
|
||||
|
||||
public GraphDeltaComputer(
|
||||
ILogger<GraphDeltaComputer> logger,
|
||||
IGraphSnapshotStore? snapshotStore = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_snapshotStore = snapshotStore;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<GraphDelta> ComputeDeltaAsync(
|
||||
IGraphSnapshot previousGraph,
|
||||
IGraphSnapshot currentGraph,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(previousGraph);
|
||||
ArgumentNullException.ThrowIfNull(currentGraph);
|
||||
|
||||
// If hashes match, no changes
|
||||
if (previousGraph.Hash == currentGraph.Hash)
|
||||
{
|
||||
_logger.LogDebug("Graph hashes match, no delta");
|
||||
return Task.FromResult(GraphDelta.Empty);
|
||||
}
|
||||
|
||||
// Compute node deltas
|
||||
var addedNodes = currentGraph.NodeKeys.Except(previousGraph.NodeKeys).ToHashSet();
|
||||
var removedNodes = previousGraph.NodeKeys.Except(currentGraph.NodeKeys).ToHashSet();
|
||||
|
||||
// Compute edge deltas
|
||||
var previousEdgeSet = previousGraph.Edges.ToHashSet();
|
||||
var currentEdgeSet = currentGraph.Edges.ToHashSet();
|
||||
|
||||
var addedEdges = currentGraph.Edges.Where(e => !previousEdgeSet.Contains(e)).ToList();
|
||||
var removedEdges = previousGraph.Edges.Where(e => !currentEdgeSet.Contains(e)).ToList();
|
||||
|
||||
// Compute affected method keys
|
||||
var affected = new HashSet<string>();
|
||||
affected.UnionWith(addedNodes);
|
||||
affected.UnionWith(removedNodes);
|
||||
|
||||
foreach (var edge in addedEdges)
|
||||
{
|
||||
affected.Add(edge.CallerKey);
|
||||
affected.Add(edge.CalleeKey);
|
||||
}
|
||||
|
||||
foreach (var edge in removedEdges)
|
||||
{
|
||||
affected.Add(edge.CallerKey);
|
||||
affected.Add(edge.CalleeKey);
|
||||
}
|
||||
|
||||
var delta = new GraphDelta
|
||||
{
|
||||
AddedNodes = addedNodes,
|
||||
RemovedNodes = removedNodes,
|
||||
AddedEdges = addedEdges,
|
||||
RemovedEdges = removedEdges,
|
||||
AffectedMethodKeys = affected,
|
||||
PreviousHash = previousGraph.Hash,
|
||||
CurrentHash = currentGraph.Hash
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Computed graph delta: +{AddedNodes} nodes, -{RemovedNodes} nodes, +{AddedEdges} edges, -{RemovedEdges} edges, {Affected} affected",
|
||||
addedNodes.Count, removedNodes.Count, addedEdges.Count, removedEdges.Count, affected.Count);
|
||||
|
||||
return Task.FromResult(delta);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<GraphDelta> ComputeDeltaFromHashesAsync(
|
||||
string serviceId,
|
||||
string previousHash,
|
||||
string currentHash,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (previousHash == currentHash)
|
||||
{
|
||||
return GraphDelta.Empty;
|
||||
}
|
||||
|
||||
if (_snapshotStore is null)
|
||||
{
|
||||
// Without snapshot store, we must do full recompute
|
||||
_logger.LogWarning(
|
||||
"No snapshot store available, forcing full recompute for {ServiceId}",
|
||||
serviceId);
|
||||
return GraphDelta.FullRecompute(previousHash, currentHash);
|
||||
}
|
||||
|
||||
// Try to load snapshots
|
||||
var previousSnapshot = await _snapshotStore.GetSnapshotAsync(serviceId, previousHash, cancellationToken);
|
||||
var currentSnapshot = await _snapshotStore.GetSnapshotAsync(serviceId, currentHash, cancellationToken);
|
||||
|
||||
if (previousSnapshot is null || currentSnapshot is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Could not load snapshots for delta computation, forcing full recompute");
|
||||
return GraphDelta.FullRecompute(previousHash, currentHash);
|
||||
}
|
||||
|
||||
return await ComputeDeltaAsync(previousSnapshot, currentSnapshot, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store for graph snapshots used in delta computation.
|
||||
/// </summary>
|
||||
public interface IGraphSnapshotStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a graph snapshot by service and hash.
|
||||
/// </summary>
|
||||
Task<IGraphSnapshot?> GetSnapshotAsync(
|
||||
string serviceId,
|
||||
string graphHash,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Stores a graph snapshot.
|
||||
/// </summary>
|
||||
Task StoreSnapshotAsync(
|
||||
string serviceId,
|
||||
IGraphSnapshot snapshot,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IGraphDeltaComputer.cs
|
||||
// Sprint: SPRINT_3700_0006_0001_incremental_cache (CACHE-005)
|
||||
// Description: Interface for computing graph deltas between versions.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Computes the difference between two call graphs.
|
||||
/// Used to identify which (entry, sink) pairs need recomputation.
|
||||
/// </summary>
|
||||
public interface IGraphDeltaComputer
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the delta between two call graphs.
|
||||
/// </summary>
|
||||
/// <param name="previousGraph">Previous graph state.</param>
|
||||
/// <param name="currentGraph">Current graph state.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Delta result with added/removed nodes and edges.</returns>
|
||||
Task<GraphDelta> ComputeDeltaAsync(
|
||||
IGraphSnapshot previousGraph,
|
||||
IGraphSnapshot currentGraph,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Computes delta from graph hashes if snapshots aren't available.
|
||||
/// </summary>
|
||||
/// <param name="serviceId">Service identifier.</param>
|
||||
/// <param name="previousHash">Previous graph hash.</param>
|
||||
/// <param name="currentHash">Current graph hash.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Delta result.</returns>
|
||||
Task<GraphDelta> ComputeDeltaFromHashesAsync(
|
||||
string serviceId,
|
||||
string previousHash,
|
||||
string currentHash,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of a call graph for delta computation.
|
||||
/// </summary>
|
||||
public interface IGraphSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// Graph hash for identity.
|
||||
/// </summary>
|
||||
string Hash { get; }
|
||||
|
||||
/// <summary>
|
||||
/// All node (method) keys in the graph.
|
||||
/// </summary>
|
||||
IReadOnlySet<string> NodeKeys { get; }
|
||||
|
||||
/// <summary>
|
||||
/// All edges in the graph (caller -> callee).
|
||||
/// </summary>
|
||||
IReadOnlyList<GraphEdge> Edges { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Entry point method keys.
|
||||
/// </summary>
|
||||
IReadOnlySet<string> EntryPoints { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An edge in the call graph.
|
||||
/// </summary>
|
||||
public readonly record struct GraphEdge(string CallerKey, string CalleeKey);
|
||||
|
||||
/// <summary>
|
||||
/// Result of computing graph delta.
|
||||
/// </summary>
|
||||
public sealed record GraphDelta
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether there are any changes.
|
||||
/// </summary>
|
||||
public bool HasChanges => AddedNodes.Count > 0 || RemovedNodes.Count > 0 ||
|
||||
AddedEdges.Count > 0 || RemovedEdges.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Nodes added in current graph (ΔV+).
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> AddedNodes { get; init; } = new HashSet<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Nodes removed from previous graph (ΔV-).
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> RemovedNodes { get; init; } = new HashSet<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Edges added in current graph (ΔE+).
|
||||
/// </summary>
|
||||
public IReadOnlyList<GraphEdge> AddedEdges { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Edges removed from previous graph (ΔE-).
|
||||
/// </summary>
|
||||
public IReadOnlyList<GraphEdge> RemovedEdges { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// All affected method keys (union of added, removed, and edge endpoints).
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> AffectedMethodKeys { get; init; } = new HashSet<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Previous graph hash.
|
||||
/// </summary>
|
||||
public string? PreviousHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current graph hash.
|
||||
/// </summary>
|
||||
public string? CurrentHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty delta (no changes).
|
||||
/// </summary>
|
||||
public static GraphDelta Empty => new();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a full recompute delta (graph hash mismatch, must recompute all).
|
||||
/// </summary>
|
||||
public static GraphDelta FullRecompute(string? previousHash, string currentHash) => new()
|
||||
{
|
||||
PreviousHash = previousHash,
|
||||
CurrentHash = currentHash
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IReachabilityCache.cs
|
||||
// Sprint: SPRINT_3700_0006_0001_incremental_cache (CACHE-003)
|
||||
// Description: Interface for reachability result caching.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for caching reachability analysis results.
|
||||
/// Enables incremental recomputation by caching (entry, sink) pairs.
|
||||
/// </summary>
|
||||
public interface IReachabilityCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets cached reachability results for a service.
|
||||
/// </summary>
|
||||
/// <param name="serviceId">Service identifier.</param>
|
||||
/// <param name="graphHash">Hash of the current call graph.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Cached result if valid, null otherwise.</returns>
|
||||
Task<CachedReachabilityResult?> GetAsync(
|
||||
string serviceId,
|
||||
string graphHash,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Stores reachability results in cache.
|
||||
/// </summary>
|
||||
/// <param name="entry">Cache entry to store.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task SetAsync(
|
||||
ReachabilityCacheEntry entry,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets reachable set for a specific (entry, sink) pair.
|
||||
/// </summary>
|
||||
/// <param name="serviceId">Service identifier.</param>
|
||||
/// <param name="entryMethodKey">Entry point method key.</param>
|
||||
/// <param name="sinkMethodKey">Sink method key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Cached reachable result if available.</returns>
|
||||
Task<ReachablePairResult?> GetReachablePairAsync(
|
||||
string serviceId,
|
||||
string entryMethodKey,
|
||||
string sinkMethodKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates cache entries affected by graph changes.
|
||||
/// </summary>
|
||||
/// <param name="serviceId">Service identifier.</param>
|
||||
/// <param name="affectedMethodKeys">Method keys that changed.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Number of invalidated entries.</returns>
|
||||
Task<int> InvalidateAsync(
|
||||
string serviceId,
|
||||
IEnumerable<string> affectedMethodKeys,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates all cache entries for a service.
|
||||
/// </summary>
|
||||
/// <param name="serviceId">Service identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task InvalidateAllAsync(
|
||||
string serviceId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets cache statistics for a service.
|
||||
/// </summary>
|
||||
/// <param name="serviceId">Service identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Cache statistics.</returns>
|
||||
Task<CacheStatistics> GetStatisticsAsync(
|
||||
string serviceId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cached reachability analysis result.
|
||||
/// </summary>
|
||||
public sealed record CachedReachabilityResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Service identifier.
|
||||
/// </summary>
|
||||
public required string ServiceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Graph hash when results were computed.
|
||||
/// </summary>
|
||||
public required string GraphHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the cache was populated.
|
||||
/// </summary>
|
||||
public DateTimeOffset CachedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Time-to-live remaining.
|
||||
/// </summary>
|
||||
public TimeSpan? TimeToLive { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Cached reachable pairs.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ReachablePairResult> ReachablePairs { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Total entry points analyzed.
|
||||
/// </summary>
|
||||
public int EntryPointCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total sinks analyzed.
|
||||
/// </summary>
|
||||
public int SinkCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result for a single (entry, sink) reachability pair.
|
||||
/// </summary>
|
||||
public sealed record ReachablePairResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Entry point method key.
|
||||
/// </summary>
|
||||
public required string EntryMethodKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sink method key.
|
||||
/// </summary>
|
||||
public required string SinkMethodKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the sink is reachable from the entry.
|
||||
/// </summary>
|
||||
public bool IsReachable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Shortest path length if reachable.
|
||||
/// </summary>
|
||||
public int? PathLength { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score.
|
||||
/// </summary>
|
||||
public double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this pair was last computed.
|
||||
/// </summary>
|
||||
public DateTimeOffset ComputedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry for storing in the reachability cache.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityCacheEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Service identifier.
|
||||
/// </summary>
|
||||
public required string ServiceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Graph hash for cache key.
|
||||
/// </summary>
|
||||
public required string GraphHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SBOM hash for versioning.
|
||||
/// </summary>
|
||||
public string? SbomHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachable pairs to cache.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<ReachablePairResult> ReachablePairs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entry points analyzed.
|
||||
/// </summary>
|
||||
public int EntryPointCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sinks analyzed.
|
||||
/// </summary>
|
||||
public int SinkCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Time-to-live for this cache entry.
|
||||
/// </summary>
|
||||
public TimeSpan? TimeToLive { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cache statistics for monitoring.
|
||||
/// </summary>
|
||||
public sealed record CacheStatistics
|
||||
{
|
||||
/// <summary>
|
||||
/// Service identifier.
|
||||
/// </summary>
|
||||
public required string ServiceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of cached pairs.
|
||||
/// </summary>
|
||||
public int CachedPairCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total cache hits.
|
||||
/// </summary>
|
||||
public long HitCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total cache misses.
|
||||
/// </summary>
|
||||
public long MissCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Cache hit ratio.
|
||||
/// </summary>
|
||||
public double HitRatio => HitCount + MissCount > 0
|
||||
? (double)HitCount / (HitCount + MissCount)
|
||||
: 0.0;
|
||||
|
||||
/// <summary>
|
||||
/// Last cache population time.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LastPopulatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Last invalidation time.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LastInvalidatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current graph hash.
|
||||
/// </summary>
|
||||
public string? CurrentGraphHash { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ImpactSetCalculator.cs
|
||||
// Sprint: SPRINT_3700_0006_0001_incremental_cache (CACHE-007)
|
||||
// Description: Calculates which entry/sink pairs are affected by graph changes.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the impact set: which (entry, sink) pairs need recomputation
|
||||
/// based on graph delta.
|
||||
/// </summary>
|
||||
public interface IImpactSetCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates which entry/sink pairs are affected by graph changes.
|
||||
/// </summary>
|
||||
/// <param name="delta">Graph delta.</param>
|
||||
/// <param name="graph">Current graph snapshot.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Impact set with affected pairs.</returns>
|
||||
Task<ImpactSet> CalculateImpactAsync(
|
||||
GraphDelta delta,
|
||||
IGraphSnapshot graph,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set of (entry, sink) pairs affected by graph changes.
|
||||
/// </summary>
|
||||
public sealed record ImpactSet
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether full recomputation is required.
|
||||
/// </summary>
|
||||
public bool RequiresFullRecompute { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entry points that need reanalysis.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> AffectedEntryPoints { get; init; } = new HashSet<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Sinks that may have changed reachability.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> AffectedSinks { get; init; } = new HashSet<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Specific (entry, sink) pairs that need recomputation.
|
||||
/// </summary>
|
||||
public IReadOnlyList<(string EntryKey, string SinkKey)> AffectedPairs { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Estimated savings ratio compared to full recompute.
|
||||
/// </summary>
|
||||
public double SavingsRatio { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates an impact set requiring full recomputation.
|
||||
/// </summary>
|
||||
public static ImpactSet FullRecompute() => new() { RequiresFullRecompute = true };
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty impact set (no changes needed).
|
||||
/// </summary>
|
||||
public static ImpactSet Empty() => new() { SavingsRatio = 1.0 };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of impact set calculator.
|
||||
/// Uses BFS to find ancestors of changed nodes to determine affected entries.
|
||||
/// </summary>
|
||||
public sealed class ImpactSetCalculator : IImpactSetCalculator
|
||||
{
|
||||
private readonly ILogger<ImpactSetCalculator> _logger;
|
||||
private readonly int _maxAffectedRatioForIncremental;
|
||||
|
||||
public ImpactSetCalculator(
|
||||
ILogger<ImpactSetCalculator> logger,
|
||||
int maxAffectedRatioForIncremental = 30)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_maxAffectedRatioForIncremental = maxAffectedRatioForIncremental;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImpactSet> CalculateImpactAsync(
|
||||
GraphDelta delta,
|
||||
IGraphSnapshot graph,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(delta);
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
|
||||
if (!delta.HasChanges)
|
||||
{
|
||||
_logger.LogDebug("No graph changes, empty impact set");
|
||||
return Task.FromResult(ImpactSet.Empty());
|
||||
}
|
||||
|
||||
// Build reverse adjacency for ancestor lookup
|
||||
var reverseAdj = BuildReverseAdjacency(graph.Edges);
|
||||
|
||||
// Find all ancestors of affected method keys
|
||||
var affectedAncestors = new HashSet<string>();
|
||||
foreach (var methodKey in delta.AffectedMethodKeys)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var ancestors = FindAncestors(methodKey, reverseAdj);
|
||||
affectedAncestors.UnionWith(ancestors);
|
||||
}
|
||||
|
||||
// Intersect with entry points to find affected entries
|
||||
var affectedEntries = graph.EntryPoints
|
||||
.Where(e => affectedAncestors.Contains(e) || delta.AffectedMethodKeys.Contains(e))
|
||||
.ToHashSet();
|
||||
|
||||
// Check if too many entries affected (fall back to full recompute)
|
||||
var affectedRatio = graph.EntryPoints.Count > 0
|
||||
? (double)affectedEntries.Count / graph.EntryPoints.Count * 100
|
||||
: 0;
|
||||
|
||||
if (affectedRatio > _maxAffectedRatioForIncremental)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Affected ratio {Ratio:F1}% exceeds threshold {Threshold}%, forcing full recompute",
|
||||
affectedRatio, _maxAffectedRatioForIncremental);
|
||||
return Task.FromResult(ImpactSet.FullRecompute());
|
||||
}
|
||||
|
||||
// Determine affected sinks (any sink reachable from affected methods)
|
||||
var affectedSinks = delta.AffectedMethodKeys.ToHashSet();
|
||||
|
||||
var savingsRatio = graph.EntryPoints.Count > 0
|
||||
? 1.0 - ((double)affectedEntries.Count / graph.EntryPoints.Count)
|
||||
: 1.0;
|
||||
|
||||
var impact = new ImpactSet
|
||||
{
|
||||
RequiresFullRecompute = false,
|
||||
AffectedEntryPoints = affectedEntries,
|
||||
AffectedSinks = affectedSinks,
|
||||
SavingsRatio = savingsRatio
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Impact set calculated: {AffectedEntries} entries, {AffectedSinks} potential sinks, {Savings:P1} savings",
|
||||
affectedEntries.Count, affectedSinks.Count, savingsRatio);
|
||||
|
||||
return Task.FromResult(impact);
|
||||
}
|
||||
|
||||
private static Dictionary<string, List<string>> BuildReverseAdjacency(IReadOnlyList<GraphEdge> edges)
|
||||
{
|
||||
var reverseAdj = new Dictionary<string, List<string>>();
|
||||
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
if (!reverseAdj.TryGetValue(edge.CalleeKey, out var callers))
|
||||
{
|
||||
callers = new List<string>();
|
||||
reverseAdj[edge.CalleeKey] = callers;
|
||||
}
|
||||
callers.Add(edge.CallerKey);
|
||||
}
|
||||
|
||||
return reverseAdj;
|
||||
}
|
||||
|
||||
private static HashSet<string> FindAncestors(string startNode, Dictionary<string, List<string>> reverseAdj)
|
||||
{
|
||||
var ancestors = new HashSet<string>();
|
||||
var queue = new Queue<string>();
|
||||
queue.Enqueue(startNode);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
|
||||
if (!reverseAdj.TryGetValue(current, out var callers))
|
||||
continue;
|
||||
|
||||
foreach (var caller in callers)
|
||||
{
|
||||
if (ancestors.Add(caller))
|
||||
{
|
||||
queue.Enqueue(caller);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ancestors;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IncrementalReachabilityService.cs
|
||||
// Sprint: SPRINT_3700_0006_0001_incremental_cache (CACHE-012)
|
||||
// Description: Orchestrates incremental reachability analysis with caching.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Service for performing incremental reachability analysis with caching.
|
||||
/// Orchestrates cache lookup, delta computation, selective recompute, and state flip detection.
|
||||
/// </summary>
|
||||
public interface IIncrementalReachabilityService
|
||||
{
|
||||
/// <summary>
|
||||
/// Performs incremental reachability analysis.
|
||||
/// </summary>
|
||||
/// <param name="request">Analysis request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Incremental analysis result.</returns>
|
||||
Task<IncrementalReachabilityResult> AnalyzeAsync(
|
||||
IncrementalReachabilityRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for incremental reachability analysis.
|
||||
/// </summary>
|
||||
public sealed record IncrementalReachabilityRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Service identifier.
|
||||
/// </summary>
|
||||
public required string ServiceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current call graph snapshot.
|
||||
/// </summary>
|
||||
public required IGraphSnapshot CurrentGraph { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sink method keys to analyze.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> Sinks { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to detect state flips.
|
||||
/// </summary>
|
||||
public bool DetectStateFlips { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to update cache with new results.
|
||||
/// </summary>
|
||||
public bool UpdateCache { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum BFS depth for reachability analysis.
|
||||
/// </summary>
|
||||
public int MaxDepth { get; init; } = 50;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of incremental reachability analysis.
|
||||
/// </summary>
|
||||
public sealed record IncrementalReachabilityResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Service identifier.
|
||||
/// </summary>
|
||||
public required string ServiceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability results for each (entry, sink) pair.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ReachablePairResult> Results { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// State flip detection result.
|
||||
/// </summary>
|
||||
public StateFlipResult? StateFlips { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether results came from cache.
|
||||
/// </summary>
|
||||
public bool FromCache { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether incremental analysis was used.
|
||||
/// </summary>
|
||||
public bool WasIncremental { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Savings ratio from incremental analysis (0.0 = full recompute, 1.0 = all cached).
|
||||
/// </summary>
|
||||
public double SavingsRatio { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Analysis duration.
|
||||
/// </summary>
|
||||
public TimeSpan Duration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Graph hash used for caching.
|
||||
/// </summary>
|
||||
public string? GraphHash { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of incremental reachability service.
|
||||
/// </summary>
|
||||
public sealed class IncrementalReachabilityService : IIncrementalReachabilityService
|
||||
{
|
||||
private readonly IReachabilityCache _cache;
|
||||
private readonly IGraphDeltaComputer _deltaComputer;
|
||||
private readonly IImpactSetCalculator _impactCalculator;
|
||||
private readonly IStateFlipDetector _stateFlipDetector;
|
||||
private readonly ILogger<IncrementalReachabilityService> _logger;
|
||||
|
||||
public IncrementalReachabilityService(
|
||||
IReachabilityCache cache,
|
||||
IGraphDeltaComputer deltaComputer,
|
||||
IImpactSetCalculator impactCalculator,
|
||||
IStateFlipDetector stateFlipDetector,
|
||||
ILogger<IncrementalReachabilityService> logger)
|
||||
{
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
_deltaComputer = deltaComputer ?? throw new ArgumentNullException(nameof(deltaComputer));
|
||||
_impactCalculator = impactCalculator ?? throw new ArgumentNullException(nameof(impactCalculator));
|
||||
_stateFlipDetector = stateFlipDetector ?? throw new ArgumentNullException(nameof(stateFlipDetector));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IncrementalReachabilityResult> AnalyzeAsync(
|
||||
IncrementalReachabilityRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var graphHash = request.CurrentGraph.Hash;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting incremental reachability analysis for {ServiceId}, graph {Hash}",
|
||||
request.ServiceId, graphHash);
|
||||
|
||||
// Step 1: Check cache for exact match
|
||||
var cached = await _cache.GetAsync(request.ServiceId, graphHash, cancellationToken);
|
||||
|
||||
if (cached is not null)
|
||||
{
|
||||
IncrementalReachabilityMetrics.CacheHits.Add(1);
|
||||
_logger.LogInformation("Cache hit for {ServiceId}, returning cached results", request.ServiceId);
|
||||
|
||||
sw.Stop();
|
||||
return new IncrementalReachabilityResult
|
||||
{
|
||||
ServiceId = request.ServiceId,
|
||||
Results = cached.ReachablePairs,
|
||||
FromCache = true,
|
||||
WasIncremental = false,
|
||||
SavingsRatio = 1.0,
|
||||
Duration = sw.Elapsed,
|
||||
GraphHash = graphHash
|
||||
};
|
||||
}
|
||||
|
||||
IncrementalReachabilityMetrics.CacheMisses.Add(1);
|
||||
|
||||
// Step 2: Get previous cache to compute delta
|
||||
var stats = await _cache.GetStatisticsAsync(request.ServiceId, cancellationToken);
|
||||
var previousHash = stats.CurrentGraphHash;
|
||||
|
||||
GraphDelta delta;
|
||||
ImpactSet impact;
|
||||
IReadOnlyList<ReachablePairResult> previousResults = [];
|
||||
|
||||
if (previousHash is not null && previousHash != graphHash)
|
||||
{
|
||||
// Compute delta
|
||||
delta = await _deltaComputer.ComputeDeltaFromHashesAsync(
|
||||
request.ServiceId, previousHash, graphHash, cancellationToken);
|
||||
|
||||
impact = await _impactCalculator.CalculateImpactAsync(
|
||||
delta, request.CurrentGraph, cancellationToken);
|
||||
|
||||
// Get previous results for state flip detection
|
||||
var previousCached = await _cache.GetAsync(request.ServiceId, previousHash, cancellationToken);
|
||||
previousResults = previousCached?.ReachablePairs ?? [];
|
||||
}
|
||||
else
|
||||
{
|
||||
// No previous cache, full compute
|
||||
delta = GraphDelta.FullRecompute(previousHash, graphHash);
|
||||
impact = ImpactSet.FullRecompute();
|
||||
}
|
||||
|
||||
// Step 3: Compute reachability (full or incremental)
|
||||
IReadOnlyList<ReachablePairResult> results;
|
||||
|
||||
if (impact.RequiresFullRecompute)
|
||||
{
|
||||
IncrementalReachabilityMetrics.FullRecomputes.Add(1);
|
||||
results = ComputeFullReachability(request);
|
||||
}
|
||||
else
|
||||
{
|
||||
IncrementalReachabilityMetrics.IncrementalComputes.Add(1);
|
||||
results = await ComputeIncrementalReachabilityAsync(
|
||||
request, impact, previousResults, cancellationToken);
|
||||
}
|
||||
|
||||
// Step 4: Detect state flips
|
||||
StateFlipResult? stateFlips = null;
|
||||
if (request.DetectStateFlips && previousResults.Count > 0)
|
||||
{
|
||||
stateFlips = await _stateFlipDetector.DetectFlipsAsync(
|
||||
previousResults, results, cancellationToken);
|
||||
}
|
||||
|
||||
// Step 5: Update cache
|
||||
if (request.UpdateCache)
|
||||
{
|
||||
var entry = new ReachabilityCacheEntry
|
||||
{
|
||||
ServiceId = request.ServiceId,
|
||||
GraphHash = graphHash,
|
||||
ReachablePairs = results,
|
||||
EntryPointCount = request.CurrentGraph.EntryPoints.Count,
|
||||
SinkCount = request.Sinks.Count,
|
||||
TimeToLive = TimeSpan.FromHours(24)
|
||||
};
|
||||
|
||||
await _cache.SetAsync(entry, cancellationToken);
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
IncrementalReachabilityMetrics.AnalysisDurationMs.Record(sw.ElapsedMilliseconds);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Incremental analysis complete for {ServiceId}: {ResultCount} pairs, {Savings:P1} savings, {Duration}ms",
|
||||
request.ServiceId, results.Count, impact.SavingsRatio, sw.ElapsedMilliseconds);
|
||||
|
||||
return new IncrementalReachabilityResult
|
||||
{
|
||||
ServiceId = request.ServiceId,
|
||||
Results = results,
|
||||
StateFlips = stateFlips,
|
||||
FromCache = false,
|
||||
WasIncremental = !impact.RequiresFullRecompute,
|
||||
SavingsRatio = impact.SavingsRatio,
|
||||
Duration = sw.Elapsed,
|
||||
GraphHash = graphHash
|
||||
};
|
||||
}
|
||||
|
||||
private List<ReachablePairResult> ComputeFullReachability(IncrementalReachabilityRequest request)
|
||||
{
|
||||
var results = new List<ReachablePairResult>();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Build forward adjacency for BFS
|
||||
var adj = new Dictionary<string, List<string>>();
|
||||
foreach (var edge in request.CurrentGraph.Edges)
|
||||
{
|
||||
if (!adj.TryGetValue(edge.CallerKey, out var callees))
|
||||
{
|
||||
callees = new List<string>();
|
||||
adj[edge.CallerKey] = callees;
|
||||
}
|
||||
callees.Add(edge.CalleeKey);
|
||||
}
|
||||
|
||||
var sinkSet = request.Sinks.ToHashSet();
|
||||
|
||||
foreach (var entry in request.CurrentGraph.EntryPoints)
|
||||
{
|
||||
// BFS from entry to find reachable sinks
|
||||
var reachableSinks = BfsToSinks(entry, sinkSet, adj, request.MaxDepth);
|
||||
|
||||
foreach (var (sink, pathLength) in reachableSinks)
|
||||
{
|
||||
results.Add(new ReachablePairResult
|
||||
{
|
||||
EntryMethodKey = entry,
|
||||
SinkMethodKey = sink,
|
||||
IsReachable = true,
|
||||
PathLength = pathLength,
|
||||
Confidence = 1.0,
|
||||
ComputedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// Add unreachable pairs for sinks not reached
|
||||
foreach (var sink in sinkSet.Except(reachableSinks.Keys))
|
||||
{
|
||||
results.Add(new ReachablePairResult
|
||||
{
|
||||
EntryMethodKey = entry,
|
||||
SinkMethodKey = sink,
|
||||
IsReachable = false,
|
||||
Confidence = 1.0,
|
||||
ComputedAt = now
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ReachablePairResult>> ComputeIncrementalReachabilityAsync(
|
||||
IncrementalReachabilityRequest request,
|
||||
ImpactSet impact,
|
||||
IReadOnlyList<ReachablePairResult> previousResults,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new Dictionary<(string, string), ReachablePairResult>();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Copy unaffected results from previous
|
||||
foreach (var prev in previousResults)
|
||||
{
|
||||
var key = (prev.EntryMethodKey, prev.SinkMethodKey);
|
||||
|
||||
if (!impact.AffectedEntryPoints.Contains(prev.EntryMethodKey))
|
||||
{
|
||||
// Entry not affected, keep previous result
|
||||
results[key] = prev;
|
||||
}
|
||||
}
|
||||
|
||||
// Recompute only affected entries
|
||||
var adj = new Dictionary<string, List<string>>();
|
||||
foreach (var edge in request.CurrentGraph.Edges)
|
||||
{
|
||||
if (!adj.TryGetValue(edge.CallerKey, out var callees))
|
||||
{
|
||||
callees = new List<string>();
|
||||
adj[edge.CallerKey] = callees;
|
||||
}
|
||||
callees.Add(edge.CalleeKey);
|
||||
}
|
||||
|
||||
var sinkSet = request.Sinks.ToHashSet();
|
||||
|
||||
foreach (var entry in impact.AffectedEntryPoints)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!request.CurrentGraph.EntryPoints.Contains(entry))
|
||||
continue; // Entry no longer exists
|
||||
|
||||
var reachableSinks = BfsToSinks(entry, sinkSet, adj, request.MaxDepth);
|
||||
|
||||
foreach (var (sink, pathLength) in reachableSinks)
|
||||
{
|
||||
var key = (entry, sink);
|
||||
results[key] = new ReachablePairResult
|
||||
{
|
||||
EntryMethodKey = entry,
|
||||
SinkMethodKey = sink,
|
||||
IsReachable = true,
|
||||
PathLength = pathLength,
|
||||
Confidence = 1.0,
|
||||
ComputedAt = now
|
||||
};
|
||||
}
|
||||
|
||||
foreach (var sink in sinkSet.Except(reachableSinks.Keys))
|
||||
{
|
||||
var key = (entry, sink);
|
||||
results[key] = new ReachablePairResult
|
||||
{
|
||||
EntryMethodKey = entry,
|
||||
SinkMethodKey = sink,
|
||||
IsReachable = false,
|
||||
Confidence = 1.0,
|
||||
ComputedAt = now
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return results.Values.ToList();
|
||||
}
|
||||
|
||||
private static Dictionary<string, int> BfsToSinks(
|
||||
string startNode,
|
||||
HashSet<string> sinks,
|
||||
Dictionary<string, List<string>> adj,
|
||||
int maxDepth)
|
||||
{
|
||||
var reached = new Dictionary<string, int>();
|
||||
var visited = new HashSet<string>();
|
||||
var queue = new Queue<(string Node, int Depth)>();
|
||||
|
||||
queue.Enqueue((startNode, 0));
|
||||
visited.Add(startNode);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (current, depth) = queue.Dequeue();
|
||||
|
||||
if (depth > maxDepth)
|
||||
break;
|
||||
|
||||
if (sinks.Contains(current))
|
||||
{
|
||||
reached[current] = depth;
|
||||
}
|
||||
|
||||
if (!adj.TryGetValue(current, out var callees))
|
||||
continue;
|
||||
|
||||
foreach (var callee in callees)
|
||||
{
|
||||
if (visited.Add(callee))
|
||||
{
|
||||
queue.Enqueue((callee, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reached;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metrics for incremental reachability service.
|
||||
/// </summary>
|
||||
internal static class IncrementalReachabilityMetrics
|
||||
{
|
||||
private static readonly string MeterName = "StellaOps.Scanner.Reachability.Cache";
|
||||
|
||||
public static readonly System.Diagnostics.Metrics.Counter<long> CacheHits =
|
||||
new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter<long>(
|
||||
"stellaops.reachability_cache.hits",
|
||||
description: "Number of cache hits");
|
||||
|
||||
public static readonly System.Diagnostics.Metrics.Counter<long> CacheMisses =
|
||||
new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter<long>(
|
||||
"stellaops.reachability_cache.misses",
|
||||
description: "Number of cache misses");
|
||||
|
||||
public static readonly System.Diagnostics.Metrics.Counter<long> FullRecomputes =
|
||||
new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter<long>(
|
||||
"stellaops.reachability_cache.full_recomputes",
|
||||
description: "Number of full recomputes");
|
||||
|
||||
public static readonly System.Diagnostics.Metrics.Counter<long> IncrementalComputes =
|
||||
new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter<long>(
|
||||
"stellaops.reachability_cache.incremental_computes",
|
||||
description: "Number of incremental computes");
|
||||
|
||||
public static readonly System.Diagnostics.Metrics.Histogram<long> AnalysisDurationMs =
|
||||
new System.Diagnostics.Metrics.Meter(MeterName).CreateHistogram<long>(
|
||||
"stellaops.reachability_cache.analysis_duration_ms",
|
||||
unit: "ms",
|
||||
description: "Analysis duration in milliseconds");
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PostgresReachabilityCache.cs
|
||||
// Sprint: SPRINT_3700_0006_0001_incremental_cache (CACHE-004)
|
||||
// Description: PostgreSQL implementation of IReachabilityCache.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of the reachability cache.
|
||||
/// </summary>
|
||||
public sealed class PostgresReachabilityCache : IReachabilityCache
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly ILogger<PostgresReachabilityCache> _logger;
|
||||
|
||||
public PostgresReachabilityCache(
|
||||
string connectionString,
|
||||
ILogger<PostgresReachabilityCache> logger)
|
||||
{
|
||||
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CachedReachabilityResult?> GetAsync(
|
||||
string serviceId,
|
||||
string graphHash,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(cancellationToken);
|
||||
|
||||
// Get cache entry
|
||||
const string entrySql = """
|
||||
SELECT id, cached_at, expires_at, entry_point_count, sink_count
|
||||
FROM reach_cache_entries
|
||||
WHERE service_id = @serviceId AND graph_hash = @graphHash
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
""";
|
||||
|
||||
await using var entryCmd = new NpgsqlCommand(entrySql, conn);
|
||||
entryCmd.Parameters.AddWithValue("@serviceId", serviceId);
|
||||
entryCmd.Parameters.AddWithValue("@graphHash", graphHash);
|
||||
|
||||
await using var entryReader = await entryCmd.ExecuteReaderAsync(cancellationToken);
|
||||
|
||||
if (!await entryReader.ReadAsync(cancellationToken))
|
||||
{
|
||||
return null; // Cache miss
|
||||
}
|
||||
|
||||
var entryId = entryReader.GetGuid(0);
|
||||
var cachedAt = entryReader.GetDateTime(1);
|
||||
var expiresAt = entryReader.IsDBNull(2) ? (DateTimeOffset?)null : entryReader.GetDateTime(2);
|
||||
var entryPointCount = entryReader.GetInt32(3);
|
||||
var sinkCount = entryReader.GetInt32(4);
|
||||
|
||||
await entryReader.CloseAsync();
|
||||
|
||||
// Get cached pairs
|
||||
const string pairsSql = """
|
||||
SELECT entry_method_key, sink_method_key, is_reachable, path_length, confidence, computed_at
|
||||
FROM reach_cache_pairs
|
||||
WHERE cache_entry_id = @entryId
|
||||
""";
|
||||
|
||||
await using var pairsCmd = new NpgsqlCommand(pairsSql, conn);
|
||||
pairsCmd.Parameters.AddWithValue("@entryId", entryId);
|
||||
|
||||
var pairs = new List<ReachablePairResult>();
|
||||
await using var pairsReader = await pairsCmd.ExecuteReaderAsync(cancellationToken);
|
||||
|
||||
while (await pairsReader.ReadAsync(cancellationToken))
|
||||
{
|
||||
pairs.Add(new ReachablePairResult
|
||||
{
|
||||
EntryMethodKey = pairsReader.GetString(0),
|
||||
SinkMethodKey = pairsReader.GetString(1),
|
||||
IsReachable = pairsReader.GetBoolean(2),
|
||||
PathLength = pairsReader.IsDBNull(3) ? null : pairsReader.GetInt32(3),
|
||||
Confidence = pairsReader.GetDouble(4),
|
||||
ComputedAt = pairsReader.GetDateTime(5)
|
||||
});
|
||||
}
|
||||
|
||||
// Update stats
|
||||
await UpdateStatsAsync(conn, serviceId, isHit: true, cancellationToken: cancellationToken);
|
||||
|
||||
_logger.LogDebug("Cache hit for {ServiceId}, {PairCount} pairs", serviceId, pairs.Count);
|
||||
|
||||
return new CachedReachabilityResult
|
||||
{
|
||||
ServiceId = serviceId,
|
||||
GraphHash = graphHash,
|
||||
CachedAt = cachedAt,
|
||||
TimeToLive = expiresAt.HasValue ? expiresAt.Value - DateTimeOffset.UtcNow : null,
|
||||
ReachablePairs = pairs,
|
||||
EntryPointCount = entryPointCount,
|
||||
SinkCount = sinkCount
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SetAsync(
|
||||
ReachabilityCacheEntry entry,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(cancellationToken);
|
||||
await using var tx = await conn.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
// Delete existing entry for this service/hash
|
||||
const string deleteSql = """
|
||||
DELETE FROM reach_cache_entries
|
||||
WHERE service_id = @serviceId AND graph_hash = @graphHash
|
||||
""";
|
||||
|
||||
await using var deleteCmd = new NpgsqlCommand(deleteSql, conn, tx);
|
||||
deleteCmd.Parameters.AddWithValue("@serviceId", entry.ServiceId);
|
||||
deleteCmd.Parameters.AddWithValue("@graphHash", entry.GraphHash);
|
||||
await deleteCmd.ExecuteNonQueryAsync(cancellationToken);
|
||||
|
||||
// Insert new cache entry
|
||||
var reachableCount = 0;
|
||||
var unreachableCount = 0;
|
||||
foreach (var pair in entry.ReachablePairs)
|
||||
{
|
||||
if (pair.IsReachable) reachableCount++;
|
||||
else unreachableCount++;
|
||||
}
|
||||
|
||||
var expiresAt = entry.TimeToLive.HasValue
|
||||
? (object)DateTimeOffset.UtcNow.Add(entry.TimeToLive.Value)
|
||||
: DBNull.Value;
|
||||
|
||||
const string insertEntrySql = """
|
||||
INSERT INTO reach_cache_entries
|
||||
(service_id, graph_hash, sbom_hash, entry_point_count, sink_count,
|
||||
pair_count, reachable_count, unreachable_count, expires_at)
|
||||
VALUES
|
||||
(@serviceId, @graphHash, @sbomHash, @entryPointCount, @sinkCount,
|
||||
@pairCount, @reachableCount, @unreachableCount, @expiresAt)
|
||||
RETURNING id
|
||||
""";
|
||||
|
||||
await using var insertCmd = new NpgsqlCommand(insertEntrySql, conn, tx);
|
||||
insertCmd.Parameters.AddWithValue("@serviceId", entry.ServiceId);
|
||||
insertCmd.Parameters.AddWithValue("@graphHash", entry.GraphHash);
|
||||
insertCmd.Parameters.AddWithValue("@sbomHash", entry.SbomHash ?? (object)DBNull.Value);
|
||||
insertCmd.Parameters.AddWithValue("@entryPointCount", entry.EntryPointCount);
|
||||
insertCmd.Parameters.AddWithValue("@sinkCount", entry.SinkCount);
|
||||
insertCmd.Parameters.AddWithValue("@pairCount", entry.ReachablePairs.Count);
|
||||
insertCmd.Parameters.AddWithValue("@reachableCount", reachableCount);
|
||||
insertCmd.Parameters.AddWithValue("@unreachableCount", unreachableCount);
|
||||
insertCmd.Parameters.AddWithValue("@expiresAt", expiresAt);
|
||||
|
||||
var entryId = (Guid)(await insertCmd.ExecuteScalarAsync(cancellationToken))!;
|
||||
|
||||
// Insert pairs in batches
|
||||
if (entry.ReachablePairs.Count > 0)
|
||||
{
|
||||
await InsertPairsBatchAsync(conn, tx, entryId, entry.ReachablePairs, cancellationToken);
|
||||
}
|
||||
|
||||
await tx.CommitAsync(cancellationToken);
|
||||
|
||||
// Update stats
|
||||
await UpdateStatsAsync(conn, entry.ServiceId, isHit: false, entry.GraphHash, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Cached {PairCount} pairs for {ServiceId}, graph {Hash}",
|
||||
entry.ReachablePairs.Count, entry.ServiceId, entry.GraphHash);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await tx.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ReachablePairResult?> GetReachablePairAsync(
|
||||
string serviceId,
|
||||
string entryMethodKey,
|
||||
string sinkMethodKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(cancellationToken);
|
||||
|
||||
const string sql = """
|
||||
SELECT p.is_reachable, p.path_length, p.confidence, p.computed_at
|
||||
FROM reach_cache_pairs p
|
||||
JOIN reach_cache_entries e ON p.cache_entry_id = e.id
|
||||
WHERE e.service_id = @serviceId
|
||||
AND p.entry_method_key = @entryKey
|
||||
AND p.sink_method_key = @sinkKey
|
||||
AND (e.expires_at IS NULL OR e.expires_at > NOW())
|
||||
ORDER BY e.cached_at DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@serviceId", serviceId);
|
||||
cmd.Parameters.AddWithValue("@entryKey", entryMethodKey);
|
||||
cmd.Parameters.AddWithValue("@sinkKey", sinkMethodKey);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
|
||||
if (!await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ReachablePairResult
|
||||
{
|
||||
EntryMethodKey = entryMethodKey,
|
||||
SinkMethodKey = sinkMethodKey,
|
||||
IsReachable = reader.GetBoolean(0),
|
||||
PathLength = reader.IsDBNull(1) ? null : reader.GetInt32(1),
|
||||
Confidence = reader.GetDouble(2),
|
||||
ComputedAt = reader.GetDateTime(3)
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> InvalidateAsync(
|
||||
string serviceId,
|
||||
IEnumerable<string> affectedMethodKeys,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(cancellationToken);
|
||||
|
||||
// For now, invalidate entire cache for service
|
||||
// More granular invalidation would require additional indices
|
||||
const string sql = """
|
||||
DELETE FROM reach_cache_entries
|
||||
WHERE service_id = @serviceId
|
||||
""";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@serviceId", serviceId);
|
||||
|
||||
var deleted = await cmd.ExecuteNonQueryAsync(cancellationToken);
|
||||
|
||||
if (deleted > 0)
|
||||
{
|
||||
await UpdateInvalidationTimeAsync(conn, serviceId, cancellationToken);
|
||||
_logger.LogInformation("Invalidated {Count} cache entries for {ServiceId}", deleted, serviceId);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task InvalidateAllAsync(
|
||||
string serviceId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await InvalidateAsync(serviceId, Array.Empty<string>(), cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CacheStatistics> GetStatisticsAsync(
|
||||
string serviceId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(cancellationToken);
|
||||
|
||||
const string sql = """
|
||||
SELECT total_hits, total_misses, full_recomputes, incremental_computes,
|
||||
current_graph_hash, last_populated_at, last_invalidated_at
|
||||
FROM reach_cache_stats
|
||||
WHERE service_id = @serviceId
|
||||
""";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@serviceId", serviceId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
|
||||
if (!await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
return new CacheStatistics { ServiceId = serviceId };
|
||||
}
|
||||
|
||||
// Get cached pair count
|
||||
await reader.CloseAsync();
|
||||
|
||||
const string countSql = """
|
||||
SELECT COALESCE(SUM(pair_count), 0)
|
||||
FROM reach_cache_entries
|
||||
WHERE service_id = @serviceId AND (expires_at IS NULL OR expires_at > NOW())
|
||||
""";
|
||||
|
||||
await using var countCmd = new NpgsqlCommand(countSql, conn);
|
||||
countCmd.Parameters.AddWithValue("@serviceId", serviceId);
|
||||
var pairCount = Convert.ToInt32(await countCmd.ExecuteScalarAsync(cancellationToken));
|
||||
|
||||
return new CacheStatistics
|
||||
{
|
||||
ServiceId = serviceId,
|
||||
CachedPairCount = pairCount,
|
||||
HitCount = reader.GetInt64(0),
|
||||
MissCount = reader.GetInt64(1),
|
||||
LastPopulatedAt = reader.IsDBNull(5) ? null : reader.GetDateTime(5),
|
||||
LastInvalidatedAt = reader.IsDBNull(6) ? null : reader.GetDateTime(6),
|
||||
CurrentGraphHash = reader.IsDBNull(4) ? null : reader.GetString(4)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task InsertPairsBatchAsync(
|
||||
NpgsqlConnection conn,
|
||||
NpgsqlTransaction tx,
|
||||
Guid entryId,
|
||||
IReadOnlyList<ReachablePairResult> pairs,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var writer = await conn.BeginBinaryImportAsync(
|
||||
"COPY reach_cache_pairs (cache_entry_id, entry_method_key, sink_method_key, is_reachable, path_length, confidence, computed_at) FROM STDIN (FORMAT BINARY)",
|
||||
cancellationToken);
|
||||
|
||||
foreach (var pair in pairs)
|
||||
{
|
||||
await writer.StartRowAsync(cancellationToken);
|
||||
await writer.WriteAsync(entryId, NpgsqlTypes.NpgsqlDbType.Uuid, cancellationToken);
|
||||
await writer.WriteAsync(pair.EntryMethodKey, NpgsqlTypes.NpgsqlDbType.Text, cancellationToken);
|
||||
await writer.WriteAsync(pair.SinkMethodKey, NpgsqlTypes.NpgsqlDbType.Text, cancellationToken);
|
||||
await writer.WriteAsync(pair.IsReachable, NpgsqlTypes.NpgsqlDbType.Boolean, cancellationToken);
|
||||
|
||||
if (pair.PathLength.HasValue)
|
||||
await writer.WriteAsync(pair.PathLength.Value, NpgsqlTypes.NpgsqlDbType.Integer, cancellationToken);
|
||||
else
|
||||
await writer.WriteNullAsync(cancellationToken);
|
||||
|
||||
await writer.WriteAsync(pair.Confidence, NpgsqlTypes.NpgsqlDbType.Double, cancellationToken);
|
||||
await writer.WriteAsync(pair.ComputedAt.UtcDateTime, NpgsqlTypes.NpgsqlDbType.TimestampTz, cancellationToken);
|
||||
}
|
||||
|
||||
await writer.CompleteAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task UpdateStatsAsync(
|
||||
NpgsqlConnection conn,
|
||||
string serviceId,
|
||||
bool isHit,
|
||||
string? graphHash = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT update_reach_cache_stats(@serviceId, @isHit, NULL, @graphHash)";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@serviceId", serviceId);
|
||||
cmd.Parameters.AddWithValue("@isHit", isHit);
|
||||
cmd.Parameters.AddWithValue("@graphHash", graphHash ?? (object)DBNull.Value);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task UpdateInvalidationTimeAsync(
|
||||
NpgsqlConnection conn,
|
||||
string serviceId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE reach_cache_stats
|
||||
SET last_invalidated_at = NOW(), updated_at = NOW()
|
||||
WHERE service_id = @serviceId
|
||||
""";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@serviceId", serviceId);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// StateFlipDetector.cs
|
||||
// Sprint: SPRINT_3700_0006_0001_incremental_cache (CACHE-011)
|
||||
// Description: Detects reachability state changes between scans.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Detects state flips: transitions between reachable and unreachable states.
|
||||
/// Used for PR gates and change tracking.
|
||||
/// </summary>
|
||||
public interface IStateFlipDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// Detects state flips between previous and current reachability results.
|
||||
/// </summary>
|
||||
/// <param name="previous">Previous scan results.</param>
|
||||
/// <param name="current">Current scan results.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>State flip detection result.</returns>
|
||||
Task<StateFlipResult> DetectFlipsAsync(
|
||||
IReadOnlyList<ReachablePairResult> previous,
|
||||
IReadOnlyList<ReachablePairResult> current,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of state flip detection.
|
||||
/// </summary>
|
||||
public sealed record StateFlipResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether any state flips occurred.
|
||||
/// </summary>
|
||||
public bool HasFlips => NewlyReachable.Count > 0 || NewlyUnreachable.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Pairs that became reachable (were unreachable, now reachable).
|
||||
/// This represents NEW RISK.
|
||||
/// </summary>
|
||||
public IReadOnlyList<StateFlip> NewlyReachable { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Pairs that became unreachable (were reachable, now unreachable).
|
||||
/// This represents MITIGATED risk.
|
||||
/// </summary>
|
||||
public IReadOnlyList<StateFlip> NewlyUnreachable { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Count of new risks introduced.
|
||||
/// </summary>
|
||||
public int NewRiskCount => NewlyReachable.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Count of mitigated risks.
|
||||
/// </summary>
|
||||
public int MitigatedCount => NewlyUnreachable.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Net change in reachable vulnerability paths.
|
||||
/// Positive = more risk, Negative = less risk.
|
||||
/// </summary>
|
||||
public int NetChange => NewlyReachable.Count - NewlyUnreachable.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Summary for PR annotation.
|
||||
/// </summary>
|
||||
public string Summary => HasFlips
|
||||
? $"Reachability changed: +{NewRiskCount} new paths, -{MitigatedCount} removed paths"
|
||||
: "No reachability changes";
|
||||
|
||||
/// <summary>
|
||||
/// Whether this should block a PR (new reachable paths introduced).
|
||||
/// </summary>
|
||||
public bool ShouldBlockPr => NewlyReachable.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty result (no flips).
|
||||
/// </summary>
|
||||
public static StateFlipResult Empty => new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single state flip event.
|
||||
/// </summary>
|
||||
public sealed record StateFlip
|
||||
{
|
||||
/// <summary>
|
||||
/// Entry point method key.
|
||||
/// </summary>
|
||||
public required string EntryMethodKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sink method key.
|
||||
/// </summary>
|
||||
public required string SinkMethodKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous state (reachable = true, unreachable = false).
|
||||
/// </summary>
|
||||
public bool WasReachable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// New state.
|
||||
/// </summary>
|
||||
public bool IsReachable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of flip.
|
||||
/// </summary>
|
||||
public StateFlipType FlipType => IsReachable ? StateFlipType.BecameReachable : StateFlipType.BecameUnreachable;
|
||||
|
||||
/// <summary>
|
||||
/// Associated CVE if applicable.
|
||||
/// </summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package name if applicable.
|
||||
/// </summary>
|
||||
public string? PackageName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of state flip.
|
||||
/// </summary>
|
||||
public enum StateFlipType
|
||||
{
|
||||
/// <summary>
|
||||
/// Was unreachable, now reachable (NEW RISK).
|
||||
/// </summary>
|
||||
BecameReachable,
|
||||
|
||||
/// <summary>
|
||||
/// Was reachable, now unreachable (MITIGATED).
|
||||
/// </summary>
|
||||
BecameUnreachable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of state flip detector.
|
||||
/// </summary>
|
||||
public sealed class StateFlipDetector : IStateFlipDetector
|
||||
{
|
||||
private readonly ILogger<StateFlipDetector> _logger;
|
||||
|
||||
public StateFlipDetector(ILogger<StateFlipDetector> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<StateFlipResult> DetectFlipsAsync(
|
||||
IReadOnlyList<ReachablePairResult> previous,
|
||||
IReadOnlyList<ReachablePairResult> current,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(previous);
|
||||
ArgumentNullException.ThrowIfNull(current);
|
||||
|
||||
// Build lookup for previous state
|
||||
var previousState = previous.ToDictionary(
|
||||
p => (p.EntryMethodKey, p.SinkMethodKey),
|
||||
p => p.IsReachable);
|
||||
|
||||
// Build lookup for current state
|
||||
var currentState = current.ToDictionary(
|
||||
p => (p.EntryMethodKey, p.SinkMethodKey),
|
||||
p => p.IsReachable);
|
||||
|
||||
var newlyReachable = new List<StateFlip>();
|
||||
var newlyUnreachable = new List<StateFlip>();
|
||||
|
||||
// Check all current pairs for flips
|
||||
foreach (var pair in current)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var key = (pair.EntryMethodKey, pair.SinkMethodKey);
|
||||
|
||||
if (previousState.TryGetValue(key, out var wasReachable))
|
||||
{
|
||||
if (!wasReachable && pair.IsReachable)
|
||||
{
|
||||
// Was unreachable, now reachable = NEW RISK
|
||||
newlyReachable.Add(new StateFlip
|
||||
{
|
||||
EntryMethodKey = pair.EntryMethodKey,
|
||||
SinkMethodKey = pair.SinkMethodKey,
|
||||
WasReachable = false,
|
||||
IsReachable = true
|
||||
});
|
||||
}
|
||||
else if (wasReachable && !pair.IsReachable)
|
||||
{
|
||||
// Was reachable, now unreachable = MITIGATED
|
||||
newlyUnreachable.Add(new StateFlip
|
||||
{
|
||||
EntryMethodKey = pair.EntryMethodKey,
|
||||
SinkMethodKey = pair.SinkMethodKey,
|
||||
WasReachable = true,
|
||||
IsReachable = false
|
||||
});
|
||||
}
|
||||
}
|
||||
else if (pair.IsReachable)
|
||||
{
|
||||
// New pair that is reachable = NEW RISK
|
||||
newlyReachable.Add(new StateFlip
|
||||
{
|
||||
EntryMethodKey = pair.EntryMethodKey,
|
||||
SinkMethodKey = pair.SinkMethodKey,
|
||||
WasReachable = false,
|
||||
IsReachable = true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for pairs that existed previously but no longer exist (removed code = mitigated)
|
||||
foreach (var prevPair in previous.Where(p => p.IsReachable))
|
||||
{
|
||||
var key = (prevPair.EntryMethodKey, prevPair.SinkMethodKey);
|
||||
|
||||
if (!currentState.ContainsKey(key))
|
||||
{
|
||||
// Pair no longer exists and was reachable = MITIGATED
|
||||
newlyUnreachable.Add(new StateFlip
|
||||
{
|
||||
EntryMethodKey = prevPair.EntryMethodKey,
|
||||
SinkMethodKey = prevPair.SinkMethodKey,
|
||||
WasReachable = true,
|
||||
IsReachable = false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var result = new StateFlipResult
|
||||
{
|
||||
NewlyReachable = newlyReachable,
|
||||
NewlyUnreachable = newlyUnreachable
|
||||
};
|
||||
|
||||
if (result.HasFlips)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"State flips detected: +{NewRisk} new reachable, -{Mitigated} unreachable (net: {Net})",
|
||||
result.NewRiskCount, result.MitigatedCount, result.NetChange);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("No state flips detected");
|
||||
}
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,11 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Npgsql" Version="9.0.3" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Cache\StellaOps.Scanner.Cache.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Scanner.ProofSpine\StellaOps.Scanner.ProofSpine.csproj" />
|
||||
@@ -11,6 +16,7 @@
|
||||
<ProjectReference Include="..\StellaOps.Scanner.SmartDiff\StellaOps.Scanner.SmartDiff.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Scanner.Analyzers.Native\StellaOps.Scanner.Analyzers.Native.csproj" />
|
||||
<ProjectReference Include="..\..\..\Attestor\StellaOps.Attestor\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\Attestor\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Replay.Core\StellaOps.Replay.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ISurfaceQueryService.cs
|
||||
// Sprint: SPRINT_3700_0004_0001_reachability_integration (REACH-001)
|
||||
// Description: Interface for querying vulnerability surfaces during scans.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Surfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Service for querying vulnerability surfaces to resolve trigger methods for reachability analysis.
|
||||
/// </summary>
|
||||
public interface ISurfaceQueryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Queries the vulnerability surface for a specific CVE and package.
|
||||
/// </summary>
|
||||
/// <param name="request">Query request with CVE and package details.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Query result with trigger methods or fallback indicators.</returns>
|
||||
Task<SurfaceQueryResult> QueryAsync(
|
||||
SurfaceQueryRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Bulk query for multiple CVE/package combinations.
|
||||
/// </summary>
|
||||
/// <param name="requests">Collection of query requests.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Dictionary of results keyed by query key.</returns>
|
||||
Task<IReadOnlyDictionary<string, SurfaceQueryResult>> QueryBulkAsync(
|
||||
IEnumerable<SurfaceQueryRequest> requests,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a surface exists for the given CVE and package.
|
||||
/// </summary>
|
||||
/// <param name="cveId">CVE identifier.</param>
|
||||
/// <param name="ecosystem">Package ecosystem.</param>
|
||||
/// <param name="packageName">Package name.</param>
|
||||
/// <param name="version">Package version.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if surface exists.</returns>
|
||||
Task<bool> ExistsAsync(
|
||||
string cveId,
|
||||
string ecosystem,
|
||||
string packageName,
|
||||
string version,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to query a vulnerability surface.
|
||||
/// </summary>
|
||||
public sealed record SurfaceQueryRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// CVE identifier.
|
||||
/// </summary>
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package ecosystem (nuget, npm, maven, pypi).
|
||||
/// </summary>
|
||||
public required string Ecosystem { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package name.
|
||||
/// </summary>
|
||||
public required string PackageName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerable package version.
|
||||
/// </summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include internal paths in the result.
|
||||
/// </summary>
|
||||
public bool IncludePaths { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of triggers to return.
|
||||
/// </summary>
|
||||
public int MaxTriggers { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a unique key for caching/batching.
|
||||
/// </summary>
|
||||
public string QueryKey => $"{CveId}|{Ecosystem}|{PackageName}|{Version}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a vulnerability surface query.
|
||||
/// </summary>
|
||||
public sealed record SurfaceQueryResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether a surface was found.
|
||||
/// </summary>
|
||||
public bool SurfaceFound { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The source of sink methods for reachability analysis.
|
||||
/// </summary>
|
||||
public SinkSource Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Surface ID if found.
|
||||
/// </summary>
|
||||
public Guid? SurfaceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trigger method keys (public API entry points).
|
||||
/// </summary>
|
||||
public IReadOnlyList<TriggerMethodInfo> Triggers { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Sink method keys (changed vulnerability methods).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Sinks { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Error message if query failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the surface was computed.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ComputedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a result indicating surface was found.
|
||||
/// </summary>
|
||||
public static SurfaceQueryResult Found(
|
||||
Guid surfaceId,
|
||||
IReadOnlyList<TriggerMethodInfo> triggers,
|
||||
IReadOnlyList<string> sinks,
|
||||
DateTimeOffset computedAt)
|
||||
{
|
||||
return new SurfaceQueryResult
|
||||
{
|
||||
SurfaceFound = true,
|
||||
Source = SinkSource.Surface,
|
||||
SurfaceId = surfaceId,
|
||||
Triggers = triggers,
|
||||
Sinks = sinks,
|
||||
ComputedAt = computedAt
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a result indicating fallback to package API.
|
||||
/// </summary>
|
||||
public static SurfaceQueryResult FallbackToPackageApi(string reason)
|
||||
{
|
||||
return new SurfaceQueryResult
|
||||
{
|
||||
SurfaceFound = false,
|
||||
Source = SinkSource.PackageApi,
|
||||
Error = reason
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a result indicating no surface data available.
|
||||
/// </summary>
|
||||
public static SurfaceQueryResult NotFound(string cveId, string packageName)
|
||||
{
|
||||
return new SurfaceQueryResult
|
||||
{
|
||||
SurfaceFound = false,
|
||||
Source = SinkSource.FallbackAll,
|
||||
Error = $"No surface found for {cveId} in {packageName}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a trigger method.
|
||||
/// </summary>
|
||||
public sealed record TriggerMethodInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Fully qualified method key.
|
||||
/// </summary>
|
||||
public required string MethodKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Simple method name.
|
||||
/// </summary>
|
||||
public required string MethodName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Declaring type.
|
||||
/// </summary>
|
||||
public required string DeclaringType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of sinks reachable from this trigger.
|
||||
/// </summary>
|
||||
public int SinkCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Shortest path length to any sink.
|
||||
/// </summary>
|
||||
public int ShortestPathLength { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this trigger is an interface method.
|
||||
/// </summary>
|
||||
public bool IsInterfaceTrigger { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source of sink methods for reachability analysis.
|
||||
/// </summary>
|
||||
public enum SinkSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Sinks from computed vulnerability surface (highest precision).
|
||||
/// </summary>
|
||||
Surface,
|
||||
|
||||
/// <summary>
|
||||
/// Sinks from package public API (medium precision).
|
||||
/// </summary>
|
||||
PackageApi,
|
||||
|
||||
/// <summary>
|
||||
/// Fallback: all methods in package (lowest precision).
|
||||
/// </summary>
|
||||
FallbackAll
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ISurfaceRepository.cs
|
||||
// Sprint: SPRINT_3700_0004_0001_reachability_integration (REACH-002)
|
||||
// Description: Repository interface for vulnerability surface data access.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Surfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for accessing vulnerability surface data.
|
||||
/// </summary>
|
||||
public interface ISurfaceRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a vulnerability surface by CVE and package.
|
||||
/// </summary>
|
||||
Task<SurfaceInfo?> GetSurfaceAsync(
|
||||
string cveId,
|
||||
string ecosystem,
|
||||
string packageName,
|
||||
string version,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets trigger methods for a surface.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TriggerMethodInfo>> GetTriggersAsync(
|
||||
Guid surfaceId,
|
||||
int maxCount,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets sink method keys for a surface.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<string>> GetSinksAsync(
|
||||
Guid surfaceId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a surface exists.
|
||||
/// </summary>
|
||||
Task<bool> ExistsAsync(
|
||||
string cveId,
|
||||
string ecosystem,
|
||||
string packageName,
|
||||
string version,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a vulnerability surface.
|
||||
/// </summary>
|
||||
public sealed record SurfaceInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Surface ID.
|
||||
/// </summary>
|
||||
public Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE identifier.
|
||||
/// </summary>
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package ecosystem.
|
||||
/// </summary>
|
||||
public required string Ecosystem { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package name.
|
||||
/// </summary>
|
||||
public required string PackageName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerable version.
|
||||
/// </summary>
|
||||
public required string VulnVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Fixed version.
|
||||
/// </summary>
|
||||
public string? FixedVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the surface was computed.
|
||||
/// </summary>
|
||||
public DateTimeOffset ComputedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of changed methods (sinks).
|
||||
/// </summary>
|
||||
public int ChangedMethodCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of trigger methods.
|
||||
/// </summary>
|
||||
public int TriggerCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ReachabilityConfidenceTier.cs
|
||||
// Sprint: SPRINT_3700_0004_0001_reachability_integration (REACH-004)
|
||||
// Description: Confidence tiers for reachability analysis results.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Scanner.Reachability;
|
||||
|
||||
/// <summary>
|
||||
/// Confidence tier for reachability analysis results.
|
||||
/// Higher tiers indicate more precise and actionable findings.
|
||||
/// </summary>
|
||||
public enum ReachabilityConfidenceTier
|
||||
{
|
||||
/// <summary>
|
||||
/// Confirmed reachable: Surface + trigger method reachable.
|
||||
/// Path from entrypoint to specific trigger method that reaches vulnerable code.
|
||||
/// Highest confidence - "You WILL hit the vulnerable code via this path."
|
||||
/// </summary>
|
||||
Confirmed = 100,
|
||||
|
||||
/// <summary>
|
||||
/// Likely reachable: No surface but package API is called.
|
||||
/// Path to public API of vulnerable package exists.
|
||||
/// Medium confidence - "You call the package; vulnerability MAY be triggered."
|
||||
/// </summary>
|
||||
Likely = 75,
|
||||
|
||||
/// <summary>
|
||||
/// Present: Package is in dependency tree but no call graph data.
|
||||
/// Dependency exists but reachability cannot be determined.
|
||||
/// Lower confidence - "Package is present; impact unknown."
|
||||
/// </summary>
|
||||
Present = 50,
|
||||
|
||||
/// <summary>
|
||||
/// Unreachable: No path to vulnerable code found.
|
||||
/// Surface analyzed, no triggers reached from entrypoints.
|
||||
/// Evidence for not_affected VEX status.
|
||||
/// </summary>
|
||||
Unreachable = 25,
|
||||
|
||||
/// <summary>
|
||||
/// Unknown: Insufficient data to determine reachability.
|
||||
/// </summary>
|
||||
Unknown = 0
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for ReachabilityConfidenceTier.
|
||||
/// </summary>
|
||||
public static class ReachabilityConfidenceTierExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets human-readable description of the confidence tier.
|
||||
/// </summary>
|
||||
public static string GetDescription(this ReachabilityConfidenceTier tier) => tier switch
|
||||
{
|
||||
ReachabilityConfidenceTier.Confirmed => "Confirmed reachable via trigger method",
|
||||
ReachabilityConfidenceTier.Likely => "Likely reachable via package API",
|
||||
ReachabilityConfidenceTier.Present => "Package present but reachability undetermined",
|
||||
ReachabilityConfidenceTier.Unreachable => "No path to vulnerable code found",
|
||||
ReachabilityConfidenceTier.Unknown => "Insufficient data for analysis",
|
||||
_ => "Unknown confidence tier"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the VEX status recommendation for this tier.
|
||||
/// </summary>
|
||||
public static string GetVexRecommendation(this ReachabilityConfidenceTier tier) => tier switch
|
||||
{
|
||||
ReachabilityConfidenceTier.Confirmed => "affected",
|
||||
ReachabilityConfidenceTier.Likely => "under_investigation",
|
||||
ReachabilityConfidenceTier.Present => "under_investigation",
|
||||
ReachabilityConfidenceTier.Unreachable => "not_affected",
|
||||
ReachabilityConfidenceTier.Unknown => "under_investigation",
|
||||
_ => "under_investigation"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Checks if this tier indicates potential impact.
|
||||
/// </summary>
|
||||
public static bool IndicatesImpact(this ReachabilityConfidenceTier tier) =>
|
||||
tier is ReachabilityConfidenceTier.Confirmed or ReachabilityConfidenceTier.Likely;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if this tier can provide evidence for not_affected.
|
||||
/// </summary>
|
||||
public static bool CanBeNotAffected(this ReachabilityConfidenceTier tier) =>
|
||||
tier is ReachabilityConfidenceTier.Unreachable;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a confidence score (0.0 - 1.0) for this tier.
|
||||
/// </summary>
|
||||
public static double GetConfidenceScore(this ReachabilityConfidenceTier tier) =>
|
||||
(int)tier / 100.0;
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SurfaceAwareReachabilityAnalyzer.cs
|
||||
// Sprint: SPRINT_3700_0004_0001_reachability_integration (REACH-005, REACH-006, REACH-009)
|
||||
// Description: Reachability analyzer that uses vulnerability surfaces for precise sink resolution.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Surfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Reachability analyzer that integrates with vulnerability surfaces
|
||||
/// for precise trigger-based sink resolution.
|
||||
/// </summary>
|
||||
public sealed class SurfaceAwareReachabilityAnalyzer : ISurfaceAwareReachabilityAnalyzer
|
||||
{
|
||||
private readonly ISurfaceQueryService _surfaceQuery;
|
||||
private readonly IReachabilityGraphService _graphService;
|
||||
private readonly ILogger<SurfaceAwareReachabilityAnalyzer> _logger;
|
||||
|
||||
public SurfaceAwareReachabilityAnalyzer(
|
||||
ISurfaceQueryService surfaceQuery,
|
||||
IReachabilityGraphService graphService,
|
||||
ILogger<SurfaceAwareReachabilityAnalyzer> logger)
|
||||
{
|
||||
_surfaceQuery = surfaceQuery ?? throw new ArgumentNullException(nameof(surfaceQuery));
|
||||
_graphService = graphService ?? throw new ArgumentNullException(nameof(graphService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SurfaceAwareReachabilityResult> AnalyzeAsync(
|
||||
SurfaceAwareReachabilityRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var findings = new List<SurfaceReachabilityFinding>();
|
||||
|
||||
// Query surfaces for all vulnerabilities
|
||||
var surfaceRequests = request.Vulnerabilities
|
||||
.Select(v => new SurfaceQueryRequest
|
||||
{
|
||||
CveId = v.CveId,
|
||||
Ecosystem = v.Ecosystem,
|
||||
PackageName = v.PackageName,
|
||||
Version = v.Version,
|
||||
IncludePaths = true
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var surfaceResults = await _surfaceQuery.QueryBulkAsync(surfaceRequests, cancellationToken);
|
||||
|
||||
foreach (var vuln in request.Vulnerabilities)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var queryKey = $"{vuln.CveId}|{vuln.Ecosystem}|{vuln.PackageName}|{vuln.Version}";
|
||||
|
||||
if (!surfaceResults.TryGetValue(queryKey, out var surface))
|
||||
{
|
||||
// No surface result - should not happen but handle gracefully
|
||||
findings.Add(CreateUnknownFinding(vuln, "No surface query result"));
|
||||
continue;
|
||||
}
|
||||
|
||||
var finding = await AnalyzeVulnerabilityAsync(vuln, surface, request.CallGraph, cancellationToken);
|
||||
findings.Add(finding);
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
|
||||
// Compute summary statistics
|
||||
var confirmedCount = findings.Count(f => f.ConfidenceTier == ReachabilityConfidenceTier.Confirmed);
|
||||
var likelyCount = findings.Count(f => f.ConfidenceTier == ReachabilityConfidenceTier.Likely);
|
||||
var unreachableCount = findings.Count(f => f.ConfidenceTier == ReachabilityConfidenceTier.Unreachable);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Surface-aware reachability analysis complete: {Total} vulns, {Confirmed} confirmed, {Likely} likely, {Unreachable} unreachable in {Duration}ms",
|
||||
findings.Count, confirmedCount, likelyCount, unreachableCount, sw.ElapsedMilliseconds);
|
||||
|
||||
return new SurfaceAwareReachabilityResult
|
||||
{
|
||||
Findings = findings,
|
||||
TotalVulnerabilities = findings.Count,
|
||||
ConfirmedReachable = confirmedCount,
|
||||
LikelyReachable = likelyCount,
|
||||
Unreachable = unreachableCount,
|
||||
AnalysisDuration = sw.Elapsed
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<SurfaceReachabilityFinding> AnalyzeVulnerabilityAsync(
|
||||
VulnerabilityInfo vuln,
|
||||
SurfaceQueryResult surface,
|
||||
ICallGraphAccessor? callGraph,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Determine sink source and resolve sinks
|
||||
IReadOnlyList<string> sinks;
|
||||
SinkSource sinkSource;
|
||||
|
||||
if (surface.SurfaceFound && surface.Triggers.Count > 0)
|
||||
{
|
||||
// Use trigger methods as sinks (highest precision)
|
||||
sinks = surface.Triggers.Select(t => t.MethodKey).ToList();
|
||||
sinkSource = SinkSource.Surface;
|
||||
|
||||
_logger.LogDebug(
|
||||
"{CveId}/{PackageName}: Using {TriggerCount} trigger methods from surface",
|
||||
vuln.CveId, vuln.PackageName, sinks.Count);
|
||||
}
|
||||
else if (surface.Source == SinkSource.PackageApi)
|
||||
{
|
||||
// Fallback to package API methods
|
||||
sinks = await ResolvePackageApiMethodsAsync(vuln, cancellationToken);
|
||||
sinkSource = SinkSource.PackageApi;
|
||||
|
||||
_logger.LogDebug(
|
||||
"{CveId}/{PackageName}: Using {SinkCount} package API methods as fallback",
|
||||
vuln.CveId, vuln.PackageName, sinks.Count);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Ultimate fallback - no sink resolution possible
|
||||
return CreatePresentFinding(vuln, surface);
|
||||
}
|
||||
|
||||
// If no call graph, we can't determine reachability
|
||||
if (callGraph is null)
|
||||
{
|
||||
return CreatePresentFinding(vuln, surface);
|
||||
}
|
||||
|
||||
// Perform reachability analysis from entrypoints to sinks
|
||||
var reachablePaths = await _graphService.FindPathsToSinksAsync(
|
||||
callGraph,
|
||||
sinks,
|
||||
cancellationToken);
|
||||
|
||||
if (reachablePaths.Count == 0)
|
||||
{
|
||||
// No paths found - unreachable
|
||||
return new SurfaceReachabilityFinding
|
||||
{
|
||||
CveId = vuln.CveId,
|
||||
PackageName = vuln.PackageName,
|
||||
Version = vuln.Version,
|
||||
ConfidenceTier = ReachabilityConfidenceTier.Unreachable,
|
||||
SinkSource = sinkSource,
|
||||
SurfaceId = surface.SurfaceId,
|
||||
Message = "No execution path to vulnerable code found",
|
||||
ReachableTriggers = [],
|
||||
Witnesses = []
|
||||
};
|
||||
}
|
||||
|
||||
// Paths found - determine confidence tier
|
||||
var tier = sinkSource == SinkSource.Surface
|
||||
? ReachabilityConfidenceTier.Confirmed
|
||||
: ReachabilityConfidenceTier.Likely;
|
||||
|
||||
var reachableTriggers = reachablePaths
|
||||
.Select(p => p.SinkMethodKey)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
return new SurfaceReachabilityFinding
|
||||
{
|
||||
CveId = vuln.CveId,
|
||||
PackageName = vuln.PackageName,
|
||||
Version = vuln.Version,
|
||||
ConfidenceTier = tier,
|
||||
SinkSource = sinkSource,
|
||||
SurfaceId = surface.SurfaceId,
|
||||
Message = $"{tier.GetDescription()}: {reachablePaths.Count} paths to {reachableTriggers.Count} triggers",
|
||||
ReachableTriggers = reachableTriggers,
|
||||
Witnesses = reachablePaths.Select(p => new PathWitness
|
||||
{
|
||||
EntrypointMethodKey = p.EntrypointMethodKey,
|
||||
SinkMethodKey = p.SinkMethodKey,
|
||||
PathLength = p.PathLength,
|
||||
PathMethodKeys = p.PathMethodKeys
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> ResolvePackageApiMethodsAsync(
|
||||
VulnerabilityInfo vuln,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO: Implement package API method resolution
|
||||
// This would query the package's public API methods as fallback sinks
|
||||
await Task.CompletedTask;
|
||||
return [];
|
||||
}
|
||||
|
||||
private static SurfaceReachabilityFinding CreatePresentFinding(
|
||||
VulnerabilityInfo vuln,
|
||||
SurfaceQueryResult surface)
|
||||
{
|
||||
return new SurfaceReachabilityFinding
|
||||
{
|
||||
CveId = vuln.CveId,
|
||||
PackageName = vuln.PackageName,
|
||||
Version = vuln.Version,
|
||||
ConfidenceTier = ReachabilityConfidenceTier.Present,
|
||||
SinkSource = surface.Source,
|
||||
SurfaceId = surface.SurfaceId,
|
||||
Message = "Package present; reachability undetermined",
|
||||
ReachableTriggers = [],
|
||||
Witnesses = []
|
||||
};
|
||||
}
|
||||
|
||||
private static SurfaceReachabilityFinding CreateUnknownFinding(
|
||||
VulnerabilityInfo vuln,
|
||||
string reason)
|
||||
{
|
||||
return new SurfaceReachabilityFinding
|
||||
{
|
||||
CveId = vuln.CveId,
|
||||
PackageName = vuln.PackageName,
|
||||
Version = vuln.Version,
|
||||
ConfidenceTier = ReachabilityConfidenceTier.Unknown,
|
||||
SinkSource = SinkSource.FallbackAll,
|
||||
Message = reason,
|
||||
ReachableTriggers = [],
|
||||
Witnesses = []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for surface-aware reachability analysis.
|
||||
/// </summary>
|
||||
public interface ISurfaceAwareReachabilityAnalyzer
|
||||
{
|
||||
/// <summary>
|
||||
/// Analyzes reachability for vulnerabilities using surface data.
|
||||
/// </summary>
|
||||
Task<SurfaceAwareReachabilityResult> AnalyzeAsync(
|
||||
SurfaceAwareReachabilityRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for surface-aware reachability analysis.
|
||||
/// </summary>
|
||||
public sealed record SurfaceAwareReachabilityRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Vulnerabilities to analyze.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<VulnerabilityInfo> Vulnerabilities { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Call graph accessor for the analyzed codebase.
|
||||
/// </summary>
|
||||
public ICallGraphAccessor? CallGraph { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum depth for path finding.
|
||||
/// </summary>
|
||||
public int MaxPathDepth { get; init; } = 20;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of surface-aware reachability analysis.
|
||||
/// </summary>
|
||||
public sealed record SurfaceAwareReachabilityResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Individual findings for each vulnerability.
|
||||
/// </summary>
|
||||
public IReadOnlyList<SurfaceReachabilityFinding> Findings { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Total vulnerabilities analyzed.
|
||||
/// </summary>
|
||||
public int TotalVulnerabilities { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Count of confirmed reachable vulnerabilities.
|
||||
/// </summary>
|
||||
public int ConfirmedReachable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Count of likely reachable vulnerabilities.
|
||||
/// </summary>
|
||||
public int LikelyReachable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Count of unreachable vulnerabilities.
|
||||
/// </summary>
|
||||
public int Unreachable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Analysis duration.
|
||||
/// </summary>
|
||||
public TimeSpan AnalysisDuration { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reachability finding for a single vulnerability.
|
||||
/// </summary>
|
||||
public sealed record SurfaceReachabilityFinding
|
||||
{
|
||||
/// <summary>
|
||||
/// CVE identifier.
|
||||
/// </summary>
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package name.
|
||||
/// </summary>
|
||||
public required string PackageName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package version.
|
||||
/// </summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence tier for this finding.
|
||||
/// </summary>
|
||||
public ReachabilityConfidenceTier ConfidenceTier { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source of sink methods used.
|
||||
/// </summary>
|
||||
public SinkSource SinkSource { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Surface ID if available.
|
||||
/// </summary>
|
||||
public Guid? SurfaceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable message.
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trigger methods that are reachable.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ReachableTriggers { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Path witnesses from entrypoints to triggers.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PathWitness> Witnesses { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability information for analysis.
|
||||
/// </summary>
|
||||
public sealed record VulnerabilityInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// CVE identifier.
|
||||
/// </summary>
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package ecosystem.
|
||||
/// </summary>
|
||||
public required string Ecosystem { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package name.
|
||||
/// </summary>
|
||||
public required string PackageName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package version.
|
||||
/// </summary>
|
||||
public required string Version { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Path witness from entrypoint to sink.
|
||||
/// </summary>
|
||||
public sealed record PathWitness
|
||||
{
|
||||
/// <summary>
|
||||
/// Entrypoint method key.
|
||||
/// </summary>
|
||||
public required string EntrypointMethodKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sink (trigger) method key.
|
||||
/// </summary>
|
||||
public required string SinkMethodKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of hops in path.
|
||||
/// </summary>
|
||||
public int PathLength { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ordered method keys in path.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> PathMethodKeys { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for call graph access.
|
||||
/// </summary>
|
||||
public interface ICallGraphAccessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets entrypoint method keys.
|
||||
/// </summary>
|
||||
IReadOnlyList<string> GetEntrypoints();
|
||||
|
||||
/// <summary>
|
||||
/// Gets callees of a method.
|
||||
/// </summary>
|
||||
IReadOnlyList<string> GetCallees(string methodKey);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a method exists.
|
||||
/// </summary>
|
||||
bool ContainsMethod(string methodKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for reachability graph operations.
|
||||
/// </summary>
|
||||
public interface IReachabilityGraphService
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds paths from entrypoints to any of the specified sinks.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ReachablePath>> FindPathsToSinksAsync(
|
||||
ICallGraphAccessor callGraph,
|
||||
IReadOnlyList<string> sinkMethodKeys,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A reachable path from entrypoint to sink.
|
||||
/// </summary>
|
||||
public sealed record ReachablePath
|
||||
{
|
||||
/// <summary>
|
||||
/// Entrypoint method key.
|
||||
/// </summary>
|
||||
public required string EntrypointMethodKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sink method key.
|
||||
/// </summary>
|
||||
public required string SinkMethodKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path length.
|
||||
/// </summary>
|
||||
public int PathLength { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ordered method keys in path.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> PathMethodKeys { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SurfaceQueryService.cs
|
||||
// Sprint: SPRINT_3700_0004_0001_reachability_integration (REACH-002, REACH-003, REACH-007)
|
||||
// Description: Implementation of vulnerability surface query service.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Surfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of the vulnerability surface query service.
|
||||
/// Queries the database for pre-computed vulnerability surfaces.
|
||||
/// </summary>
|
||||
public sealed class SurfaceQueryService : ISurfaceQueryService
|
||||
{
|
||||
private readonly ISurfaceRepository _repository;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<SurfaceQueryService> _logger;
|
||||
private readonly SurfaceQueryOptions _options;
|
||||
|
||||
private static readonly TimeSpan DefaultCacheDuration = TimeSpan.FromMinutes(15);
|
||||
|
||||
public SurfaceQueryService(
|
||||
ISurfaceRepository repository,
|
||||
IMemoryCache cache,
|
||||
ILogger<SurfaceQueryService> logger,
|
||||
SurfaceQueryOptions? options = null)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options ?? new SurfaceQueryOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SurfaceQueryResult> QueryAsync(
|
||||
SurfaceQueryRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var cacheKey = $"surface:{request.QueryKey}";
|
||||
|
||||
// Check cache first
|
||||
if (_options.EnableCaching && _cache.TryGetValue<SurfaceQueryResult>(cacheKey, out var cached))
|
||||
{
|
||||
SurfaceQueryMetrics.CacheHits.Add(1);
|
||||
return cached!;
|
||||
}
|
||||
|
||||
SurfaceQueryMetrics.CacheMisses.Add(1);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
// Query repository
|
||||
var surface = await _repository.GetSurfaceAsync(
|
||||
request.CveId,
|
||||
request.Ecosystem,
|
||||
request.PackageName,
|
||||
request.Version,
|
||||
cancellationToken);
|
||||
|
||||
SurfaceQueryResult result;
|
||||
|
||||
if (surface is not null)
|
||||
{
|
||||
// Surface found - get triggers
|
||||
var triggers = await _repository.GetTriggersAsync(
|
||||
surface.Id,
|
||||
request.MaxTriggers,
|
||||
cancellationToken);
|
||||
|
||||
var sinks = await _repository.GetSinksAsync(surface.Id, cancellationToken);
|
||||
|
||||
result = SurfaceQueryResult.Found(
|
||||
surface.Id,
|
||||
triggers,
|
||||
sinks,
|
||||
surface.ComputedAt);
|
||||
|
||||
SurfaceQueryMetrics.SurfaceHits.Add(1);
|
||||
_logger.LogDebug(
|
||||
"Surface found for {CveId}/{PackageName}: {TriggerCount} triggers, {SinkCount} sinks",
|
||||
request.CveId, request.PackageName, triggers.Count, sinks.Count);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Surface not found - apply fallback cascade
|
||||
result = ApplyFallbackCascade(request);
|
||||
SurfaceQueryMetrics.SurfaceMisses.Add(1);
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
SurfaceQueryMetrics.QueryDurationMs.Record(sw.ElapsedMilliseconds);
|
||||
|
||||
// Cache result
|
||||
if (_options.EnableCaching)
|
||||
{
|
||||
var cacheOptions = new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = _options.CacheDuration ?? DefaultCacheDuration
|
||||
};
|
||||
_cache.Set(cacheKey, result, cacheOptions);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
SurfaceQueryMetrics.QueryErrors.Add(1);
|
||||
_logger.LogWarning(ex, "Failed to query surface for {CveId}/{PackageName}", request.CveId, request.PackageName);
|
||||
|
||||
return SurfaceQueryResult.FallbackToPackageApi($"Query failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyDictionary<string, SurfaceQueryResult>> QueryBulkAsync(
|
||||
IEnumerable<SurfaceQueryRequest> requests,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var requestList = requests.ToList();
|
||||
var results = new Dictionary<string, SurfaceQueryResult>(requestList.Count);
|
||||
|
||||
// Split into cached and uncached
|
||||
var uncachedRequests = new List<SurfaceQueryRequest>();
|
||||
foreach (var request in requestList)
|
||||
{
|
||||
var cacheKey = $"surface:{request.QueryKey}";
|
||||
if (_options.EnableCaching && _cache.TryGetValue<SurfaceQueryResult>(cacheKey, out var cached))
|
||||
{
|
||||
results[request.QueryKey] = cached!;
|
||||
SurfaceQueryMetrics.CacheHits.Add(1);
|
||||
}
|
||||
else
|
||||
{
|
||||
uncachedRequests.Add(request);
|
||||
SurfaceQueryMetrics.CacheMisses.Add(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Query remaining in parallel batches
|
||||
if (uncachedRequests.Count > 0)
|
||||
{
|
||||
var batchSize = _options.BulkQueryBatchSize;
|
||||
var batches = uncachedRequests
|
||||
.Select((r, i) => new { Request = r, Index = i })
|
||||
.GroupBy(x => x.Index / batchSize)
|
||||
.Select(g => g.Select(x => x.Request).ToList());
|
||||
|
||||
foreach (var batch in batches)
|
||||
{
|
||||
var tasks = batch.Select(r => QueryAsync(r, cancellationToken));
|
||||
var batchResults = await Task.WhenAll(tasks);
|
||||
|
||||
for (var i = 0; i < batch.Count; i++)
|
||||
{
|
||||
results[batch[i].QueryKey] = batchResults[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> ExistsAsync(
|
||||
string cveId,
|
||||
string ecosystem,
|
||||
string packageName,
|
||||
string version,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = $"surface_exists:{cveId}|{ecosystem}|{packageName}|{version}";
|
||||
|
||||
if (_options.EnableCaching && _cache.TryGetValue<bool>(cacheKey, out var exists))
|
||||
{
|
||||
return exists;
|
||||
}
|
||||
|
||||
var result = await _repository.ExistsAsync(cveId, ecosystem, packageName, version, cancellationToken);
|
||||
|
||||
if (_options.EnableCaching)
|
||||
{
|
||||
_cache.Set(cacheKey, result, TimeSpan.FromMinutes(5));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private SurfaceQueryResult ApplyFallbackCascade(SurfaceQueryRequest request)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"No surface for {CveId}/{PackageName} v{Version}, applying fallback cascade",
|
||||
request.CveId, request.PackageName, request.Version);
|
||||
|
||||
// Fallback cascade:
|
||||
// 1. If we have package API info, use that
|
||||
// 2. Otherwise, fall back to "all methods" mode
|
||||
|
||||
// For now, return FallbackAll - in future we can add PackageApi lookup
|
||||
return SurfaceQueryResult.NotFound(request.CveId, request.PackageName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for surface query service.
|
||||
/// </summary>
|
||||
public sealed record SurfaceQueryOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether to enable in-memory caching.
|
||||
/// </summary>
|
||||
public bool EnableCaching { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Cache duration for surface results.
|
||||
/// </summary>
|
||||
public TimeSpan? CacheDuration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Batch size for bulk queries.
|
||||
/// </summary>
|
||||
public int BulkQueryBatchSize { get; init; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metrics for surface query service.
|
||||
/// </summary>
|
||||
internal static class SurfaceQueryMetrics
|
||||
{
|
||||
private static readonly string MeterName = "StellaOps.Scanner.Reachability.Surfaces";
|
||||
|
||||
public static readonly System.Diagnostics.Metrics.Counter<long> CacheHits =
|
||||
new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter<long>(
|
||||
"stellaops.surface_query.cache_hits",
|
||||
description: "Number of surface query cache hits");
|
||||
|
||||
public static readonly System.Diagnostics.Metrics.Counter<long> CacheMisses =
|
||||
new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter<long>(
|
||||
"stellaops.surface_query.cache_misses",
|
||||
description: "Number of surface query cache misses");
|
||||
|
||||
public static readonly System.Diagnostics.Metrics.Counter<long> SurfaceHits =
|
||||
new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter<long>(
|
||||
"stellaops.surface_query.surface_hits",
|
||||
description: "Number of surfaces found");
|
||||
|
||||
public static readonly System.Diagnostics.Metrics.Counter<long> SurfaceMisses =
|
||||
new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter<long>(
|
||||
"stellaops.surface_query.surface_misses",
|
||||
description: "Number of surfaces not found");
|
||||
|
||||
public static readonly System.Diagnostics.Metrics.Counter<long> QueryErrors =
|
||||
new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter<long>(
|
||||
"stellaops.surface_query.errors",
|
||||
description: "Number of query errors");
|
||||
|
||||
public static readonly System.Diagnostics.Metrics.Histogram<long> QueryDurationMs =
|
||||
new System.Diagnostics.Metrics.Meter(MeterName).CreateHistogram<long>(
|
||||
"stellaops.surface_query.duration_ms",
|
||||
unit: "ms",
|
||||
description: "Surface query duration in milliseconds");
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Witnesses;
|
||||
|
||||
/// <summary>
|
||||
@@ -20,6 +22,18 @@ public interface IPathWitnessBuilder
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>All generated witnesses.</returns>
|
||||
IAsyncEnumerable<PathWitness> BuildAllAsync(BatchWitnessRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates path witnesses from pre-computed ReachabilityAnalyzer output.
|
||||
/// Sprint: SPRINT_3700_0001_0001 (WIT-008)
|
||||
/// This method uses deterministic paths from the analyzer instead of computing its own.
|
||||
/// </summary>
|
||||
/// <param name="request">The analyzer-based witness request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>All generated witnesses from the analyzer paths.</returns>
|
||||
IAsyncEnumerable<PathWitness> BuildFromAnalyzerAsync(
|
||||
AnalyzerWitnessRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -173,3 +187,92 @@ public sealed record BatchWitnessRequest
|
||||
/// </summary>
|
||||
public string? BuildId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to build witnesses from pre-computed ReachabilityAnalyzer output.
|
||||
/// Sprint: SPRINT_3700_0001_0001 (WIT-008)
|
||||
/// </summary>
|
||||
public sealed record AnalyzerWitnessRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The SBOM digest for artifact context.
|
||||
/// </summary>
|
||||
public required string SbomDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL of the vulnerable component.
|
||||
/// </summary>
|
||||
public required string ComponentPurl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability ID (e.g., "CVE-2024-12345").
|
||||
/// </summary>
|
||||
public required string VulnId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability source (e.g., "NVD").
|
||||
/// </summary>
|
||||
public required string VulnSource { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Affected version range.
|
||||
/// </summary>
|
||||
public required string AffectedRange { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sink taxonomy type for all sinks in the paths.
|
||||
/// </summary>
|
||||
public required string SinkType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Graph digest from the analyzer result.
|
||||
/// </summary>
|
||||
public required string GraphDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Pre-computed paths from ReachabilityAnalyzer.
|
||||
/// Each path contains (EntrypointId, SinkId, NodeIds ordered from entrypoint to sink).
|
||||
/// </summary>
|
||||
public required IReadOnlyList<AnalyzerPathData> Paths { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Node metadata lookup for resolving node details.
|
||||
/// Key is node ID, value contains name, file, line info.
|
||||
/// </summary>
|
||||
public required IReadOnlyDictionary<string, AnalyzerNodeData> NodeMetadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional attack surface digest.
|
||||
/// </summary>
|
||||
public string? SurfaceDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional analysis config digest.
|
||||
/// </summary>
|
||||
public string? AnalysisConfigDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional build ID.
|
||||
/// </summary>
|
||||
public string? BuildId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight representation of a reachability path from the analyzer.
|
||||
/// Sprint: SPRINT_3700_0001_0001 (WIT-008)
|
||||
/// </summary>
|
||||
public sealed record AnalyzerPathData(
|
||||
string EntrypointId,
|
||||
string SinkId,
|
||||
ImmutableArray<string> NodeIds);
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight node metadata for witness generation.
|
||||
/// Sprint: SPRINT_3700_0001_0001 (WIT-008)
|
||||
/// </summary>
|
||||
public sealed record AnalyzerNodeData(
|
||||
string Name,
|
||||
string? FilePath,
|
||||
int? Line,
|
||||
string? EntrypointKind);
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using StellaOps.Attestor.Envelope;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Witnesses;
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating and verifying DSSE-signed path witness envelopes.
|
||||
/// Sprint: SPRINT_3700_0001_0001 (WIT-007D)
|
||||
/// </summary>
|
||||
public interface IWitnessDsseSigner
|
||||
{
|
||||
/// <summary>
|
||||
/// Signs a path witness and creates a DSSE envelope.
|
||||
/// </summary>
|
||||
/// <param name="witness">The path witness to sign.</param>
|
||||
/// <param name="signingKey">The key to use for signing (must have private material).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result containing the DSSE envelope or error.</returns>
|
||||
WitnessDsseResult SignWitness(PathWitness witness, EnvelopeKey signingKey, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a DSSE-signed witness envelope.
|
||||
/// </summary>
|
||||
/// <param name="envelope">The DSSE envelope containing the signed witness.</param>
|
||||
/// <param name="publicKey">The public key to verify against.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result containing the verified witness or error.</returns>
|
||||
WitnessVerifyResult VerifyWitness(DsseEnvelope envelope, EnvelopeKey publicKey, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -164,6 +164,111 @@ public sealed class PathWitnessBuilder : IPathWitnessBuilder
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>
|
||||
/// Creates path witnesses from pre-computed ReachabilityAnalyzer output.
|
||||
/// Sprint: SPRINT_3700_0001_0001 (WIT-008)
|
||||
/// </summary>
|
||||
public async IAsyncEnumerable<PathWitness> BuildFromAnalyzerAsync(
|
||||
AnalyzerWitnessRequest request,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (request.Paths.Count == 0)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var nodeMetadata = request.NodeMetadata;
|
||||
|
||||
foreach (var analyzerPath in request.Paths)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Convert analyzer NodeIds to PathSteps with metadata
|
||||
var pathSteps = new List<PathStep>();
|
||||
foreach (var nodeId in analyzerPath.NodeIds)
|
||||
{
|
||||
if (nodeMetadata.TryGetValue(nodeId, out var node))
|
||||
{
|
||||
pathSteps.Add(new PathStep
|
||||
{
|
||||
Symbol = node.Name,
|
||||
SymbolId = nodeId,
|
||||
File = node.FilePath,
|
||||
Line = node.Line
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
// Node not found, add with just the ID
|
||||
pathSteps.Add(new PathStep
|
||||
{
|
||||
Symbol = nodeId,
|
||||
SymbolId = nodeId,
|
||||
File = null,
|
||||
Line = null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get entrypoint metadata
|
||||
nodeMetadata.TryGetValue(analyzerPath.EntrypointId, out var entrypointNode);
|
||||
var entrypointKind = entrypointNode?.EntrypointKind ?? "unknown";
|
||||
var entrypointName = entrypointNode?.Name ?? analyzerPath.EntrypointId;
|
||||
|
||||
// Get sink metadata
|
||||
nodeMetadata.TryGetValue(analyzerPath.SinkId, out var sinkNode);
|
||||
var sinkSymbol = sinkNode?.Name ?? analyzerPath.SinkId;
|
||||
|
||||
// Build the witness
|
||||
var witness = new PathWitness
|
||||
{
|
||||
WitnessId = string.Empty, // Will be set after hashing
|
||||
Artifact = new WitnessArtifact
|
||||
{
|
||||
SbomDigest = request.SbomDigest,
|
||||
ComponentPurl = request.ComponentPurl
|
||||
},
|
||||
Vuln = new WitnessVuln
|
||||
{
|
||||
Id = request.VulnId,
|
||||
Source = request.VulnSource,
|
||||
AffectedRange = request.AffectedRange
|
||||
},
|
||||
Entrypoint = new WitnessEntrypoint
|
||||
{
|
||||
Kind = entrypointKind,
|
||||
Name = entrypointName,
|
||||
SymbolId = analyzerPath.EntrypointId
|
||||
},
|
||||
Path = pathSteps,
|
||||
Sink = new WitnessSink
|
||||
{
|
||||
Symbol = sinkSymbol,
|
||||
SymbolId = analyzerPath.SinkId,
|
||||
SinkType = request.SinkType
|
||||
},
|
||||
Gates = null, // Gate detection not applied for analyzer-based paths yet
|
||||
Evidence = new WitnessEvidence
|
||||
{
|
||||
CallgraphDigest = request.GraphDigest,
|
||||
SurfaceDigest = request.SurfaceDigest,
|
||||
AnalysisConfigDigest = request.AnalysisConfigDigest,
|
||||
BuildId = request.BuildId
|
||||
},
|
||||
ObservedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
// Compute witness ID from canonical content
|
||||
var witnessId = ComputeWitnessId(witness);
|
||||
witness = witness with { WitnessId = witnessId };
|
||||
|
||||
yield return witness;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the shortest path from source to target using BFS.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Witnesses;
|
||||
|
||||
/// <summary>
|
||||
/// Generates signed DSSE envelopes for path witnesses.
|
||||
/// Sprint: SPRINT_3700_0001_0001 (WIT-009)
|
||||
/// Combines PathWitnessBuilder with WitnessDsseSigner for end-to-end witness attestation.
|
||||
/// </summary>
|
||||
public sealed class SignedWitnessGenerator : ISignedWitnessGenerator
|
||||
{
|
||||
private readonly IPathWitnessBuilder _builder;
|
||||
private readonly IWitnessDsseSigner _signer;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new SignedWitnessGenerator.
|
||||
/// </summary>
|
||||
public SignedWitnessGenerator(IPathWitnessBuilder builder, IWitnessDsseSigner signer)
|
||||
{
|
||||
_builder = builder ?? throw new ArgumentNullException(nameof(builder));
|
||||
_signer = signer ?? throw new ArgumentNullException(nameof(signer));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SignedWitnessResult?> GenerateSignedWitnessAsync(
|
||||
PathWitnessRequest request,
|
||||
EnvelopeKey signingKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(signingKey);
|
||||
|
||||
// Build the witness
|
||||
var witness = await _builder.BuildAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (witness is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sign it
|
||||
var signResult = _signer.SignWitness(witness, signingKey, cancellationToken);
|
||||
if (!signResult.IsSuccess)
|
||||
{
|
||||
return new SignedWitnessResult
|
||||
{
|
||||
IsSuccess = false,
|
||||
Error = signResult.Error
|
||||
};
|
||||
}
|
||||
|
||||
return new SignedWitnessResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
Witness = witness,
|
||||
Envelope = signResult.Envelope,
|
||||
PayloadBytes = signResult.PayloadBytes
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<SignedWitnessResult> GenerateSignedWitnessesAsync(
|
||||
BatchWitnessRequest request,
|
||||
EnvelopeKey signingKey,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(signingKey);
|
||||
|
||||
await foreach (var witness in _builder.BuildAllAsync(request, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var signResult = _signer.SignWitness(witness, signingKey, cancellationToken);
|
||||
|
||||
yield return signResult.IsSuccess
|
||||
? new SignedWitnessResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
Witness = witness,
|
||||
Envelope = signResult.Envelope,
|
||||
PayloadBytes = signResult.PayloadBytes
|
||||
}
|
||||
: new SignedWitnessResult
|
||||
{
|
||||
IsSuccess = false,
|
||||
Error = signResult.Error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<SignedWitnessResult> GenerateSignedWitnessesFromAnalyzerAsync(
|
||||
AnalyzerWitnessRequest request,
|
||||
EnvelopeKey signingKey,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(signingKey);
|
||||
|
||||
await foreach (var witness in _builder.BuildFromAnalyzerAsync(request, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var signResult = _signer.SignWitness(witness, signingKey, cancellationToken);
|
||||
|
||||
yield return signResult.IsSuccess
|
||||
? new SignedWitnessResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
Witness = witness,
|
||||
Envelope = signResult.Envelope,
|
||||
PayloadBytes = signResult.PayloadBytes
|
||||
}
|
||||
: new SignedWitnessResult
|
||||
{
|
||||
IsSuccess = false,
|
||||
Error = signResult.Error
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for generating signed DSSE envelopes for path witnesses.
|
||||
/// </summary>
|
||||
public interface ISignedWitnessGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a signed witness from a single request.
|
||||
/// </summary>
|
||||
Task<SignedWitnessResult?> GenerateSignedWitnessAsync(
|
||||
PathWitnessRequest request,
|
||||
EnvelopeKey signingKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Generates signed witnesses from a batch request.
|
||||
/// </summary>
|
||||
IAsyncEnumerable<SignedWitnessResult> GenerateSignedWitnessesAsync(
|
||||
BatchWitnessRequest request,
|
||||
EnvelopeKey signingKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Generates signed witnesses from pre-computed analyzer paths.
|
||||
/// </summary>
|
||||
IAsyncEnumerable<SignedWitnessResult> GenerateSignedWitnessesFromAnalyzerAsync(
|
||||
AnalyzerWitnessRequest request,
|
||||
EnvelopeKey signingKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of generating a signed witness.
|
||||
/// </summary>
|
||||
public sealed record SignedWitnessResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the signing succeeded.
|
||||
/// </summary>
|
||||
public bool IsSuccess { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The generated witness (if successful).
|
||||
/// </summary>
|
||||
public PathWitness? Witness { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The DSSE envelope containing the signed witness (if successful).
|
||||
/// </summary>
|
||||
public DsseEnvelope? Envelope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The canonical JSON payload bytes (if successful).
|
||||
/// </summary>
|
||||
public byte[]? PayloadBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message (if failed).
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Witnesses;
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating and verifying DSSE-signed path witness envelopes.
|
||||
/// Sprint: SPRINT_3700_0001_0001 (WIT-007D)
|
||||
/// </summary>
|
||||
public sealed class WitnessDsseSigner : IWitnessDsseSigner
|
||||
{
|
||||
private readonly EnvelopeSignatureService _signatureService;
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new WitnessDsseSigner with the specified signature service.
|
||||
/// </summary>
|
||||
public WitnessDsseSigner(EnvelopeSignatureService signatureService)
|
||||
{
|
||||
_signatureService = signatureService ?? throw new ArgumentNullException(nameof(signatureService));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new WitnessDsseSigner with a default signature service.
|
||||
/// </summary>
|
||||
public WitnessDsseSigner() : this(new EnvelopeSignatureService())
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public WitnessDsseResult SignWitness(PathWitness witness, EnvelopeKey signingKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(witness);
|
||||
ArgumentNullException.ThrowIfNull(signingKey);
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
// Serialize witness to canonical JSON bytes
|
||||
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(witness, CanonicalJsonOptions);
|
||||
|
||||
// Build the PAE (Pre-Authentication Encoding) for DSSE
|
||||
var pae = BuildPae(WitnessSchema.DssePayloadType, payloadBytes);
|
||||
|
||||
// Sign the PAE
|
||||
var signResult = _signatureService.Sign(pae, signingKey, cancellationToken);
|
||||
if (!signResult.IsSuccess)
|
||||
{
|
||||
return WitnessDsseResult.Failure($"Signing failed: {signResult.Error?.Message}");
|
||||
}
|
||||
|
||||
var signature = signResult.Value;
|
||||
|
||||
// Create the DSSE envelope
|
||||
var dsseSignature = new DsseSignature(
|
||||
signature: Convert.ToBase64String(signature.Value.Span),
|
||||
keyId: signature.KeyId);
|
||||
|
||||
var envelope = new DsseEnvelope(
|
||||
payloadType: WitnessSchema.DssePayloadType,
|
||||
payload: payloadBytes,
|
||||
signatures: [dsseSignature]);
|
||||
|
||||
return WitnessDsseResult.Success(envelope, payloadBytes);
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidOperationException)
|
||||
{
|
||||
return WitnessDsseResult.Failure($"Failed to create DSSE envelope: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public WitnessVerifyResult VerifyWitness(DsseEnvelope envelope, EnvelopeKey publicKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
ArgumentNullException.ThrowIfNull(publicKey);
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
// Verify payload type
|
||||
if (!string.Equals(envelope.PayloadType, WitnessSchema.DssePayloadType, StringComparison.Ordinal))
|
||||
{
|
||||
return WitnessVerifyResult.Failure($"Invalid payload type: expected '{WitnessSchema.DssePayloadType}', got '{envelope.PayloadType}'");
|
||||
}
|
||||
|
||||
// Deserialize the witness from payload
|
||||
var witness = JsonSerializer.Deserialize<PathWitness>(envelope.Payload.Span, CanonicalJsonOptions);
|
||||
if (witness is null)
|
||||
{
|
||||
return WitnessVerifyResult.Failure("Failed to deserialize witness from payload");
|
||||
}
|
||||
|
||||
// Verify schema version
|
||||
if (!string.Equals(witness.WitnessSchema, WitnessSchema.Version, StringComparison.Ordinal))
|
||||
{
|
||||
return WitnessVerifyResult.Failure($"Unsupported witness schema: {witness.WitnessSchema}");
|
||||
}
|
||||
|
||||
// Find signature matching the public key
|
||||
var matchingSignature = envelope.Signatures.FirstOrDefault(
|
||||
s => string.Equals(s.KeyId, publicKey.KeyId, StringComparison.Ordinal));
|
||||
|
||||
if (matchingSignature is null)
|
||||
{
|
||||
return WitnessVerifyResult.Failure($"No signature found for key ID: {publicKey.KeyId}");
|
||||
}
|
||||
|
||||
// Build PAE and verify signature
|
||||
var pae = BuildPae(envelope.PayloadType, envelope.Payload.ToArray());
|
||||
var signatureBytes = Convert.FromBase64String(matchingSignature.Signature);
|
||||
var envelopeSignature = new EnvelopeSignature(publicKey.KeyId, publicKey.AlgorithmId, signatureBytes);
|
||||
|
||||
var verifyResult = _signatureService.Verify(pae, envelopeSignature, publicKey, cancellationToken);
|
||||
if (!verifyResult.IsSuccess)
|
||||
{
|
||||
return WitnessVerifyResult.Failure($"Signature verification failed: {verifyResult.Error?.Message}");
|
||||
}
|
||||
|
||||
return WitnessVerifyResult.Success(witness, matchingSignature.KeyId);
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or FormatException or InvalidOperationException)
|
||||
{
|
||||
return WitnessVerifyResult.Failure($"Verification failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the DSSE Pre-Authentication Encoding (PAE) for a payload.
|
||||
/// PAE = "DSSEv1" SP len(type) SP type SP len(payload) SP payload
|
||||
/// </summary>
|
||||
private static byte[] BuildPae(string payloadType, byte[] payload)
|
||||
{
|
||||
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true);
|
||||
|
||||
// Write "DSSEv1 "
|
||||
writer.Write(Encoding.UTF8.GetBytes("DSSEv1 "));
|
||||
|
||||
// Write len(type) as little-endian 8-byte integer followed by space
|
||||
WriteLengthAndSpace(writer, typeBytes.Length);
|
||||
|
||||
// Write type followed by space
|
||||
writer.Write(typeBytes);
|
||||
writer.Write((byte)' ');
|
||||
|
||||
// Write len(payload) as little-endian 8-byte integer followed by space
|
||||
WriteLengthAndSpace(writer, payload.Length);
|
||||
|
||||
// Write payload
|
||||
writer.Write(payload);
|
||||
|
||||
writer.Flush();
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteLengthAndSpace(BinaryWriter writer, int length)
|
||||
{
|
||||
// Write length as ASCII decimal string
|
||||
writer.Write(Encoding.UTF8.GetBytes(length.ToString()));
|
||||
writer.Write((byte)' ');
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of DSSE signing a witness.
|
||||
/// </summary>
|
||||
public sealed record WitnessDsseResult
|
||||
{
|
||||
public bool IsSuccess { get; init; }
|
||||
public DsseEnvelope? Envelope { get; init; }
|
||||
public byte[]? PayloadBytes { get; init; }
|
||||
public string? Error { get; init; }
|
||||
|
||||
public static WitnessDsseResult Success(DsseEnvelope envelope, byte[] payloadBytes)
|
||||
=> new() { IsSuccess = true, Envelope = envelope, PayloadBytes = payloadBytes };
|
||||
|
||||
public static WitnessDsseResult Failure(string error)
|
||||
=> new() { IsSuccess = false, Error = error };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of verifying a DSSE-signed witness.
|
||||
/// </summary>
|
||||
public sealed record WitnessVerifyResult
|
||||
{
|
||||
public bool IsSuccess { get; init; }
|
||||
public PathWitness? Witness { get; init; }
|
||||
public string? VerifiedKeyId { get; init; }
|
||||
public string? Error { get; init; }
|
||||
|
||||
public static WitnessVerifyResult Success(PathWitness witness, string keyId)
|
||||
=> new() { IsSuccess = true, Witness = witness, VerifiedKeyId = keyId };
|
||||
|
||||
public static WitnessVerifyResult Failure(string error)
|
||||
=> new() { IsSuccess = false, Error = error };
|
||||
}
|
||||
@@ -2,6 +2,7 @@ namespace StellaOps.Scanner.Reachability.Witnesses;
|
||||
|
||||
/// <summary>
|
||||
/// Constants for the stellaops.witness.v1 schema.
|
||||
/// Sprint: SPRINT_3700_0001_0001 (WIT-007C)
|
||||
/// </summary>
|
||||
public static class WitnessSchema
|
||||
{
|
||||
@@ -16,7 +17,29 @@ public static class WitnessSchema
|
||||
public const string WitnessIdPrefix = "wit:";
|
||||
|
||||
/// <summary>
|
||||
/// Default DSSE payload type for witnesses.
|
||||
/// Default DSSE payload type for path witnesses.
|
||||
/// Used when creating DSSE envelopes for path witness attestations.
|
||||
/// </summary>
|
||||
public const string DssePayloadType = "application/vnd.stellaops.witness.v1+json";
|
||||
|
||||
/// <summary>
|
||||
/// DSSE predicate type URI for path witnesses (in-toto style).
|
||||
/// Matches PredicateTypes.StellaOpsPathWitness in Signer.Core.
|
||||
/// </summary>
|
||||
public const string PredicateType = "stella.ops/pathWitness@v1";
|
||||
|
||||
/// <summary>
|
||||
/// Witness type for reachability path witnesses.
|
||||
/// </summary>
|
||||
public const string WitnessTypeReachabilityPath = "reachability_path";
|
||||
|
||||
/// <summary>
|
||||
/// Witness type for gate proof witnesses.
|
||||
/// </summary>
|
||||
public const string WitnessTypeGateProof = "gate_proof";
|
||||
|
||||
/// <summary>
|
||||
/// JSON schema URI for witness validation.
|
||||
/// </summary>
|
||||
public const string JsonSchemaUri = "https://stellaops.org/schemas/witness-v1.json";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DriftAttestationOptions.cs
|
||||
// Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
|
||||
// Task: UI-016
|
||||
// Description: Configuration options for drift attestation service.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Scanner.ReachabilityDrift.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for drift attestation.
|
||||
/// </summary>
|
||||
public sealed class DriftAttestationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "DriftAttestation";
|
||||
|
||||
/// <summary>
|
||||
/// Whether attestation creation is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to use the remote signer service.
|
||||
/// </summary>
|
||||
public bool UseSignerService { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Default key ID for signing if not specified in request.
|
||||
/// </summary>
|
||||
public string? DefaultKeyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to submit attestations to Rekor by default.
|
||||
/// </summary>
|
||||
public bool SubmitToRekorByDefault { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Sink ruleset identifier for analysis metadata.
|
||||
/// </summary>
|
||||
public string? SinkRuleset { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Signer service endpoint URL.
|
||||
/// </summary>
|
||||
public string? SignerServiceUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for signer service calls in seconds.
|
||||
/// </summary>
|
||||
public int SignerTimeoutSeconds { get; set; } = 30;
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DriftAttestationService.cs
|
||||
// Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
|
||||
// Task: UI-016
|
||||
// Description: Service for creating signed reachability drift attestations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.ProofChain.Predicates;
|
||||
using StellaOps.Signer.Core;
|
||||
|
||||
namespace StellaOps.Scanner.ReachabilityDrift.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IDriftAttestationService"/>.
|
||||
/// Creates stellaops.dev/predicates/reachability-drift@v1 attestations wrapped in DSSE envelopes.
|
||||
/// </summary>
|
||||
public sealed class DriftAttestationService : IDriftAttestationService
|
||||
{
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly IDriftSignerClient? _signerClient;
|
||||
private readonly IOptionsMonitor<DriftAttestationOptions> _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<DriftAttestationService> _logger;
|
||||
|
||||
public DriftAttestationService(
|
||||
IDriftSignerClient? signerClient,
|
||||
IOptionsMonitor<DriftAttestationOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<DriftAttestationService> logger)
|
||||
{
|
||||
_signerClient = signerClient;
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<DriftAttestationResult> CreateAttestationAsync(
|
||||
DriftAttestationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
using var activity = Activity.Current?.Source.StartActivity(
|
||||
"reachability_drift.attest",
|
||||
ActivityKind.Internal);
|
||||
activity?.SetTag("tenant", request.TenantId);
|
||||
activity?.SetTag("base_scan", request.DriftResult.BaseScanId);
|
||||
activity?.SetTag("head_scan", request.DriftResult.HeadScanId);
|
||||
|
||||
var options = _options.CurrentValue;
|
||||
|
||||
if (!options.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Drift attestation is disabled");
|
||||
return new DriftAttestationResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Attestation creation is disabled"
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Build the predicate
|
||||
var predicate = BuildPredicate(request);
|
||||
|
||||
// Build the in-toto statement
|
||||
var statement = BuildStatement(request, predicate);
|
||||
var statementJson = SerializeCanonical(statement);
|
||||
var payloadBase64 = Convert.ToBase64String(statementJson);
|
||||
|
||||
// Sign the payload
|
||||
DriftDsseSignature signature;
|
||||
string? keyId;
|
||||
|
||||
if (_signerClient is not null && options.UseSignerService)
|
||||
{
|
||||
var signResult = await _signerClient.SignAsync(
|
||||
new DriftSignerRequest
|
||||
{
|
||||
PayloadType = ReachabilityDriftPredicate.PredicateType,
|
||||
PayloadBase64 = payloadBase64,
|
||||
KeyId = request.KeyId ?? options.DefaultKeyId,
|
||||
TenantId = request.TenantId
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!signResult.Success)
|
||||
{
|
||||
_logger.LogWarning("Failed to sign drift attestation: {Error}", signResult.Error);
|
||||
return new DriftAttestationResult
|
||||
{
|
||||
Success = false,
|
||||
Error = signResult.Error ?? "Signing failed"
|
||||
};
|
||||
}
|
||||
|
||||
keyId = signResult.KeyId;
|
||||
signature = new DriftDsseSignature
|
||||
{
|
||||
KeyId = signResult.KeyId ?? "unknown",
|
||||
Sig = signResult.Signature!
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
// Create locally-signed envelope (dev/test mode)
|
||||
keyId = "local-dev-key";
|
||||
signature = SignLocally(statementJson);
|
||||
_logger.LogDebug("Created locally-signed attestation (signer service not available)");
|
||||
}
|
||||
|
||||
var envelope = new DriftDsseEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = payloadBase64,
|
||||
Signatures = [signature]
|
||||
};
|
||||
|
||||
var envelopeJson = JsonSerializer.Serialize(envelope, CanonicalJsonOptions);
|
||||
var envelopeDigestHex = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(envelopeJson))).ToLowerInvariant();
|
||||
var attestationDigest = $"sha256:{envelopeDigestHex}";
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created drift attestation for scans {BaseScan} → {HeadScan}. " +
|
||||
"Newly reachable: {NewlyReachable}, Newly unreachable: {NewlyUnreachable}. Digest: {Digest}",
|
||||
request.DriftResult.BaseScanId,
|
||||
request.DriftResult.HeadScanId,
|
||||
request.DriftResult.NewlyReachable.Length,
|
||||
request.DriftResult.NewlyUnreachable.Length,
|
||||
attestationDigest);
|
||||
|
||||
return new DriftAttestationResult
|
||||
{
|
||||
Success = true,
|
||||
AttestationDigest = attestationDigest,
|
||||
EnvelopeJson = envelopeJson,
|
||||
KeyId = keyId,
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create drift attestation");
|
||||
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
|
||||
return new DriftAttestationResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private ReachabilityDriftPredicate BuildPredicate(DriftAttestationRequest request)
|
||||
{
|
||||
var drift = request.DriftResult;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
return new ReachabilityDriftPredicate
|
||||
{
|
||||
BaseImage = new DriftImageReference
|
||||
{
|
||||
Name = request.BaseImage.Name,
|
||||
Digest = request.BaseImage.Digest,
|
||||
Tag = request.BaseImage.Tag
|
||||
},
|
||||
TargetImage = new DriftImageReference
|
||||
{
|
||||
Name = request.TargetImage.Name,
|
||||
Digest = request.TargetImage.Digest,
|
||||
Tag = request.TargetImage.Tag
|
||||
},
|
||||
BaseScanId = drift.BaseScanId,
|
||||
HeadScanId = drift.HeadScanId,
|
||||
Drift = new DriftPredicateSummary
|
||||
{
|
||||
NewlyReachableCount = drift.NewlyReachable.Length,
|
||||
NewlyUnreachableCount = drift.NewlyUnreachable.Length,
|
||||
NewlyReachable = drift.NewlyReachable
|
||||
.Select(s => MapSinkToSummary(s))
|
||||
.ToImmutableArray(),
|
||||
NewlyUnreachable = drift.NewlyUnreachable
|
||||
.Select(s => MapSinkToSummary(s))
|
||||
.ToImmutableArray()
|
||||
},
|
||||
Analysis = new DriftAnalysisMetadata
|
||||
{
|
||||
AnalyzedAt = now,
|
||||
Scanner = new DriftScannerInfo
|
||||
{
|
||||
Name = "StellaOps.Scanner",
|
||||
Version = GetScannerVersion(),
|
||||
Ruleset = _options.CurrentValue.SinkRuleset
|
||||
},
|
||||
BaseGraphDigest = request.BaseGraphDigest,
|
||||
HeadGraphDigest = request.HeadGraphDigest,
|
||||
CodeChangesDigest = request.CodeChangesDigest
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static DriftedSinkPredicateSummary MapSinkToSummary(DriftedSink sink)
|
||||
{
|
||||
return new DriftedSinkPredicateSummary
|
||||
{
|
||||
SinkNodeId = sink.SinkNodeId,
|
||||
Symbol = sink.Symbol,
|
||||
SinkCategory = sink.SinkCategory.ToString(),
|
||||
CauseKind = sink.Cause.Kind.ToString(),
|
||||
CauseDescription = sink.Cause.Description,
|
||||
AssociatedCves = sink.AssociatedVulns
|
||||
.Select(v => v.CveId)
|
||||
.Where(cve => !string.IsNullOrEmpty(cve))
|
||||
.ToImmutableArray()!,
|
||||
PathHash = ComputePathHash(sink.Path)
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputePathHash(CompressedPath path)
|
||||
{
|
||||
// Create a deterministic representation of the path
|
||||
var pathData = new StringBuilder();
|
||||
pathData.Append(path.Entrypoint.NodeId);
|
||||
pathData.Append(':');
|
||||
foreach (var node in path.KeyNodes)
|
||||
{
|
||||
pathData.Append(node.NodeId);
|
||||
pathData.Append(':');
|
||||
}
|
||||
pathData.Append(path.Sink.NodeId);
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(pathData.ToString()));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant()[..16]; // First 64 bits
|
||||
}
|
||||
|
||||
private DriftInTotoStatement BuildStatement(
|
||||
DriftAttestationRequest request,
|
||||
ReachabilityDriftPredicate predicate)
|
||||
{
|
||||
return new DriftInTotoStatement
|
||||
{
|
||||
Type = "https://in-toto.io/Statement/v1",
|
||||
Subject =
|
||||
[
|
||||
new DriftSubject
|
||||
{
|
||||
Name = request.TargetImage.Name,
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = request.TargetImage.Digest.Replace("sha256:", "")
|
||||
}
|
||||
}
|
||||
],
|
||||
PredicateType = ReachabilityDriftPredicate.PredicateType,
|
||||
Predicate = predicate
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] SerializeCanonical<T>(T value)
|
||||
{
|
||||
return JsonSerializer.SerializeToUtf8Bytes(value, CanonicalJsonOptions);
|
||||
}
|
||||
|
||||
private static DriftDsseSignature SignLocally(byte[] payload)
|
||||
{
|
||||
// Local/dev signing: create a placeholder signature
|
||||
// In production, this would use a real key
|
||||
var paeString = $"DSSEv1 {payload.Length} application/vnd.in-toto+json {payload.Length} ";
|
||||
var paeBytes = Encoding.UTF8.GetBytes(paeString).Concat(payload).ToArray();
|
||||
var hash = SHA256.HashData(paeBytes);
|
||||
|
||||
return new DriftDsseSignature
|
||||
{
|
||||
KeyId = "local-dev-key",
|
||||
Sig = Convert.ToBase64String(hash)
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetScannerVersion()
|
||||
{
|
||||
var assembly = typeof(DriftAttestationService).Assembly;
|
||||
var version = assembly.GetName().Version;
|
||||
return version?.ToString() ?? "0.0.0";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement for drift attestation.
|
||||
/// </summary>
|
||||
internal sealed record DriftInTotoStatement
|
||||
{
|
||||
[JsonPropertyName("_type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public required IReadOnlyList<DriftSubject> Subject { get; init; }
|
||||
|
||||
[JsonPropertyName("predicateType")]
|
||||
public required string PredicateType { get; init; }
|
||||
|
||||
[JsonPropertyName("predicate")]
|
||||
public required ReachabilityDriftPredicate Predicate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject in an in-toto statement.
|
||||
/// </summary>
|
||||
internal sealed record DriftSubject
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public required IReadOnlyDictionary<string, string> Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope for drift attestation.
|
||||
/// </summary>
|
||||
internal sealed record DriftDsseEnvelope
|
||||
{
|
||||
[JsonPropertyName("payloadType")]
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
public required string Payload { get; init; }
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public required IReadOnlyList<DriftDsseSignature> Signatures { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature in a DSSE envelope.
|
||||
/// </summary>
|
||||
internal sealed record DriftDsseSignature
|
||||
{
|
||||
[JsonPropertyName("keyid")]
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("sig")]
|
||||
public required string Sig { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DriftAttestationServiceCollectionExtensions.cs
|
||||
// Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
|
||||
// Task: UI-017
|
||||
// Description: Service collection extensions for drift attestation.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace StellaOps.Scanner.ReachabilityDrift.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering drift attestation services.
|
||||
/// </summary>
|
||||
public static class DriftAttestationServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds drift attestation services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">The configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddDriftAttestation(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
// Bind options
|
||||
services.Configure<DriftAttestationOptions>(
|
||||
configuration.GetSection(DriftAttestationOptions.SectionName));
|
||||
|
||||
// Register the attestation service
|
||||
services.TryAddSingleton<IDriftAttestationService, DriftAttestationService>();
|
||||
|
||||
// Register TimeProvider if not already registered
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a custom drift signer client implementation.
|
||||
/// </summary>
|
||||
/// <typeparam name="TClient">The signer client implementation type.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddDriftSignerClient<TClient>(
|
||||
this IServiceCollection services)
|
||||
where TClient : class, IDriftSignerClient
|
||||
{
|
||||
services.TryAddSingleton<IDriftSignerClient, TClient>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IDriftAttestationService.cs
|
||||
// Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
|
||||
// Task: UI-016
|
||||
// Description: Interface for creating signed reachability drift attestations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.ReachabilityDrift.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating signed DSSE attestations for reachability drift results.
|
||||
/// </summary>
|
||||
public interface IDriftAttestationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a signed attestation for a drift result.
|
||||
/// </summary>
|
||||
/// <param name="request">The attestation request containing drift data and signing options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The attestation result including the signed envelope and digest.</returns>
|
||||
Task<DriftAttestationResult> CreateAttestationAsync(
|
||||
DriftAttestationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a drift attestation.
|
||||
/// </summary>
|
||||
public sealed record DriftAttestationRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The tenant ID for key selection.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The drift result to attest.
|
||||
/// </summary>
|
||||
public required ReachabilityDriftResult DriftResult { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the base image.
|
||||
/// </summary>
|
||||
public required ImageRef BaseImage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the target (head) image.
|
||||
/// </summary>
|
||||
public required ImageRef TargetImage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed digest of the base call graph.
|
||||
/// </summary>
|
||||
public required string BaseGraphDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed digest of the head call graph.
|
||||
/// </summary>
|
||||
public required string HeadGraphDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional: digest of the code change facts used.
|
||||
/// </summary>
|
||||
public string? CodeChangesDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional key ID for signing. If not provided, uses default.
|
||||
/// </summary>
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to submit to transparency log.
|
||||
/// </summary>
|
||||
public bool SubmitToRekor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Image reference for drift attestation.
|
||||
/// </summary>
|
||||
public sealed record ImageRef
|
||||
{
|
||||
/// <summary>
|
||||
/// Image name (repository/image).
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Image digest (sha256:...).
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional tag at time of analysis.
|
||||
/// </summary>
|
||||
public string? Tag { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of drift attestation creation.
|
||||
/// </summary>
|
||||
public sealed record DriftAttestationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the attestation was created successfully.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed digest of the attestation envelope.
|
||||
/// </summary>
|
||||
public string? AttestationDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The signed DSSE envelope (JSON).
|
||||
/// </summary>
|
||||
public string? EnvelopeJson { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID used for signing.
|
||||
/// </summary>
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if creation failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log entry index if submitted.
|
||||
/// </summary>
|
||||
public long? RekorLogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the attestation was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset? CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IDriftSignerClient.cs
|
||||
// Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
|
||||
// Task: UI-016
|
||||
// Description: Client interface for signing drift attestations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.ReachabilityDrift.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Client for signing drift attestations via the Signer service.
|
||||
/// </summary>
|
||||
public interface IDriftSignerClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Signs the given payload.
|
||||
/// </summary>
|
||||
/// <param name="request">The signing request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The signing result.</returns>
|
||||
Task<DriftSignerResult> SignAsync(
|
||||
DriftSignerRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to sign a drift attestation payload.
|
||||
/// </summary>
|
||||
public sealed record DriftSignerRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The predicate type being signed.
|
||||
/// </summary>
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded payload to sign.
|
||||
/// </summary>
|
||||
public required string PayloadBase64 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID to use for signing.
|
||||
/// </summary>
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID for key selection.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result from signing a drift attestation.
|
||||
/// </summary>
|
||||
public sealed record DriftSignerResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether signing succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The signature (base64 encoded).
|
||||
/// </summary>
|
||||
public string? Signature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The key ID that was used.
|
||||
/// </summary>
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if signing failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
@@ -8,12 +8,16 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\StellaOps.Scanner.CallGraph\\StellaOps.Scanner.CallGraph.csproj" />
|
||||
<ProjectReference Include="..\\..\\..\\Attestor\\__Libraries\\StellaOps.Attestor.ProofChain\\StellaOps.Attestor.ProofChain.csproj" />
|
||||
<ProjectReference Include="..\\..\\..\\Signer\\StellaOps.Signer\\StellaOps.Signer.Core\\StellaOps.Signer.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
namespace StellaOps.Scanner.Storage.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Entity mapping to scanner.proof_bundle table.
|
||||
/// Stores cryptographic evidence chains for scan results.
|
||||
/// </summary>
|
||||
public sealed class ProofBundleRow
|
||||
{
|
||||
/// <summary>Reference to the parent scan.</summary>
|
||||
public Guid ScanId { get; set; }
|
||||
|
||||
/// <summary>Merkle root hash of all evidence.</summary>
|
||||
public string RootHash { get; set; } = default!;
|
||||
|
||||
/// <summary>Type of bundle: standard, extended, or minimal.</summary>
|
||||
public string BundleType { get; set; } = "standard";
|
||||
|
||||
/// <summary>Full DSSE-signed envelope as JSONB.</summary>
|
||||
public string? DsseEnvelope { get; set; }
|
||||
|
||||
/// <summary>Key ID used for signing.</summary>
|
||||
public string? SignatureKeyId { get; set; }
|
||||
|
||||
/// <summary>Signature algorithm (e.g., ed25519, rsa-pss-sha256).</summary>
|
||||
public string? SignatureAlgorithm { get; set; }
|
||||
|
||||
/// <summary>Bundle content (ZIP archive or raw data).</summary>
|
||||
public byte[]? BundleContent { get; set; }
|
||||
|
||||
/// <summary>SHA-256 hash of bundle_content.</summary>
|
||||
public string BundleHash { get; set; } = default!;
|
||||
|
||||
/// <summary>Hash of the proof ledger.</summary>
|
||||
public string? LedgerHash { get; set; }
|
||||
|
||||
/// <summary>Reference to the scan manifest hash.</summary>
|
||||
public string? ManifestHash { get; set; }
|
||||
|
||||
/// <summary>Hash of the SBOM in this bundle.</summary>
|
||||
public string? SbomHash { get; set; }
|
||||
|
||||
/// <summary>Hash of the VEX in this bundle.</summary>
|
||||
public string? VexHash { get; set; }
|
||||
|
||||
/// <summary>When this bundle was created.</summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>Optional expiration time for retention policies.</summary>
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace StellaOps.Scanner.Storage.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Entity mapping to scanner.scan_manifest table.
|
||||
/// Captures all inputs that affect a scan's results for reproducibility.
|
||||
/// </summary>
|
||||
public sealed class ScanManifestRow
|
||||
{
|
||||
/// <summary>Unique identifier for this manifest.</summary>
|
||||
public Guid ManifestId { get; set; }
|
||||
|
||||
/// <summary>Reference to the parent scan.</summary>
|
||||
public Guid ScanId { get; set; }
|
||||
|
||||
/// <summary>SHA-256 hash of the manifest content.</summary>
|
||||
public string ManifestHash { get; set; } = default!;
|
||||
|
||||
/// <summary>Hash of the input SBOM.</summary>
|
||||
public string SbomHash { get; set; } = default!;
|
||||
|
||||
/// <summary>Hash of the rules snapshot.</summary>
|
||||
public string RulesHash { get; set; } = default!;
|
||||
|
||||
/// <summary>Hash of the advisory feed snapshot.</summary>
|
||||
public string FeedHash { get; set; } = default!;
|
||||
|
||||
/// <summary>Hash of the scoring policy.</summary>
|
||||
public string PolicyHash { get; set; } = default!;
|
||||
|
||||
/// <summary>When the scan started.</summary>
|
||||
public DateTimeOffset ScanStartedAt { get; set; }
|
||||
|
||||
/// <summary>When the scan completed (null if still running).</summary>
|
||||
public DateTimeOffset? ScanCompletedAt { get; set; }
|
||||
|
||||
/// <summary>Full manifest content as JSONB.</summary>
|
||||
public string ManifestContent { get; set; } = default!;
|
||||
|
||||
/// <summary>Version of the scanner that created this manifest.</summary>
|
||||
public string ScannerVersion { get; set; } = default!;
|
||||
|
||||
/// <summary>When this row was created.</summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 015_vuln_surface_triggers_update.sql
|
||||
-- Sprint: SPRINT_3700_0003_0001_trigger_extraction
|
||||
-- Task: TRIG-010, TRIG-013
|
||||
-- Description: Add trigger_count column and trigger path storage.
|
||||
--
|
||||
-- Note: migrations are executed with the module schema as the active search_path.
|
||||
-- Keep objects unqualified so integration tests can run in isolated schemas.
|
||||
-- =============================================================================
|
||||
|
||||
-- =============================================================================
|
||||
-- ADD TRIGGER_COUNT TO VULN_SURFACES
|
||||
-- =============================================================================
|
||||
ALTER TABLE vuln_surfaces
|
||||
ADD COLUMN IF NOT EXISTS trigger_count INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN vuln_surfaces.trigger_count IS 'Count of public API trigger methods that can reach changed sinks';
|
||||
|
||||
-- =============================================================================
|
||||
-- VULN_SURFACE_TRIGGER_PATHS: Internal paths from trigger to sink
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS vuln_surface_trigger_paths (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
surface_id UUID NOT NULL REFERENCES vuln_surfaces(id) ON DELETE CASCADE,
|
||||
|
||||
-- Trigger method (public API entry point)
|
||||
trigger_method_key TEXT NOT NULL, -- FQN of public API method
|
||||
trigger_method_name TEXT NOT NULL, -- Simple name
|
||||
trigger_declaring_type TEXT NOT NULL, -- Declaring class/module
|
||||
|
||||
-- Sink method (changed vulnerability method)
|
||||
sink_method_key TEXT NOT NULL, -- FQN of sink method (references vuln_surface_sinks.method_key)
|
||||
|
||||
-- Path from trigger to sink
|
||||
path_length INTEGER NOT NULL, -- Number of hops
|
||||
path_methods TEXT[] NOT NULL, -- Ordered list of method keys in path
|
||||
|
||||
-- Metadata
|
||||
is_interface_trigger BOOLEAN NOT NULL DEFAULT false, -- Trigger is interface method
|
||||
is_virtual_trigger BOOLEAN NOT NULL DEFAULT false, -- Trigger is virtual/overridable
|
||||
computed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT uq_trigger_path_key UNIQUE (surface_id, trigger_method_key, sink_method_key)
|
||||
);
|
||||
|
||||
-- Indexes for common queries
|
||||
CREATE INDEX IF NOT EXISTS idx_vuln_surface_trigger_paths_surface ON vuln_surface_trigger_paths(surface_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_vuln_surface_trigger_paths_trigger ON vuln_surface_trigger_paths(trigger_method_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_vuln_surface_trigger_paths_sink ON vuln_surface_trigger_paths(sink_method_key);
|
||||
|
||||
COMMENT ON TABLE vuln_surface_trigger_paths IS 'Internal paths from public API trigger methods to vulnerability sink methods within a package';
|
||||
|
||||
-- =============================================================================
|
||||
-- FUNCTIONS
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION get_vuln_surface_triggers(
|
||||
p_surface_id UUID
|
||||
)
|
||||
RETURNS TABLE (
|
||||
trigger_method_key TEXT,
|
||||
trigger_method_name TEXT,
|
||||
trigger_declaring_type TEXT,
|
||||
sink_count BIGINT,
|
||||
shortest_path_length INTEGER,
|
||||
is_interface_trigger BOOLEAN
|
||||
) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
tp.trigger_method_key,
|
||||
tp.trigger_method_name,
|
||||
tp.trigger_declaring_type,
|
||||
COUNT(DISTINCT tp.sink_method_key)::BIGINT AS sink_count,
|
||||
MIN(tp.path_length) AS shortest_path_length,
|
||||
BOOL_OR(tp.is_interface_trigger) AS is_interface_trigger
|
||||
FROM vuln_surface_trigger_paths tp
|
||||
WHERE tp.surface_id = p_surface_id
|
||||
GROUP BY tp.trigger_method_key, tp.trigger_method_name, tp.trigger_declaring_type
|
||||
ORDER BY sink_count DESC, shortest_path_length;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql STABLE;
|
||||
|
||||
CREATE OR REPLACE FUNCTION get_trigger_path_to_sink(
|
||||
p_surface_id UUID,
|
||||
p_trigger_method_key TEXT,
|
||||
p_sink_method_key TEXT
|
||||
)
|
||||
RETURNS TABLE (
|
||||
path_length INTEGER,
|
||||
path_methods TEXT[]
|
||||
) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
tp.path_length,
|
||||
tp.path_methods
|
||||
FROM vuln_surface_trigger_paths tp
|
||||
WHERE tp.surface_id = p_surface_id
|
||||
AND tp.trigger_method_key = p_trigger_method_key
|
||||
AND tp.sink_method_key = p_sink_method_key;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql STABLE;
|
||||
@@ -0,0 +1,135 @@
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 016_reach_cache.sql
|
||||
-- Sprint: SPRINT_3700_0006_0001_incremental_cache (CACHE-001)
|
||||
-- Description: Schema for reachability result caching.
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
-- Reachability cache metadata per service
|
||||
CREATE TABLE IF NOT EXISTS reach_cache_entries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
service_id TEXT NOT NULL,
|
||||
graph_hash TEXT NOT NULL,
|
||||
sbom_hash TEXT,
|
||||
entry_point_count INTEGER NOT NULL DEFAULT 0,
|
||||
sink_count INTEGER NOT NULL DEFAULT 0,
|
||||
pair_count INTEGER NOT NULL DEFAULT 0,
|
||||
reachable_count INTEGER NOT NULL DEFAULT 0,
|
||||
unreachable_count INTEGER NOT NULL DEFAULT 0,
|
||||
cached_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_reach_cache_service_graph UNIQUE (service_id, graph_hash)
|
||||
);
|
||||
|
||||
-- Index for cache lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_reach_cache_service_id ON reach_cache_entries (service_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_reach_cache_expires ON reach_cache_entries (expires_at) WHERE expires_at IS NOT NULL;
|
||||
|
||||
-- Cached (entry, sink) pair results
|
||||
CREATE TABLE IF NOT EXISTS reach_cache_pairs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
cache_entry_id UUID NOT NULL REFERENCES reach_cache_entries(id) ON DELETE CASCADE,
|
||||
entry_method_key TEXT NOT NULL,
|
||||
sink_method_key TEXT NOT NULL,
|
||||
is_reachable BOOLEAN NOT NULL,
|
||||
path_length INTEGER,
|
||||
confidence DOUBLE PRECISION NOT NULL DEFAULT 1.0,
|
||||
computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_reach_pair UNIQUE (cache_entry_id, entry_method_key, sink_method_key)
|
||||
);
|
||||
|
||||
-- Index for pair lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_reach_cache_pairs_entry ON reach_cache_pairs (cache_entry_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_reach_cache_pairs_reachable ON reach_cache_pairs (cache_entry_id, is_reachable);
|
||||
|
||||
-- Graph snapshots for delta computation
|
||||
CREATE TABLE IF NOT EXISTS reach_graph_snapshots (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
service_id TEXT NOT NULL,
|
||||
graph_hash TEXT NOT NULL,
|
||||
node_count INTEGER NOT NULL DEFAULT 0,
|
||||
edge_count INTEGER NOT NULL DEFAULT 0,
|
||||
entry_point_count INTEGER NOT NULL DEFAULT 0,
|
||||
snapshot_data BYTEA, -- Compressed graph data
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_graph_snapshot UNIQUE (service_id, graph_hash)
|
||||
);
|
||||
|
||||
-- Cache statistics for monitoring
|
||||
CREATE TABLE IF NOT EXISTS reach_cache_stats (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
service_id TEXT NOT NULL UNIQUE,
|
||||
total_hits BIGINT NOT NULL DEFAULT 0,
|
||||
total_misses BIGINT NOT NULL DEFAULT 0,
|
||||
full_recomputes BIGINT NOT NULL DEFAULT 0,
|
||||
incremental_computes BIGINT NOT NULL DEFAULT 0,
|
||||
current_graph_hash TEXT,
|
||||
last_populated_at TIMESTAMPTZ,
|
||||
last_invalidated_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- State flip history for auditing
|
||||
CREATE TABLE IF NOT EXISTS reach_state_flips (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
service_id TEXT NOT NULL,
|
||||
scan_id UUID,
|
||||
entry_method_key TEXT NOT NULL,
|
||||
sink_method_key TEXT NOT NULL,
|
||||
flip_type TEXT NOT NULL CHECK (flip_type IN ('became_reachable', 'became_unreachable')),
|
||||
cve_id TEXT,
|
||||
package_name TEXT,
|
||||
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Index for flip queries
|
||||
CREATE INDEX IF NOT EXISTS idx_state_flips_service ON reach_state_flips (service_id, detected_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_state_flips_scan ON reach_state_flips (scan_id) WHERE scan_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_state_flips_type ON reach_state_flips (flip_type);
|
||||
|
||||
-- Function to clean up expired cache entries
|
||||
CREATE OR REPLACE FUNCTION cleanup_expired_reach_cache()
|
||||
RETURNS INTEGER AS $$
|
||||
DECLARE
|
||||
deleted_count INTEGER;
|
||||
BEGIN
|
||||
DELETE FROM reach_cache_entries
|
||||
WHERE expires_at < NOW();
|
||||
|
||||
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
||||
RETURN deleted_count;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Function to update cache statistics
|
||||
CREATE OR REPLACE FUNCTION update_reach_cache_stats(
|
||||
p_service_id TEXT,
|
||||
p_is_hit BOOLEAN,
|
||||
p_is_incremental BOOLEAN DEFAULT NULL,
|
||||
p_graph_hash TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS VOID AS $$
|
||||
BEGIN
|
||||
INSERT INTO reach_cache_stats (service_id, total_hits, total_misses, current_graph_hash)
|
||||
VALUES (p_service_id,
|
||||
CASE WHEN p_is_hit THEN 1 ELSE 0 END,
|
||||
CASE WHEN NOT p_is_hit THEN 1 ELSE 0 END,
|
||||
p_graph_hash)
|
||||
ON CONFLICT (service_id) DO UPDATE SET
|
||||
total_hits = reach_cache_stats.total_hits + CASE WHEN p_is_hit THEN 1 ELSE 0 END,
|
||||
total_misses = reach_cache_stats.total_misses + CASE WHEN NOT p_is_hit THEN 1 ELSE 0 END,
|
||||
full_recomputes = reach_cache_stats.full_recomputes +
|
||||
CASE WHEN p_is_incremental = FALSE THEN 1 ELSE 0 END,
|
||||
incremental_computes = reach_cache_stats.incremental_computes +
|
||||
CASE WHEN p_is_incremental = TRUE THEN 1 ELSE 0 END,
|
||||
current_graph_hash = COALESCE(p_graph_hash, reach_cache_stats.current_graph_hash),
|
||||
last_populated_at = CASE WHEN NOT p_is_hit THEN NOW() ELSE reach_cache_stats.last_populated_at END,
|
||||
updated_at = NOW();
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
COMMENT ON TABLE reach_cache_entries IS 'Cached reachability analysis results per service/graph';
|
||||
COMMENT ON TABLE reach_cache_pairs IS 'Individual (entry, sink) pair reachability results';
|
||||
COMMENT ON TABLE reach_graph_snapshots IS 'Graph snapshots for delta computation';
|
||||
COMMENT ON TABLE reach_cache_stats IS 'Cache performance statistics';
|
||||
COMMENT ON TABLE reach_state_flips IS 'History of reachability state changes';
|
||||
@@ -0,0 +1,142 @@
|
||||
using Dapper;
|
||||
using StellaOps.Scanner.Storage.Entities;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of proof bundle repository.
|
||||
/// </summary>
|
||||
public sealed class PostgresProofBundleRepository : IProofBundleRepository
|
||||
{
|
||||
private readonly ScannerDataSource _dataSource;
|
||||
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
|
||||
private string TableName => $"{SchemaName}.proof_bundle";
|
||||
|
||||
public PostgresProofBundleRepository(ScannerDataSource dataSource)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
}
|
||||
|
||||
public async Task<ProofBundleRow?> GetByRootHashAsync(string rootHash, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT
|
||||
scan_id AS ScanId,
|
||||
root_hash AS RootHash,
|
||||
bundle_type AS BundleType,
|
||||
dsse_envelope AS DsseEnvelope,
|
||||
signature_keyid AS SignatureKeyId,
|
||||
signature_algorithm AS SignatureAlgorithm,
|
||||
bundle_content AS BundleContent,
|
||||
bundle_hash AS BundleHash,
|
||||
ledger_hash AS LedgerHash,
|
||||
manifest_hash AS ManifestHash,
|
||||
sbom_hash AS SbomHash,
|
||||
vex_hash AS VexHash,
|
||||
created_at AS CreatedAt,
|
||||
expires_at AS ExpiresAt
|
||||
FROM {TableName}
|
||||
WHERE root_hash = @RootHash
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await connection.QuerySingleOrDefaultAsync<ProofBundleRow>(
|
||||
new CommandDefinition(sql, new { RootHash = rootHash }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ProofBundleRow>> GetByScanIdAsync(Guid scanId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT
|
||||
scan_id AS ScanId,
|
||||
root_hash AS RootHash,
|
||||
bundle_type AS BundleType,
|
||||
dsse_envelope AS DsseEnvelope,
|
||||
signature_keyid AS SignatureKeyId,
|
||||
signature_algorithm AS SignatureAlgorithm,
|
||||
bundle_content AS BundleContent,
|
||||
bundle_hash AS BundleHash,
|
||||
ledger_hash AS LedgerHash,
|
||||
manifest_hash AS ManifestHash,
|
||||
sbom_hash AS SbomHash,
|
||||
vex_hash AS VexHash,
|
||||
created_at AS CreatedAt,
|
||||
expires_at AS ExpiresAt
|
||||
FROM {TableName}
|
||||
WHERE scan_id = @ScanId
|
||||
ORDER BY created_at DESC
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
var results = await connection.QueryAsync<ProofBundleRow>(
|
||||
new CommandDefinition(sql, new { ScanId = scanId }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
return results.ToList();
|
||||
}
|
||||
|
||||
public async Task<ProofBundleRow> SaveAsync(ProofBundleRow bundle, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
|
||||
var sql = $"""
|
||||
INSERT INTO {TableName} (
|
||||
scan_id,
|
||||
root_hash,
|
||||
bundle_type,
|
||||
dsse_envelope,
|
||||
signature_keyid,
|
||||
signature_algorithm,
|
||||
bundle_content,
|
||||
bundle_hash,
|
||||
ledger_hash,
|
||||
manifest_hash,
|
||||
sbom_hash,
|
||||
vex_hash,
|
||||
expires_at
|
||||
) VALUES (
|
||||
@ScanId,
|
||||
@RootHash,
|
||||
@BundleType,
|
||||
@DsseEnvelope::jsonb,
|
||||
@SignatureKeyId,
|
||||
@SignatureAlgorithm,
|
||||
@BundleContent,
|
||||
@BundleHash,
|
||||
@LedgerHash,
|
||||
@ManifestHash,
|
||||
@SbomHash,
|
||||
@VexHash,
|
||||
@ExpiresAt
|
||||
)
|
||||
ON CONFLICT (scan_id, root_hash) DO UPDATE SET
|
||||
dsse_envelope = EXCLUDED.dsse_envelope,
|
||||
bundle_content = EXCLUDED.bundle_content,
|
||||
bundle_hash = EXCLUDED.bundle_hash,
|
||||
ledger_hash = EXCLUDED.ledger_hash
|
||||
RETURNING created_at AS CreatedAt
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
var createdAt = await connection.QuerySingleAsync<DateTimeOffset>(
|
||||
new CommandDefinition(sql, bundle, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
bundle.CreatedAt = createdAt;
|
||||
return bundle;
|
||||
}
|
||||
|
||||
public async Task<int> DeleteExpiredAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = $"""
|
||||
DELETE FROM {TableName}
|
||||
WHERE expires_at IS NOT NULL AND expires_at < NOW()
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await connection.ExecuteAsync(
|
||||
new CommandDefinition(sql, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using Dapper;
|
||||
using StellaOps.Scanner.Storage.Entities;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of scan manifest repository.
|
||||
/// </summary>
|
||||
public sealed class PostgresScanManifestRepository : IScanManifestRepository
|
||||
{
|
||||
private readonly ScannerDataSource _dataSource;
|
||||
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
|
||||
private string TableName => $"{SchemaName}.scan_manifest";
|
||||
|
||||
public PostgresScanManifestRepository(ScannerDataSource dataSource)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
}
|
||||
|
||||
public async Task<ScanManifestRow?> GetByHashAsync(string manifestHash, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT
|
||||
manifest_id AS ManifestId,
|
||||
scan_id AS ScanId,
|
||||
manifest_hash AS ManifestHash,
|
||||
sbom_hash AS SbomHash,
|
||||
rules_hash AS RulesHash,
|
||||
feed_hash AS FeedHash,
|
||||
policy_hash AS PolicyHash,
|
||||
scan_started_at AS ScanStartedAt,
|
||||
scan_completed_at AS ScanCompletedAt,
|
||||
manifest_content AS ManifestContent,
|
||||
scanner_version AS ScannerVersion,
|
||||
created_at AS CreatedAt
|
||||
FROM {TableName}
|
||||
WHERE manifest_hash = @ManifestHash
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await connection.QuerySingleOrDefaultAsync<ScanManifestRow>(
|
||||
new CommandDefinition(sql, new { ManifestHash = manifestHash }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<ScanManifestRow?> GetByScanIdAsync(Guid scanId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT
|
||||
manifest_id AS ManifestId,
|
||||
scan_id AS ScanId,
|
||||
manifest_hash AS ManifestHash,
|
||||
sbom_hash AS SbomHash,
|
||||
rules_hash AS RulesHash,
|
||||
feed_hash AS FeedHash,
|
||||
policy_hash AS PolicyHash,
|
||||
scan_started_at AS ScanStartedAt,
|
||||
scan_completed_at AS ScanCompletedAt,
|
||||
manifest_content AS ManifestContent,
|
||||
scanner_version AS ScannerVersion,
|
||||
created_at AS CreatedAt
|
||||
FROM {TableName}
|
||||
WHERE scan_id = @ScanId
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await connection.QuerySingleOrDefaultAsync<ScanManifestRow>(
|
||||
new CommandDefinition(sql, new { ScanId = scanId }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<ScanManifestRow> SaveAsync(ScanManifestRow manifest, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
var sql = $"""
|
||||
INSERT INTO {TableName} (
|
||||
scan_id,
|
||||
manifest_hash,
|
||||
sbom_hash,
|
||||
rules_hash,
|
||||
feed_hash,
|
||||
policy_hash,
|
||||
scan_started_at,
|
||||
scan_completed_at,
|
||||
manifest_content,
|
||||
scanner_version
|
||||
) VALUES (
|
||||
@ScanId,
|
||||
@ManifestHash,
|
||||
@SbomHash,
|
||||
@RulesHash,
|
||||
@FeedHash,
|
||||
@PolicyHash,
|
||||
@ScanStartedAt,
|
||||
@ScanCompletedAt,
|
||||
@ManifestContent::jsonb,
|
||||
@ScannerVersion
|
||||
)
|
||||
RETURNING manifest_id AS ManifestId, created_at AS CreatedAt
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await connection.QuerySingleAsync<(Guid ManifestId, DateTimeOffset CreatedAt)>(
|
||||
new CommandDefinition(sql, manifest, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
manifest.ManifestId = result.ManifestId;
|
||||
manifest.CreatedAt = result.CreatedAt;
|
||||
return manifest;
|
||||
}
|
||||
|
||||
public async Task MarkCompletedAsync(Guid manifestId, DateTimeOffset completedAt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = $"""
|
||||
UPDATE {TableName}
|
||||
SET scan_completed_at = @CompletedAt
|
||||
WHERE manifest_id = @ManifestId
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await connection.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { ManifestId = manifestId, CompletedAt = completedAt }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using StellaOps.Scanner.Storage.Entities;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for scan manifest operations.
|
||||
/// </summary>
|
||||
public interface IScanManifestRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a scan manifest by its hash.
|
||||
/// </summary>
|
||||
Task<ScanManifestRow?> GetByHashAsync(string manifestHash, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a scan manifest by scan ID.
|
||||
/// </summary>
|
||||
Task<ScanManifestRow?> GetByScanIdAsync(Guid scanId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Saves a new scan manifest.
|
||||
/// </summary>
|
||||
Task<ScanManifestRow> SaveAsync(ScanManifestRow manifest, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Marks a scan manifest as completed.
|
||||
/// </summary>
|
||||
Task MarkCompletedAsync(Guid manifestId, DateTimeOffset completedAt, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for proof bundle operations.
|
||||
/// </summary>
|
||||
public interface IProofBundleRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a proof bundle by its root hash.
|
||||
/// </summary>
|
||||
Task<ProofBundleRow?> GetByRootHashAsync(string rootHash, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all proof bundles for a scan.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ProofBundleRow>> GetByScanIdAsync(Guid scanId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Saves a new proof bundle.
|
||||
/// </summary>
|
||||
Task<ProofBundleRow> SaveAsync(ProofBundleRow bundle, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes expired proof bundles.
|
||||
/// </summary>
|
||||
Task<int> DeleteExpiredAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -7,14 +7,14 @@
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AWSSDK.S3" Version="3.7.305.6" />
|
||||
<PackageReference Include="AWSSDK.S3" Version="4.0.6" />
|
||||
<PackageReference Include="Dapper" Version="2.1.35" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
|
||||
<PackageReference Include="Npgsql" Version="9.0.2" />
|
||||
<PackageReference Include="Npgsql" Version="9.0.3" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Postgres\Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VulnSurfaceIntegrationTests.cs
|
||||
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
|
||||
// Task: SURF-023
|
||||
// Description: Integration tests with real CVE data (Newtonsoft.Json).
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.VulnSurfaces.Builder;
|
||||
using StellaOps.Scanner.VulnSurfaces.CallGraph;
|
||||
using StellaOps.Scanner.VulnSurfaces.Download;
|
||||
using StellaOps.Scanner.VulnSurfaces.Fingerprint;
|
||||
using StellaOps.Scanner.VulnSurfaces.Triggers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for VulnSurfaceBuilder using real packages.
|
||||
/// These tests require network access and may be slow.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Category", "SlowTests")]
|
||||
public sealed class VulnSurfaceIntegrationTests : IDisposable
|
||||
{
|
||||
private readonly string _workDir;
|
||||
|
||||
public VulnSurfaceIntegrationTests()
|
||||
{
|
||||
_workDir = Path.Combine(Path.GetTempPath(), "vuln-surface-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_workDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_workDir))
|
||||
{
|
||||
Directory.Delete(_workDir, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests vulnerability surface extraction for Newtonsoft.Json CVE-2024-21907.
|
||||
/// This CVE relates to type confusion in TypeNameHandling.
|
||||
/// Vuln: 13.0.1, Fixed: 13.0.3
|
||||
/// </summary>
|
||||
[Fact(Skip = "Requires network access and ~30s runtime")]
|
||||
public async Task BuildAsync_NewtonsoftJson_CVE_2024_21907_DetectsSinks()
|
||||
{
|
||||
// Arrange
|
||||
var builder = CreateBuilder();
|
||||
var request = new VulnSurfaceBuildRequest
|
||||
{
|
||||
CveId = "CVE-2024-21907",
|
||||
PackageName = "Newtonsoft.Json",
|
||||
Ecosystem = "nuget",
|
||||
VulnVersion = "13.0.1",
|
||||
FixedVersion = "13.0.3",
|
||||
WorkingDirectory = _workDir,
|
||||
ExtractTriggers = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await builder.BuildAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success, result.Error ?? "Build should succeed");
|
||||
Assert.NotNull(result.Surface);
|
||||
Assert.Equal("CVE-2024-21907", result.Surface.CveId);
|
||||
Assert.Equal("nuget", result.Surface.Ecosystem);
|
||||
|
||||
// Should detect changed methods in the security fix
|
||||
Assert.NotEmpty(result.Surface.Sinks);
|
||||
|
||||
// Log for visibility
|
||||
foreach (var sink in result.Surface.Sinks)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Sink: {sink.MethodKey} ({sink.ChangeType})");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests building a surface for a small well-known package.
|
||||
/// Uses Humanizer.Core which is small and has version differences.
|
||||
/// </summary>
|
||||
[Fact(Skip = "Requires network access and ~15s runtime")]
|
||||
public async Task BuildAsync_HumanizerCore_DetectsMethodChanges()
|
||||
{
|
||||
// Arrange
|
||||
var builder = CreateBuilder();
|
||||
var request = new VulnSurfaceBuildRequest
|
||||
{
|
||||
CveId = "TEST-0001",
|
||||
PackageName = "Humanizer.Core",
|
||||
Ecosystem = "nuget",
|
||||
VulnVersion = "2.14.0",
|
||||
FixedVersion = "2.14.1",
|
||||
WorkingDirectory = _workDir,
|
||||
ExtractTriggers = false // Skip trigger extraction for speed
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await builder.BuildAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success, result.Error ?? "Build should succeed");
|
||||
Assert.NotNull(result.Surface);
|
||||
// Even if no sinks are found, the surface should be created successfully
|
||||
Assert.NotNull(result.Surface.Sinks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that invalid package name returns appropriate error.
|
||||
/// </summary>
|
||||
[Fact(Skip = "Requires network access")]
|
||||
public async Task BuildAsync_InvalidPackage_ReturnsFailed()
|
||||
{
|
||||
// Arrange
|
||||
var builder = CreateBuilder();
|
||||
var request = new VulnSurfaceBuildRequest
|
||||
{
|
||||
CveId = "TEST-INVALID",
|
||||
PackageName = "This.Package.Does.Not.Exist.12345",
|
||||
Ecosystem = "nuget",
|
||||
VulnVersion = "1.0.0",
|
||||
FixedVersion = "1.0.1",
|
||||
WorkingDirectory = _workDir,
|
||||
ExtractTriggers = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await builder.BuildAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.NotNull(result.Error);
|
||||
Assert.Contains("Failed to download", result.Error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that unsupported ecosystem returns error.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task BuildAsync_UnsupportedEcosystem_ReturnsFailed()
|
||||
{
|
||||
// Arrange
|
||||
var builder = CreateBuilder();
|
||||
var request = new VulnSurfaceBuildRequest
|
||||
{
|
||||
CveId = "TEST-UNSUPPORTED",
|
||||
PackageName = "some-package",
|
||||
Ecosystem = "cargo", // Not supported yet
|
||||
VulnVersion = "1.0.0",
|
||||
FixedVersion = "1.0.1",
|
||||
WorkingDirectory = _workDir,
|
||||
ExtractTriggers = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await builder.BuildAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("No downloader for ecosystem", result.Error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests surface building with trigger extraction.
|
||||
/// </summary>
|
||||
[Fact(Skip = "Requires network access and ~45s runtime")]
|
||||
public async Task BuildAsync_WithTriggers_ExtractsTriggerMethods()
|
||||
{
|
||||
// Arrange
|
||||
var builder = CreateBuilder();
|
||||
var request = new VulnSurfaceBuildRequest
|
||||
{
|
||||
CveId = "CVE-2024-21907",
|
||||
PackageName = "Newtonsoft.Json",
|
||||
Ecosystem = "nuget",
|
||||
VulnVersion = "13.0.1",
|
||||
FixedVersion = "13.0.3",
|
||||
WorkingDirectory = _workDir,
|
||||
ExtractTriggers = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await builder.BuildAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success, result.Error ?? "Build should succeed");
|
||||
Assert.NotNull(result.Surface);
|
||||
|
||||
// When trigger extraction is enabled, we should have trigger info
|
||||
// Note: TriggerCount may be 0 if no public API calls into the changed methods
|
||||
Assert.True(result.Surface.TriggerCount >= 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests deterministic output for the same inputs.
|
||||
/// </summary>
|
||||
[Fact(Skip = "Requires network access and ~60s runtime")]
|
||||
public async Task BuildAsync_SameInput_ProducesDeterministicOutput()
|
||||
{
|
||||
// Arrange
|
||||
var builder = CreateBuilder();
|
||||
var request = new VulnSurfaceBuildRequest
|
||||
{
|
||||
CveId = "CVE-2024-21907",
|
||||
PackageName = "Newtonsoft.Json",
|
||||
Ecosystem = "nuget",
|
||||
VulnVersion = "13.0.1",
|
||||
FixedVersion = "13.0.3",
|
||||
WorkingDirectory = Path.Combine(_workDir, "run1"),
|
||||
ExtractTriggers = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var result1 = await builder.BuildAsync(request);
|
||||
|
||||
// Reset for second run
|
||||
request = request with { WorkingDirectory = Path.Combine(_workDir, "run2") };
|
||||
var result2 = await builder.BuildAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result1.Success && result2.Success);
|
||||
Assert.NotNull(result1.Surface);
|
||||
Assert.NotNull(result2.Surface);
|
||||
|
||||
// Sink count should be identical
|
||||
Assert.Equal(result1.Surface.Sinks.Count, result2.Surface.Sinks.Count);
|
||||
|
||||
// Method keys should be identical
|
||||
var keys1 = result1.Surface.Sinks.Select(s => s.MethodKey).OrderBy(k => k).ToList();
|
||||
var keys2 = result2.Surface.Sinks.Select(s => s.MethodKey).OrderBy(k => k).ToList();
|
||||
Assert.Equal(keys1, keys2);
|
||||
}
|
||||
|
||||
private VulnSurfaceBuilder CreateBuilder()
|
||||
{
|
||||
var downloaders = new List<IPackageDownloader>
|
||||
{
|
||||
new NuGetPackageDownloader(
|
||||
new HttpClient(),
|
||||
NullLogger<NuGetPackageDownloader>.Instance,
|
||||
TimeProvider.System)
|
||||
};
|
||||
|
||||
var fingerprinters = new List<IMethodFingerprinter>
|
||||
{
|
||||
new CecilMethodFingerprinter(NullLogger<CecilMethodFingerprinter>.Instance)
|
||||
};
|
||||
|
||||
var diffEngine = new MethodDiffEngine(NullLogger<MethodDiffEngine>.Instance);
|
||||
|
||||
var triggerExtractor = new TriggerMethodExtractor(
|
||||
NullLogger<TriggerMethodExtractor>.Instance);
|
||||
|
||||
var graphBuilders = new List<IInternalCallGraphBuilder>
|
||||
{
|
||||
new CecilInternalCallGraphBuilder(NullLogger<CecilInternalCallGraphBuilder>.Instance)
|
||||
};
|
||||
|
||||
return new VulnSurfaceBuilder(
|
||||
downloaders,
|
||||
fingerprinters,
|
||||
diffEngine,
|
||||
triggerExtractor,
|
||||
graphBuilders,
|
||||
NullLogger<VulnSurfaceBuilder>.Instance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// JavaInternalGraphBuilder.cs
|
||||
// Sprint: SPRINT_3700_0003_0001_trigger_extraction (TRIG-004)
|
||||
// Description: Java internal call graph builder using bytecode analysis.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.VulnSurfaces.Models;
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.CallGraph;
|
||||
|
||||
/// <summary>
|
||||
/// Internal call graph builder for Java packages using bytecode analysis.
|
||||
/// Parses .class files from JAR archives.
|
||||
/// </summary>
|
||||
public sealed class JavaInternalGraphBuilder : IInternalCallGraphBuilder
|
||||
{
|
||||
private readonly ILogger<JavaInternalGraphBuilder> _logger;
|
||||
private const uint ClassFileMagic = 0xCAFEBABE;
|
||||
|
||||
public JavaInternalGraphBuilder(ILogger<JavaInternalGraphBuilder> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Ecosystem => "maven";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanHandle(string packagePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(packagePath))
|
||||
return false;
|
||||
|
||||
if (packagePath.EndsWith(".jar", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
if (Directory.Exists(packagePath))
|
||||
{
|
||||
return Directory.EnumerateFiles(packagePath, "*.class", SearchOption.AllDirectories).Any();
|
||||
}
|
||||
|
||||
return packagePath.EndsWith(".class", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<InternalCallGraphBuildResult> BuildAsync(
|
||||
InternalCallGraphBuildRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var graph = new InternalCallGraph
|
||||
{
|
||||
PackageId = request.PackageId,
|
||||
Version = request.Version
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var classFiles = GetClassFiles(request.PackagePath);
|
||||
var filesProcessed = 0;
|
||||
|
||||
// First pass: collect all classes and methods
|
||||
var packageClasses = new HashSet<string>(StringComparer.Ordinal);
|
||||
var allMethods = new Dictionary<string, MethodInfo>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var classPath in classFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = await File.ReadAllBytesAsync(classPath, cancellationToken);
|
||||
var classInfo = ParseClassFile(bytes);
|
||||
if (classInfo is not null)
|
||||
{
|
||||
packageClasses.Add(classInfo.ClassName);
|
||||
foreach (var method in classInfo.Methods)
|
||||
{
|
||||
var key = $"{classInfo.ClassName}::{method.Name}{method.Descriptor}";
|
||||
allMethods[key] = method with { DeclaringClass = classInfo.ClassName };
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to parse class file {Path}", classPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: analyze method bodies for internal calls
|
||||
foreach (var classPath in classFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = await File.ReadAllBytesAsync(classPath, cancellationToken);
|
||||
var classInfo = ParseClassFileWithCalls(bytes, packageClasses);
|
||||
if (classInfo is not null)
|
||||
{
|
||||
foreach (var method in classInfo.Methods)
|
||||
{
|
||||
var callerKey = $"{classInfo.ClassName}::{method.Name}{method.Descriptor}";
|
||||
|
||||
// Skip private methods unless requested
|
||||
if (!request.IncludePrivateMethods && !method.IsPublic && !method.IsProtected)
|
||||
continue;
|
||||
|
||||
graph.AddMethod(new InternalMethodRef
|
||||
{
|
||||
MethodKey = callerKey,
|
||||
Name = method.Name,
|
||||
DeclaringType = classInfo.ClassName,
|
||||
IsPublic = method.IsPublic
|
||||
});
|
||||
|
||||
// Add edges for internal calls
|
||||
foreach (var call in method.InternalCalls)
|
||||
{
|
||||
var calleeKey = $"{call.TargetClass}::{call.MethodName}{call.Descriptor}";
|
||||
if (allMethods.ContainsKey(calleeKey))
|
||||
{
|
||||
graph.AddEdge(new InternalCallEdge { Caller = callerKey, Callee = calleeKey });
|
||||
}
|
||||
}
|
||||
}
|
||||
filesProcessed++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to analyze calls in {Path}", classPath);
|
||||
}
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
_logger.LogDebug(
|
||||
"Built internal call graph for Maven {PackageId} v{Version}: {Methods} methods, {Edges} edges in {Duration}ms",
|
||||
request.PackageId, request.Version, graph.MethodCount, graph.EdgeCount, sw.ElapsedMilliseconds);
|
||||
|
||||
return InternalCallGraphBuildResult.Ok(graph, sw.Elapsed, filesProcessed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
_logger.LogWarning(ex, "Failed to build internal call graph for Maven {PackageId}", request.PackageId);
|
||||
return InternalCallGraphBuildResult.Fail(ex.Message, sw.Elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
private static string[] GetClassFiles(string packagePath)
|
||||
{
|
||||
if (File.Exists(packagePath) && packagePath.EndsWith(".class", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return [packagePath];
|
||||
}
|
||||
|
||||
if (Directory.Exists(packagePath))
|
||||
{
|
||||
return Directory.GetFiles(packagePath, "*.class", SearchOption.AllDirectories)
|
||||
.Where(f => !f.Contains("META-INF"))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private ClassInfo? ParseClassFile(byte[] bytes)
|
||||
{
|
||||
if (bytes.Length < 10 || BinaryPrimitives.ReadUInt32BigEndian(bytes) != ClassFileMagic)
|
||||
return null;
|
||||
|
||||
var reader = new ByteReader(bytes);
|
||||
reader.Skip(4); // magic
|
||||
reader.Skip(4); // version
|
||||
|
||||
var constantPool = ParseConstantPool(reader);
|
||||
var accessFlags = reader.ReadU2();
|
||||
var thisClassIndex = reader.ReadU2();
|
||||
var className = ResolveClassName(constantPool, thisClassIndex);
|
||||
|
||||
reader.Skip(2); // super class
|
||||
var interfaceCount = reader.ReadU2();
|
||||
reader.Skip(interfaceCount * 2);
|
||||
|
||||
// Skip fields
|
||||
var fieldCount = reader.ReadU2();
|
||||
for (var i = 0; i < fieldCount; i++)
|
||||
SkipFieldOrMethod(reader);
|
||||
|
||||
// Parse methods
|
||||
var methodCount = reader.ReadU2();
|
||||
var methods = new List<MethodInfo>();
|
||||
for (var i = 0; i < methodCount; i++)
|
||||
{
|
||||
var method = ParseMethod(reader, constantPool);
|
||||
if (method is not null)
|
||||
methods.Add(method);
|
||||
}
|
||||
|
||||
return new ClassInfo
|
||||
{
|
||||
ClassName = className,
|
||||
AccessFlags = accessFlags,
|
||||
Methods = methods
|
||||
};
|
||||
}
|
||||
|
||||
private ClassInfo? ParseClassFileWithCalls(byte[] bytes, HashSet<string> packageClasses)
|
||||
{
|
||||
if (bytes.Length < 10 || BinaryPrimitives.ReadUInt32BigEndian(bytes) != ClassFileMagic)
|
||||
return null;
|
||||
|
||||
var reader = new ByteReader(bytes);
|
||||
reader.Skip(4); // magic
|
||||
reader.Skip(4); // version
|
||||
|
||||
var constantPool = ParseConstantPool(reader);
|
||||
var accessFlags = reader.ReadU2();
|
||||
var thisClassIndex = reader.ReadU2();
|
||||
var className = ResolveClassName(constantPool, thisClassIndex);
|
||||
|
||||
reader.Skip(2); // super class
|
||||
var interfaceCount = reader.ReadU2();
|
||||
reader.Skip(interfaceCount * 2);
|
||||
|
||||
// Skip fields
|
||||
var fieldCount = reader.ReadU2();
|
||||
for (var i = 0; i < fieldCount; i++)
|
||||
SkipFieldOrMethod(reader);
|
||||
|
||||
// Parse methods with call analysis
|
||||
var methodCount = reader.ReadU2();
|
||||
var methods = new List<MethodInfo>();
|
||||
for (var i = 0; i < methodCount; i++)
|
||||
{
|
||||
var method = ParseMethodWithCalls(reader, constantPool, packageClasses);
|
||||
if (method is not null)
|
||||
methods.Add(method);
|
||||
}
|
||||
|
||||
return new ClassInfo
|
||||
{
|
||||
ClassName = className,
|
||||
AccessFlags = accessFlags,
|
||||
Methods = methods
|
||||
};
|
||||
}
|
||||
|
||||
private static List<ConstantPoolEntry> ParseConstantPool(ByteReader reader)
|
||||
{
|
||||
var count = reader.ReadU2();
|
||||
var pool = new List<ConstantPoolEntry>(count) { new() };
|
||||
|
||||
for (var i = 1; i < count; i++)
|
||||
{
|
||||
var tag = reader.ReadU1();
|
||||
var entry = new ConstantPoolEntry { Tag = tag };
|
||||
|
||||
switch (tag)
|
||||
{
|
||||
case 1: // CONSTANT_Utf8
|
||||
var length = reader.ReadU2();
|
||||
entry.StringValue = Encoding.UTF8.GetString(reader.ReadBytes(length));
|
||||
break;
|
||||
case 3: case 4: reader.Skip(4); break;
|
||||
case 5: case 6: reader.Skip(8); pool.Add(new()); i++; break;
|
||||
case 7: case 8: entry.NameIndex = reader.ReadU2(); break;
|
||||
case 9: case 10: case 11:
|
||||
entry.ClassIndex = reader.ReadU2();
|
||||
entry.NameAndTypeIndex = reader.ReadU2();
|
||||
break;
|
||||
case 12:
|
||||
entry.NameIndex = reader.ReadU2();
|
||||
entry.DescriptorIndex = reader.ReadU2();
|
||||
break;
|
||||
case 15: reader.Skip(3); break;
|
||||
case 16: reader.Skip(2); break;
|
||||
case 17: case 18: reader.Skip(4); break;
|
||||
case 19: case 20: reader.Skip(2); break;
|
||||
}
|
||||
|
||||
pool.Add(entry);
|
||||
}
|
||||
|
||||
return pool;
|
||||
}
|
||||
|
||||
private static MethodInfo? ParseMethod(ByteReader reader, List<ConstantPoolEntry> pool)
|
||||
{
|
||||
var accessFlags = reader.ReadU2();
|
||||
var nameIndex = reader.ReadU2();
|
||||
var descriptorIndex = reader.ReadU2();
|
||||
|
||||
var name = GetUtf8(pool, nameIndex);
|
||||
var descriptor = GetUtf8(pool, descriptorIndex);
|
||||
|
||||
var attrCount = reader.ReadU2();
|
||||
for (var i = 0; i < attrCount; i++)
|
||||
{
|
||||
reader.Skip(2);
|
||||
var attrLength = reader.ReadU4();
|
||||
reader.Skip((int)attrLength);
|
||||
}
|
||||
|
||||
return new MethodInfo
|
||||
{
|
||||
Name = name,
|
||||
Descriptor = descriptor,
|
||||
AccessFlags = accessFlags,
|
||||
InternalCalls = []
|
||||
};
|
||||
}
|
||||
|
||||
private static MethodInfo? ParseMethodWithCalls(
|
||||
ByteReader reader,
|
||||
List<ConstantPoolEntry> pool,
|
||||
HashSet<string> packageClasses)
|
||||
{
|
||||
var accessFlags = reader.ReadU2();
|
||||
var nameIndex = reader.ReadU2();
|
||||
var descriptorIndex = reader.ReadU2();
|
||||
|
||||
var name = GetUtf8(pool, nameIndex);
|
||||
var descriptor = GetUtf8(pool, descriptorIndex);
|
||||
var calls = new List<CallInfo>();
|
||||
|
||||
var attrCount = reader.ReadU2();
|
||||
for (var i = 0; i < attrCount; i++)
|
||||
{
|
||||
var attrNameIndex = reader.ReadU2();
|
||||
var attrLength = reader.ReadU4();
|
||||
var attrName = GetUtf8(pool, attrNameIndex);
|
||||
|
||||
if (attrName == "Code")
|
||||
{
|
||||
reader.Skip(4); // max_stack, max_locals
|
||||
var codeLength = reader.ReadU4();
|
||||
var code = reader.ReadBytes((int)codeLength);
|
||||
|
||||
// Analyze bytecode for method calls
|
||||
AnalyzeBytecode(code, pool, packageClasses, calls);
|
||||
|
||||
// Skip exception table and code attributes
|
||||
var exceptionTableLength = reader.ReadU2();
|
||||
reader.Skip(exceptionTableLength * 8);
|
||||
|
||||
var codeAttrCount = reader.ReadU2();
|
||||
for (var j = 0; j < codeAttrCount; j++)
|
||||
{
|
||||
reader.Skip(2);
|
||||
var codeAttrLength = reader.ReadU4();
|
||||
reader.Skip((int)codeAttrLength);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
reader.Skip((int)attrLength);
|
||||
}
|
||||
}
|
||||
|
||||
return new MethodInfo
|
||||
{
|
||||
Name = name,
|
||||
Descriptor = descriptor,
|
||||
AccessFlags = accessFlags,
|
||||
InternalCalls = calls
|
||||
};
|
||||
}
|
||||
|
||||
private static void AnalyzeBytecode(
|
||||
byte[] code,
|
||||
List<ConstantPoolEntry> pool,
|
||||
HashSet<string> packageClasses,
|
||||
List<CallInfo> calls)
|
||||
{
|
||||
var i = 0;
|
||||
while (i < code.Length)
|
||||
{
|
||||
var opcode = code[i];
|
||||
|
||||
// invokevirtual, invokespecial, invokestatic, invokeinterface
|
||||
if (opcode is 0xB6 or 0xB7 or 0xB8 or 0xB9)
|
||||
{
|
||||
if (i + 2 < code.Length)
|
||||
{
|
||||
var methodRefIndex = (code[i + 1] << 8) | code[i + 2];
|
||||
var callInfo = ResolveMethodRef(pool, methodRefIndex);
|
||||
if (callInfo is not null && packageClasses.Contains(callInfo.TargetClass))
|
||||
{
|
||||
calls.Add(callInfo);
|
||||
}
|
||||
}
|
||||
|
||||
i += opcode == 0xB9 ? 5 : 3; // invokeinterface has 5 bytes
|
||||
}
|
||||
else
|
||||
{
|
||||
i += GetOpcodeLength(opcode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static CallInfo? ResolveMethodRef(List<ConstantPoolEntry> pool, int index)
|
||||
{
|
||||
if (index <= 0 || index >= pool.Count)
|
||||
return null;
|
||||
|
||||
var methodRef = pool[index];
|
||||
if (methodRef.Tag is not (10 or 11)) // Methodref or InterfaceMethodref
|
||||
return null;
|
||||
|
||||
var classEntry = pool.ElementAtOrDefault(methodRef.ClassIndex);
|
||||
var nameAndType = pool.ElementAtOrDefault(methodRef.NameAndTypeIndex);
|
||||
|
||||
if (classEntry?.Tag != 7 || nameAndType?.Tag != 12)
|
||||
return null;
|
||||
|
||||
var className = GetUtf8(pool, classEntry.NameIndex).Replace('/', '.');
|
||||
var methodName = GetUtf8(pool, nameAndType.NameIndex);
|
||||
var descriptor = GetUtf8(pool, nameAndType.DescriptorIndex);
|
||||
|
||||
return new CallInfo
|
||||
{
|
||||
TargetClass = className,
|
||||
MethodName = methodName,
|
||||
Descriptor = descriptor
|
||||
};
|
||||
}
|
||||
|
||||
private static void SkipFieldOrMethod(ByteReader reader)
|
||||
{
|
||||
reader.Skip(6);
|
||||
var attrCount = reader.ReadU2();
|
||||
for (var i = 0; i < attrCount; i++)
|
||||
{
|
||||
reader.Skip(2);
|
||||
var length = reader.ReadU4();
|
||||
reader.Skip((int)length);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveClassName(List<ConstantPoolEntry> pool, int classIndex)
|
||||
{
|
||||
if (classIndex <= 0 || classIndex >= pool.Count || pool[classIndex].Tag != 7)
|
||||
return "Unknown";
|
||||
return GetUtf8(pool, pool[classIndex].NameIndex).Replace('/', '.');
|
||||
}
|
||||
|
||||
private static string GetUtf8(List<ConstantPoolEntry> pool, int index)
|
||||
{
|
||||
if (index <= 0 || index >= pool.Count)
|
||||
return string.Empty;
|
||||
return pool[index].StringValue ?? string.Empty;
|
||||
}
|
||||
|
||||
private static int GetOpcodeLength(byte opcode) => opcode switch
|
||||
{
|
||||
// Wide instructions and tableswitch/lookupswitch are variable - simplified handling
|
||||
0xC4 => 4, // wide (simplified)
|
||||
0xAA or 0xAB => 4, // tableswitch/lookupswitch (simplified)
|
||||
_ when opcode is 0x10 or 0x12 or 0x15 or 0x16 or 0x17 or 0x18 or 0x19
|
||||
or 0x36 or 0x37 or 0x38 or 0x39 or 0x3A or 0xA9 or 0xBC => 2,
|
||||
_ when opcode is 0x11 or 0x13 or 0x14 or 0x84 or 0x99 or 0x9A or 0x9B
|
||||
or 0x9C or 0x9D or 0x9E or 0x9F or 0xA0 or 0xA1 or 0xA2 or 0xA3
|
||||
or 0xA4 or 0xA5 or 0xA6 or 0xA7 or 0xA8 or 0xB2 or 0xB3 or 0xB4
|
||||
or 0xB5 or 0xB6 or 0xB7 or 0xB8 or 0xBB or 0xBD or 0xC0 or 0xC1
|
||||
or 0xC6 or 0xC7 => 3,
|
||||
0xC8 or 0xC9 => 5, // goto_w, jsr_w
|
||||
0xB9 or 0xBA => 5, // invokeinterface, invokedynamic
|
||||
0xC5 => 4, // multianewarray
|
||||
_ => 1
|
||||
};
|
||||
|
||||
private sealed class ByteReader(byte[] data)
|
||||
{
|
||||
private int _pos;
|
||||
public byte ReadU1() => data[_pos++];
|
||||
public ushort ReadU2() { var v = BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(_pos)); _pos += 2; return v; }
|
||||
public uint ReadU4() { var v = BinaryPrimitives.ReadUInt32BigEndian(data.AsSpan(_pos)); _pos += 4; return v; }
|
||||
public byte[] ReadBytes(int n) { var r = data[_pos..(_pos + n)]; _pos += n; return r; }
|
||||
public void Skip(int n) => _pos += n;
|
||||
}
|
||||
|
||||
private sealed class ConstantPoolEntry
|
||||
{
|
||||
public byte Tag { get; init; }
|
||||
public string? StringValue { get; set; }
|
||||
public int NameIndex { get; set; }
|
||||
public int DescriptorIndex { get; set; }
|
||||
public int ClassIndex { get; set; }
|
||||
public int NameAndTypeIndex { get; set; }
|
||||
}
|
||||
|
||||
private sealed record ClassInfo
|
||||
{
|
||||
public required string ClassName { get; init; }
|
||||
public ushort AccessFlags { get; init; }
|
||||
public required List<MethodInfo> Methods { get; init; }
|
||||
}
|
||||
|
||||
private sealed record MethodInfo
|
||||
{
|
||||
public string DeclaringClass { get; init; } = string.Empty;
|
||||
public required string Name { get; init; }
|
||||
public required string Descriptor { get; init; }
|
||||
public ushort AccessFlags { get; init; }
|
||||
public required List<CallInfo> InternalCalls { get; init; }
|
||||
public bool IsPublic => (AccessFlags & 0x0001) != 0;
|
||||
public bool IsProtected => (AccessFlags & 0x0004) != 0;
|
||||
}
|
||||
|
||||
private sealed record CallInfo
|
||||
{
|
||||
public required string TargetClass { get; init; }
|
||||
public required string MethodName { get; init; }
|
||||
public required string Descriptor { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// JavaScriptInternalGraphBuilder.cs
|
||||
// Sprint: SPRINT_3700_0003_0001_trigger_extraction (TRIG-003)
|
||||
// Description: JavaScript/Node.js internal call graph builder using AST parsing.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.VulnSurfaces.Models;
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.CallGraph;
|
||||
|
||||
/// <summary>
|
||||
/// Internal call graph builder for JavaScript/Node.js packages using AST-based parsing.
|
||||
/// </summary>
|
||||
public sealed partial class JavaScriptInternalGraphBuilder : IInternalCallGraphBuilder
|
||||
{
|
||||
private readonly ILogger<JavaScriptInternalGraphBuilder> _logger;
|
||||
|
||||
// Regex patterns for JavaScript analysis
|
||||
[GeneratedRegex(@"(export\s+)?(async\s+)?function\s+(\w+)\s*\(", RegexOptions.Compiled)]
|
||||
private static partial Regex FunctionDeclarationRegex();
|
||||
|
||||
[GeneratedRegex(@"(const|let|var)\s+(\w+)\s*=\s*(async\s+)?\(", RegexOptions.Compiled)]
|
||||
private static partial Regex ArrowFunctionRegex();
|
||||
|
||||
[GeneratedRegex(@"class\s+(\w+)", RegexOptions.Compiled)]
|
||||
private static partial Regex ClassDeclarationRegex();
|
||||
|
||||
[GeneratedRegex(@"(async\s+)?(\w+)\s*\([^)]*\)\s*\{", RegexOptions.Compiled)]
|
||||
private static partial Regex MethodDeclarationRegex();
|
||||
|
||||
[GeneratedRegex(@"(?:this\.)?(\w+)\s*\(", RegexOptions.Compiled)]
|
||||
private static partial Regex FunctionCallRegex();
|
||||
|
||||
[GeneratedRegex(@"module\.exports\s*=\s*\{?([^}]+)", RegexOptions.Compiled)]
|
||||
private static partial Regex ModuleExportsRegex();
|
||||
|
||||
[GeneratedRegex(@"exports\.(\w+)", RegexOptions.Compiled)]
|
||||
private static partial Regex NamedExportRegex();
|
||||
|
||||
public JavaScriptInternalGraphBuilder(ILogger<JavaScriptInternalGraphBuilder> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Ecosystem => "npm";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanHandle(string packagePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(packagePath))
|
||||
return false;
|
||||
|
||||
if (packagePath.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
if (Directory.Exists(packagePath))
|
||||
{
|
||||
// Check for package.json or .js files
|
||||
return File.Exists(Path.Combine(packagePath, "package.json")) ||
|
||||
Directory.EnumerateFiles(packagePath, "*.js", SearchOption.AllDirectories).Any();
|
||||
}
|
||||
|
||||
return packagePath.EndsWith(".js", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<InternalCallGraphBuildResult> BuildAsync(
|
||||
InternalCallGraphBuildRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var graph = new InternalCallGraph
|
||||
{
|
||||
PackageId = request.PackageId,
|
||||
Version = request.Version
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var jsFiles = GetJavaScriptFiles(request.PackagePath);
|
||||
var filesProcessed = 0;
|
||||
var allFunctions = new Dictionary<string, FunctionInfo>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// First pass: collect all function declarations
|
||||
foreach (var jsPath in jsFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(jsPath, cancellationToken);
|
||||
var moduleName = GetModuleName(jsPath, request.PackagePath);
|
||||
CollectFunctions(content, moduleName, allFunctions, request.IncludePrivateMethods);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to collect functions from {Path}", jsPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: analyze call relationships
|
||||
foreach (var jsPath in jsFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(jsPath, cancellationToken);
|
||||
var moduleName = GetModuleName(jsPath, request.PackagePath);
|
||||
AnalyzeCalls(content, moduleName, allFunctions, graph);
|
||||
filesProcessed++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to analyze calls in {Path}", jsPath);
|
||||
}
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
_logger.LogDebug(
|
||||
"Built internal call graph for npm {PackageId} v{Version}: {Methods} methods, {Edges} edges in {Duration}ms",
|
||||
request.PackageId, request.Version, graph.MethodCount, graph.EdgeCount, sw.ElapsedMilliseconds);
|
||||
|
||||
return InternalCallGraphBuildResult.Ok(graph, sw.Elapsed, filesProcessed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
_logger.LogWarning(ex, "Failed to build internal call graph for npm {PackageId}", request.PackageId);
|
||||
return InternalCallGraphBuildResult.Fail(ex.Message, sw.Elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
private static string[] GetJavaScriptFiles(string packagePath)
|
||||
{
|
||||
if (File.Exists(packagePath) && packagePath.EndsWith(".js", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return [packagePath];
|
||||
}
|
||||
|
||||
if (Directory.Exists(packagePath))
|
||||
{
|
||||
return Directory.GetFiles(packagePath, "*.js", SearchOption.AllDirectories)
|
||||
.Where(f =>
|
||||
{
|
||||
var name = Path.GetFileName(f);
|
||||
return !name.Contains(".min.") &&
|
||||
!name.EndsWith(".spec.js") &&
|
||||
!name.EndsWith(".test.js") &&
|
||||
!f.Contains("node_modules") &&
|
||||
!f.Contains("__tests__");
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private static string GetModuleName(string jsPath, string basePath)
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(basePath, jsPath);
|
||||
var withoutExt = Path.ChangeExtension(relativePath, null);
|
||||
return withoutExt
|
||||
.Replace(Path.DirectorySeparatorChar, '.')
|
||||
.Replace(Path.AltDirectorySeparatorChar, '.');
|
||||
}
|
||||
|
||||
private void CollectFunctions(
|
||||
string content,
|
||||
string moduleName,
|
||||
Dictionary<string, FunctionInfo> functions,
|
||||
bool includePrivate)
|
||||
{
|
||||
// Collect function declarations
|
||||
foreach (Match match in FunctionDeclarationRegex().Matches(content))
|
||||
{
|
||||
var isExported = !string.IsNullOrEmpty(match.Groups[1].Value);
|
||||
var functionName = match.Groups[3].Value;
|
||||
|
||||
if (!includePrivate && !isExported)
|
||||
continue;
|
||||
|
||||
var key = $"{moduleName}::{functionName}";
|
||||
functions[key] = new FunctionInfo
|
||||
{
|
||||
Name = functionName,
|
||||
Module = moduleName,
|
||||
IsPublic = isExported,
|
||||
StartIndex = match.Index,
|
||||
EndIndex = FindFunctionEnd(content, match.Index)
|
||||
};
|
||||
}
|
||||
|
||||
// Collect arrow functions
|
||||
foreach (Match match in ArrowFunctionRegex().Matches(content))
|
||||
{
|
||||
var functionName = match.Groups[2].Value;
|
||||
var lineStart = content.LastIndexOf('\n', match.Index) + 1;
|
||||
var prefix = content[lineStart..match.Index];
|
||||
var isExported = prefix.Contains("export");
|
||||
|
||||
if (!includePrivate && !isExported)
|
||||
continue;
|
||||
|
||||
var key = $"{moduleName}::{functionName}";
|
||||
if (!functions.ContainsKey(key))
|
||||
{
|
||||
functions[key] = new FunctionInfo
|
||||
{
|
||||
Name = functionName,
|
||||
Module = moduleName,
|
||||
IsPublic = isExported,
|
||||
StartIndex = match.Index,
|
||||
EndIndex = FindArrowFunctionEnd(content, match.Index)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Collect class methods
|
||||
foreach (Match classMatch in ClassDeclarationRegex().Matches(content))
|
||||
{
|
||||
var className = classMatch.Groups[1].Value;
|
||||
var classBodyStart = content.IndexOf('{', classMatch.Index);
|
||||
if (classBodyStart < 0) continue;
|
||||
|
||||
var classBody = ExtractBracedBlock(content, classBodyStart);
|
||||
if (string.IsNullOrEmpty(classBody)) continue;
|
||||
|
||||
foreach (Match methodMatch in MethodDeclarationRegex().Matches(classBody))
|
||||
{
|
||||
var methodName = methodMatch.Groups[2].Value;
|
||||
if (methodName == "constructor") continue;
|
||||
|
||||
var key = $"{moduleName}.{className}::{methodName}";
|
||||
functions[key] = new FunctionInfo
|
||||
{
|
||||
Name = methodName,
|
||||
Module = $"{moduleName}.{className}",
|
||||
IsPublic = true, // Class methods are typically public
|
||||
StartIndex = classMatch.Index + methodMatch.Index,
|
||||
EndIndex = classMatch.Index + FindFunctionEnd(classBody, methodMatch.Index)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Mark exported functions from module.exports
|
||||
var exportsMatch = ModuleExportsRegex().Match(content);
|
||||
if (exportsMatch.Success)
|
||||
{
|
||||
var exports = exportsMatch.Groups[1].Value;
|
||||
foreach (var func in functions.Values)
|
||||
{
|
||||
if (exports.Contains(func.Name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
func.IsPublic = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Match exportMatch in NamedExportRegex().Matches(content))
|
||||
{
|
||||
var exportedName = exportMatch.Groups[1].Value;
|
||||
var key = $"{moduleName}::{exportedName}";
|
||||
if (functions.TryGetValue(key, out var func))
|
||||
{
|
||||
func.IsPublic = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void AnalyzeCalls(
|
||||
string content,
|
||||
string moduleName,
|
||||
Dictionary<string, FunctionInfo> allFunctions,
|
||||
InternalCallGraph graph)
|
||||
{
|
||||
var moduleFunctions = allFunctions
|
||||
.Where(kvp => kvp.Value.Module == moduleName || kvp.Value.Module.StartsWith($"{moduleName}."))
|
||||
.ToList();
|
||||
|
||||
foreach (var (callerKey, callerInfo) in moduleFunctions)
|
||||
{
|
||||
// Add node
|
||||
graph.AddMethod(new InternalMethodRef
|
||||
{
|
||||
MethodKey = callerKey,
|
||||
Name = callerInfo.Name,
|
||||
DeclaringType = callerInfo.Module,
|
||||
IsPublic = callerInfo.IsPublic
|
||||
});
|
||||
|
||||
// Extract function body
|
||||
var bodyStart = callerInfo.StartIndex;
|
||||
var bodyEnd = callerInfo.EndIndex;
|
||||
if (bodyEnd <= bodyStart || bodyEnd > content.Length)
|
||||
continue;
|
||||
|
||||
var body = content[bodyStart..Math.Min(bodyEnd, content.Length)];
|
||||
|
||||
// Find calls in body
|
||||
foreach (Match callMatch in FunctionCallRegex().Matches(body))
|
||||
{
|
||||
var calledName = callMatch.Groups[1].Value;
|
||||
|
||||
// Skip common built-ins and keywords
|
||||
if (IsBuiltIn(calledName))
|
||||
continue;
|
||||
|
||||
// Try to resolve callee
|
||||
var calleeKey = ResolveFunctionKey(calledName, moduleName, allFunctions);
|
||||
if (calleeKey is not null && calleeKey != callerKey)
|
||||
{
|
||||
graph.AddEdge(new InternalCallEdge { Caller = callerKey, Callee = calleeKey });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveFunctionKey(
|
||||
string calledName,
|
||||
string callerModule,
|
||||
Dictionary<string, FunctionInfo> allFunctions)
|
||||
{
|
||||
// Try same module first
|
||||
var sameModuleKey = $"{callerModule}::{calledName}";
|
||||
if (allFunctions.ContainsKey(sameModuleKey))
|
||||
return sameModuleKey;
|
||||
|
||||
// Try any module with that function
|
||||
var match = allFunctions.Keys
|
||||
.FirstOrDefault(k => k.EndsWith($"::{calledName}", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return match;
|
||||
}
|
||||
|
||||
private static bool IsBuiltIn(string name)
|
||||
{
|
||||
return name is "console" or "require" or "import" or "export" or "if" or "for" or "while"
|
||||
or "switch" or "return" or "throw" or "catch" or "try" or "new" or "typeof" or "instanceof"
|
||||
or "delete" or "void" or "await" or "Promise" or "Array" or "Object" or "String" or "Number"
|
||||
or "Boolean" or "Date" or "Math" or "JSON" or "Error" or "RegExp" or "Map" or "Set"
|
||||
or "setTimeout" or "setInterval" or "clearTimeout" or "clearInterval" or "fetch"
|
||||
or "process" or "Buffer" or "__dirname" or "__filename";
|
||||
}
|
||||
|
||||
private static int FindFunctionEnd(string content, int start)
|
||||
{
|
||||
var braceStart = content.IndexOf('{', start);
|
||||
if (braceStart < 0) return start + 100;
|
||||
|
||||
return braceStart + FindMatchingBrace(content, braceStart);
|
||||
}
|
||||
|
||||
private static int FindArrowFunctionEnd(string content, int start)
|
||||
{
|
||||
var arrowIndex = content.IndexOf("=>", start);
|
||||
if (arrowIndex < 0) return start + 100;
|
||||
|
||||
var afterArrow = arrowIndex + 2;
|
||||
while (afterArrow < content.Length && char.IsWhiteSpace(content[afterArrow]))
|
||||
afterArrow++;
|
||||
|
||||
if (afterArrow < content.Length && content[afterArrow] == '{')
|
||||
{
|
||||
return afterArrow + FindMatchingBrace(content, afterArrow);
|
||||
}
|
||||
|
||||
// Expression body
|
||||
var endIndex = content.IndexOfAny([';', '\n', ','], afterArrow);
|
||||
return endIndex > 0 ? endIndex : afterArrow + 100;
|
||||
}
|
||||
|
||||
private static int FindMatchingBrace(string content, int braceStart)
|
||||
{
|
||||
var depth = 0;
|
||||
for (var i = braceStart; i < content.Length; i++)
|
||||
{
|
||||
if (content[i] == '{') depth++;
|
||||
else if (content[i] == '}')
|
||||
{
|
||||
depth--;
|
||||
if (depth == 0) return i - braceStart + 1;
|
||||
}
|
||||
}
|
||||
return content.Length - braceStart;
|
||||
}
|
||||
|
||||
private static string ExtractBracedBlock(string content, int braceStart)
|
||||
{
|
||||
if (braceStart >= content.Length || content[braceStart] != '{')
|
||||
return string.Empty;
|
||||
|
||||
var length = FindMatchingBrace(content, braceStart);
|
||||
var endIndex = braceStart + length;
|
||||
if (endIndex > content.Length) endIndex = content.Length;
|
||||
|
||||
return content[(braceStart + 1)..(endIndex - 1)];
|
||||
}
|
||||
|
||||
private sealed class FunctionInfo
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string Module { get; init; }
|
||||
public bool IsPublic { get; set; }
|
||||
public int StartIndex { get; init; }
|
||||
public int EndIndex { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PythonInternalGraphBuilder.cs
|
||||
// Sprint: SPRINT_3700_0003_0001_trigger_extraction (TRIG-005)
|
||||
// Description: Python internal call graph builder using AST-based parsing.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.VulnSurfaces.Models;
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.CallGraph;
|
||||
|
||||
/// <summary>
|
||||
/// Internal call graph builder for Python packages using AST-based parsing.
|
||||
/// </summary>
|
||||
public sealed partial class PythonInternalGraphBuilder : IInternalCallGraphBuilder
|
||||
{
|
||||
private readonly ILogger<PythonInternalGraphBuilder> _logger;
|
||||
|
||||
// Regex patterns for Python analysis
|
||||
[GeneratedRegex(@"^(async\s+)?def\s+(\w+)\s*\(([^)]*)\)\s*(?:->\s*[^:]+)?:", RegexOptions.Multiline | RegexOptions.Compiled)]
|
||||
private static partial Regex FunctionDefRegex();
|
||||
|
||||
[GeneratedRegex(@"^class\s+(\w+)(?:\s*\([^)]*\))?\s*:", RegexOptions.Multiline | RegexOptions.Compiled)]
|
||||
private static partial Regex ClassDefRegex();
|
||||
|
||||
[GeneratedRegex(@"^(\s+)(async\s+)?def\s+(\w+)\s*\(([^)]*)\)\s*(?:->\s*[^:]+)?:", RegexOptions.Multiline | RegexOptions.Compiled)]
|
||||
private static partial Regex MethodDefRegex();
|
||||
|
||||
[GeneratedRegex(@"(?:self\.)?(\w+)\s*\(", RegexOptions.Compiled)]
|
||||
private static partial Regex FunctionCallRegex();
|
||||
|
||||
[GeneratedRegex(@"^from\s+(\S+)\s+import\s+(.+)$", RegexOptions.Multiline | RegexOptions.Compiled)]
|
||||
private static partial Regex FromImportRegex();
|
||||
|
||||
[GeneratedRegex(@"^import\s+(\S+)", RegexOptions.Multiline | RegexOptions.Compiled)]
|
||||
private static partial Regex ImportRegex();
|
||||
|
||||
[GeneratedRegex(@"^__all__\s*=\s*\[([^\]]+)\]", RegexOptions.Multiline | RegexOptions.Compiled)]
|
||||
private static partial Regex AllExportRegex();
|
||||
|
||||
public PythonInternalGraphBuilder(ILogger<PythonInternalGraphBuilder> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Ecosystem => "pypi";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanHandle(string packagePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(packagePath))
|
||||
return false;
|
||||
|
||||
if (packagePath.EndsWith(".whl", StringComparison.OrdinalIgnoreCase) ||
|
||||
packagePath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
if (Directory.Exists(packagePath))
|
||||
{
|
||||
return File.Exists(Path.Combine(packagePath, "setup.py")) ||
|
||||
File.Exists(Path.Combine(packagePath, "pyproject.toml")) ||
|
||||
Directory.EnumerateFiles(packagePath, "*.py", SearchOption.AllDirectories).Any();
|
||||
}
|
||||
|
||||
return packagePath.EndsWith(".py", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<InternalCallGraphBuildResult> BuildAsync(
|
||||
InternalCallGraphBuildRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var graph = new InternalCallGraph
|
||||
{
|
||||
PackageId = request.PackageId,
|
||||
Version = request.Version
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var pyFiles = GetPythonFiles(request.PackagePath);
|
||||
var filesProcessed = 0;
|
||||
var allFunctions = new Dictionary<string, FunctionInfo>(StringComparer.Ordinal);
|
||||
|
||||
// First pass: collect all function declarations
|
||||
foreach (var pyPath in pyFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(pyPath, cancellationToken);
|
||||
var moduleName = GetModuleName(pyPath, request.PackagePath);
|
||||
CollectFunctions(content, moduleName, allFunctions, request.IncludePrivateMethods);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to collect functions from {Path}", pyPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: analyze call relationships
|
||||
foreach (var pyPath in pyFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(pyPath, cancellationToken);
|
||||
var moduleName = GetModuleName(pyPath, request.PackagePath);
|
||||
AnalyzeCalls(content, moduleName, allFunctions, graph);
|
||||
filesProcessed++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to analyze calls in {Path}", pyPath);
|
||||
}
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
_logger.LogDebug(
|
||||
"Built internal call graph for PyPI {PackageId} v{Version}: {Methods} methods, {Edges} edges in {Duration}ms",
|
||||
request.PackageId, request.Version, graph.MethodCount, graph.EdgeCount, sw.ElapsedMilliseconds);
|
||||
|
||||
return InternalCallGraphBuildResult.Ok(graph, sw.Elapsed, filesProcessed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
_logger.LogWarning(ex, "Failed to build internal call graph for PyPI {PackageId}", request.PackageId);
|
||||
return InternalCallGraphBuildResult.Fail(ex.Message, sw.Elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
private static string[] GetPythonFiles(string packagePath)
|
||||
{
|
||||
if (File.Exists(packagePath) && packagePath.EndsWith(".py", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return [packagePath];
|
||||
}
|
||||
|
||||
if (Directory.Exists(packagePath))
|
||||
{
|
||||
return Directory.GetFiles(packagePath, "*.py", SearchOption.AllDirectories)
|
||||
.Where(f =>
|
||||
{
|
||||
var name = Path.GetFileName(f);
|
||||
return !name.StartsWith("test_") &&
|
||||
!name.EndsWith("_test.py") &&
|
||||
!f.Contains("__pycache__") &&
|
||||
!f.Contains(".egg-info") &&
|
||||
!f.Contains("tests/") &&
|
||||
!f.Contains("test/");
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private static string GetModuleName(string pyPath, string basePath)
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(basePath, pyPath);
|
||||
var withoutExt = Path.ChangeExtension(relativePath, null);
|
||||
var moduleName = withoutExt
|
||||
.Replace(Path.DirectorySeparatorChar, '.')
|
||||
.Replace(Path.AltDirectorySeparatorChar, '.');
|
||||
|
||||
// Remove __init__ from module name
|
||||
if (moduleName.EndsWith(".__init__"))
|
||||
moduleName = moduleName[..^9];
|
||||
|
||||
return moduleName;
|
||||
}
|
||||
|
||||
private void CollectFunctions(
|
||||
string content,
|
||||
string moduleName,
|
||||
Dictionary<string, FunctionInfo> functions,
|
||||
bool includePrivate)
|
||||
{
|
||||
var lines = content.Split('\n');
|
||||
|
||||
// Check for __all__ exports
|
||||
var exportedNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
var allMatch = AllExportRegex().Match(content);
|
||||
if (allMatch.Success)
|
||||
{
|
||||
var exports = allMatch.Groups[1].Value;
|
||||
foreach (var name in exports.Split(',').Select(s => s.Trim().Trim('\'', '"')))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
exportedNames.Add(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect module-level functions
|
||||
foreach (Match match in FunctionDefRegex().Matches(content))
|
||||
{
|
||||
// Skip if indented (class method)
|
||||
var lineStart = content.LastIndexOf('\n', Math.Max(0, match.Index - 1)) + 1;
|
||||
if (lineStart < match.Index && char.IsWhiteSpace(content[lineStart]))
|
||||
continue;
|
||||
|
||||
var functionName = match.Groups[2].Value;
|
||||
|
||||
// Skip private functions unless requested
|
||||
var isPrivate = functionName.StartsWith('_') && !functionName.StartsWith("__");
|
||||
if (!includePrivate && isPrivate)
|
||||
continue;
|
||||
|
||||
var isPublic = !isPrivate && (exportedNames.Count == 0 || exportedNames.Contains(functionName));
|
||||
var lineNumber = GetLineNumber(content, match.Index);
|
||||
|
||||
var key = $"{moduleName}::{functionName}";
|
||||
functions[key] = new FunctionInfo
|
||||
{
|
||||
Name = functionName,
|
||||
Module = moduleName,
|
||||
IsPublic = isPublic,
|
||||
StartLine = lineNumber,
|
||||
EndLine = FindFunctionEndLine(lines, lineNumber - 1, 0)
|
||||
};
|
||||
}
|
||||
|
||||
// Collect class methods
|
||||
foreach (Match classMatch in ClassDefRegex().Matches(content))
|
||||
{
|
||||
var className = classMatch.Groups[1].Value;
|
||||
var classLine = GetLineNumber(content, classMatch.Index);
|
||||
var classIndent = GetIndentation(lines[classLine - 1]);
|
||||
|
||||
foreach (Match methodMatch in MethodDefRegex().Matches(content))
|
||||
{
|
||||
var methodLine = GetLineNumber(content, methodMatch.Index);
|
||||
if (methodLine <= classLine)
|
||||
continue;
|
||||
|
||||
var methodIndent = methodMatch.Groups[1].Value.Length;
|
||||
if (methodIndent <= classIndent)
|
||||
break;
|
||||
|
||||
var methodName = methodMatch.Groups[3].Value;
|
||||
|
||||
// Skip private methods unless requested
|
||||
var isPrivate = methodName.StartsWith('_') && !methodName.StartsWith("__");
|
||||
if (!includePrivate && isPrivate)
|
||||
continue;
|
||||
|
||||
// Dunder methods are considered public
|
||||
var isPublic = !isPrivate || (methodName.StartsWith("__") && methodName.EndsWith("__"));
|
||||
|
||||
var key = $"{moduleName}.{className}::{methodName}";
|
||||
functions[key] = new FunctionInfo
|
||||
{
|
||||
Name = methodName,
|
||||
Module = $"{moduleName}.{className}",
|
||||
IsPublic = isPublic,
|
||||
StartLine = methodLine,
|
||||
EndLine = FindFunctionEndLine(lines, methodLine - 1, methodIndent)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void AnalyzeCalls(
|
||||
string content,
|
||||
string moduleName,
|
||||
Dictionary<string, FunctionInfo> allFunctions,
|
||||
InternalCallGraph graph)
|
||||
{
|
||||
var lines = content.Split('\n');
|
||||
var moduleFunctions = allFunctions
|
||||
.Where(kvp => kvp.Value.Module == moduleName || kvp.Value.Module.StartsWith($"{moduleName}."))
|
||||
.ToList();
|
||||
|
||||
// Collect imports for resolution
|
||||
var imports = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (Match match in FromImportRegex().Matches(content))
|
||||
{
|
||||
var fromModule = match.Groups[1].Value;
|
||||
var imported = match.Groups[2].Value;
|
||||
foreach (var item in imported.Split(',').Select(s => s.Trim()))
|
||||
{
|
||||
var parts = item.Split(" as ");
|
||||
var name = parts[0].Trim();
|
||||
var alias = parts.Length > 1 ? parts[1].Trim() : name;
|
||||
imports[alias] = $"{fromModule}.{name}";
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (callerKey, callerInfo) in moduleFunctions)
|
||||
{
|
||||
graph.AddMethod(new InternalMethodRef
|
||||
{
|
||||
MethodKey = callerKey,
|
||||
Name = callerInfo.Name,
|
||||
DeclaringType = callerInfo.Module,
|
||||
IsPublic = callerInfo.IsPublic
|
||||
});
|
||||
|
||||
// Extract function body
|
||||
if (callerInfo.StartLine <= 0 || callerInfo.EndLine <= callerInfo.StartLine)
|
||||
continue;
|
||||
|
||||
var bodyLines = lines
|
||||
.Skip(callerInfo.StartLine)
|
||||
.Take(callerInfo.EndLine - callerInfo.StartLine)
|
||||
.ToArray();
|
||||
var body = string.Join("\n", bodyLines);
|
||||
|
||||
// Find calls in body
|
||||
foreach (Match callMatch in FunctionCallRegex().Matches(body))
|
||||
{
|
||||
var calledName = callMatch.Groups[1].Value;
|
||||
|
||||
// Skip built-ins and keywords
|
||||
if (IsBuiltIn(calledName))
|
||||
continue;
|
||||
|
||||
// Try to resolve callee
|
||||
var calleeKey = ResolveFunctionKey(calledName, moduleName, imports, allFunctions);
|
||||
if (calleeKey is not null && calleeKey != callerKey)
|
||||
{
|
||||
graph.AddEdge(new InternalCallEdge { Caller = callerKey, Callee = calleeKey });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveFunctionKey(
|
||||
string calledName,
|
||||
string callerModule,
|
||||
Dictionary<string, string> imports,
|
||||
Dictionary<string, FunctionInfo> allFunctions)
|
||||
{
|
||||
// Try same module first
|
||||
var sameModuleKey = $"{callerModule}::{calledName}";
|
||||
if (allFunctions.ContainsKey(sameModuleKey))
|
||||
return sameModuleKey;
|
||||
|
||||
// Try class method in same module
|
||||
var classMethodKey = allFunctions.Keys
|
||||
.FirstOrDefault(k => k.StartsWith($"{callerModule}.") && k.EndsWith($"::{calledName}"));
|
||||
if (classMethodKey is not null)
|
||||
return classMethodKey;
|
||||
|
||||
// Try imported name
|
||||
if (imports.TryGetValue(calledName, out var importedPath))
|
||||
{
|
||||
var importedKey = allFunctions.Keys
|
||||
.FirstOrDefault(k => k.Contains(importedPath, StringComparison.OrdinalIgnoreCase) ||
|
||||
k.EndsWith($"::{calledName}", StringComparison.OrdinalIgnoreCase));
|
||||
if (importedKey is not null)
|
||||
return importedKey;
|
||||
}
|
||||
|
||||
// Try any module with that function
|
||||
return allFunctions.Keys
|
||||
.FirstOrDefault(k => k.EndsWith($"::{calledName}", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static bool IsBuiltIn(string name)
|
||||
{
|
||||
return name is "print" or "len" or "range" or "str" or "int" or "float" or "bool" or "list"
|
||||
or "dict" or "set" or "tuple" or "type" or "isinstance" or "issubclass" or "hasattr"
|
||||
or "getattr" or "setattr" or "delattr" or "callable" or "super" or "property"
|
||||
or "staticmethod" or "classmethod" or "open" or "input" or "format" or "repr"
|
||||
or "id" or "hash" or "abs" or "round" or "min" or "max" or "sum" or "sorted"
|
||||
or "reversed" or "enumerate" or "zip" or "map" or "filter" or "any" or "all"
|
||||
or "iter" or "next" or "slice" or "object" or "Exception" or "ValueError"
|
||||
or "TypeError" or "KeyError" or "IndexError" or "AttributeError" or "RuntimeError"
|
||||
or "if" or "for" or "while" or "return" or "yield" or "raise" or "try"
|
||||
or "except" or "finally" or "with" or "as" or "import" or "from" or "class" or "def"
|
||||
or "async" or "await" or "lambda" or "pass" or "break" or "continue" or "assert"
|
||||
or "True" or "False" or "None" or "self" or "cls";
|
||||
}
|
||||
|
||||
private static int GetLineNumber(string content, int index)
|
||||
{
|
||||
var lineNumber = 1;
|
||||
for (var i = 0; i < index && i < content.Length; i++)
|
||||
{
|
||||
if (content[i] == '\n')
|
||||
lineNumber++;
|
||||
}
|
||||
return lineNumber;
|
||||
}
|
||||
|
||||
private static int GetIndentation(string line)
|
||||
{
|
||||
var indent = 0;
|
||||
foreach (var c in line)
|
||||
{
|
||||
if (c == ' ') indent++;
|
||||
else if (c == '\t') indent += 4;
|
||||
else break;
|
||||
}
|
||||
return indent;
|
||||
}
|
||||
|
||||
private static int FindFunctionEndLine(string[] lines, int defLineIndex, int baseIndent)
|
||||
{
|
||||
var bodyIndent = -1;
|
||||
|
||||
for (var i = defLineIndex + 1; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
continue;
|
||||
|
||||
var currentIndent = GetIndentation(line);
|
||||
|
||||
if (bodyIndent < 0)
|
||||
{
|
||||
if (currentIndent <= baseIndent)
|
||||
return defLineIndex + 1;
|
||||
bodyIndent = currentIndent;
|
||||
}
|
||||
else if (currentIndent <= baseIndent && !string.IsNullOrWhiteSpace(line.Trim()))
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return lines.Length;
|
||||
}
|
||||
|
||||
private sealed class FunctionInfo
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string Module { get; init; }
|
||||
public bool IsPublic { get; set; }
|
||||
public int StartLine { get; init; }
|
||||
public int EndLine { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// MavenPackageDownloader.cs
|
||||
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core (SURF-005)
|
||||
// Description: Downloads Maven packages (JARs) from Maven Central or custom
|
||||
// repositories for vulnerability surface analysis.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.Download;
|
||||
|
||||
/// <summary>
|
||||
/// Downloads Maven packages (JARs) from Maven Central or custom repositories.
|
||||
/// Maven coordinates: groupId:artifactId:version
|
||||
/// </summary>
|
||||
public sealed class MavenPackageDownloader : IPackageDownloader
|
||||
{
|
||||
private const string DefaultRepositoryUrl = "https://repo1.maven.org/maven2";
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<MavenPackageDownloader> _logger;
|
||||
private readonly MavenDownloaderOptions _options;
|
||||
|
||||
public MavenPackageDownloader(
|
||||
HttpClient httpClient,
|
||||
ILogger<MavenPackageDownloader> logger,
|
||||
IOptions<MavenDownloaderOptions> options)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? new MavenDownloaderOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Ecosystem => "maven";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PackageDownloadResult> DownloadAsync(
|
||||
PackageDownloadRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
// Parse Maven coordinates (groupId:artifactId or just artifactId for simple cases)
|
||||
var (groupId, artifactId) = ParseCoordinates(request.PackageName);
|
||||
var version = request.Version;
|
||||
var safeArtifactId = GetSafeDirectoryName(groupId, artifactId);
|
||||
|
||||
var extractedDir = Path.Combine(request.OutputDirectory, $"{safeArtifactId}-{version}");
|
||||
var archivePath = Path.Combine(request.OutputDirectory, $"{safeArtifactId}-{version}.jar");
|
||||
|
||||
// Check cache first
|
||||
if (request.UseCache && Directory.Exists(extractedDir))
|
||||
{
|
||||
sw.Stop();
|
||||
_logger.LogDebug("Using cached Maven package {GroupId}:{ArtifactId} v{Version}",
|
||||
groupId, artifactId, version);
|
||||
return PackageDownloadResult.Ok(extractedDir, archivePath, sw.Elapsed, fromCache: true);
|
||||
}
|
||||
|
||||
// Build download URL
|
||||
// Maven Central path: /<groupId with / instead of .>/<artifactId>/<version>/<artifactId>-<version>.jar
|
||||
var repositoryUrl = request.RegistryUrl ?? _options.RepositoryUrl ?? DefaultRepositoryUrl;
|
||||
var groupPath = groupId.Replace('.', '/');
|
||||
var jarUrl = $"{repositoryUrl}/{groupPath}/{artifactId}/{version}/{artifactId}-{version}.jar";
|
||||
|
||||
_logger.LogDebug("Downloading Maven JAR from {Url}", jarUrl);
|
||||
|
||||
// Download JAR
|
||||
Directory.CreateDirectory(request.OutputDirectory);
|
||||
|
||||
using var response = await _httpClient.GetAsync(jarUrl, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
// Try sources JAR as fallback for source analysis
|
||||
var sourcesUrl = $"{repositoryUrl}/{groupPath}/{artifactId}/{version}/{artifactId}-{version}-sources.jar";
|
||||
_logger.LogDebug("Primary JAR not found, trying sources JAR from {Url}", sourcesUrl);
|
||||
|
||||
using var sourcesResponse = await _httpClient.GetAsync(sourcesUrl, cancellationToken);
|
||||
|
||||
if (!sourcesResponse.IsSuccessStatusCode)
|
||||
{
|
||||
sw.Stop();
|
||||
var error = $"Failed to download: HTTP {(int)response.StatusCode} {response.ReasonPhrase}";
|
||||
_logger.LogWarning("Maven download failed for {GroupId}:{ArtifactId} v{Version}: {Error}",
|
||||
groupId, artifactId, version, error);
|
||||
return PackageDownloadResult.Fail(error, sw.Elapsed);
|
||||
}
|
||||
|
||||
// Save sources JAR
|
||||
await using (var fs = File.Create(archivePath))
|
||||
{
|
||||
await sourcesResponse.Content.CopyToAsync(fs, cancellationToken);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Save primary JAR
|
||||
await using (var fs = File.Create(archivePath))
|
||||
{
|
||||
await response.Content.CopyToAsync(fs, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract JAR (it's just a ZIP file)
|
||||
if (Directory.Exists(extractedDir))
|
||||
{
|
||||
Directory.Delete(extractedDir, recursive: true);
|
||||
}
|
||||
|
||||
ZipFile.ExtractToDirectory(archivePath, extractedDir);
|
||||
|
||||
sw.Stop();
|
||||
_logger.LogDebug("Downloaded and extracted Maven {GroupId}:{ArtifactId} v{Version} in {Duration}ms",
|
||||
groupId, artifactId, version, sw.ElapsedMilliseconds);
|
||||
|
||||
return PackageDownloadResult.Ok(extractedDir, archivePath, sw.Elapsed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
_logger.LogWarning(ex, "Failed to download Maven package {Package} v{Version}",
|
||||
request.PackageName, request.Version);
|
||||
return PackageDownloadResult.Fail(ex.Message, sw.Elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses Maven coordinates from package name.
|
||||
/// Formats: "groupId:artifactId" or just "artifactId" (assumes default group).
|
||||
/// </summary>
|
||||
private (string groupId, string artifactId) ParseCoordinates(string packageName)
|
||||
{
|
||||
var parts = packageName.Split(':');
|
||||
if (parts.Length >= 2)
|
||||
{
|
||||
return (parts[0], parts[1]);
|
||||
}
|
||||
|
||||
// If no groupId provided, assume the package name is the artifactId
|
||||
// and try to derive groupId from common patterns
|
||||
return (packageName, packageName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a safe directory name from Maven coordinates.
|
||||
/// </summary>
|
||||
private static string GetSafeDirectoryName(string groupId, string artifactId)
|
||||
{
|
||||
// Use artifactId primarily, prefixed with last segment of groupId if different
|
||||
var groupLastPart = groupId.Split('.')[^1];
|
||||
if (groupLastPart.Equals(artifactId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return artifactId;
|
||||
}
|
||||
|
||||
return $"{groupLastPart}.{artifactId}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for Maven package downloader.
|
||||
/// </summary>
|
||||
public sealed class MavenDownloaderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Custom repository URL (null for Maven Central).
|
||||
/// </summary>
|
||||
public string? RepositoryUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cache directory for downloaded packages.
|
||||
/// </summary>
|
||||
public string? CacheDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum package size in bytes (0 for unlimited).
|
||||
/// </summary>
|
||||
public long MaxPackageSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to prefer sources JARs for analysis.
|
||||
/// </summary>
|
||||
public bool PreferSourcesJar { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// NpmPackageDownloader.cs
|
||||
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core (SURF-004)
|
||||
// Description: Downloads npm packages from registry.npmjs.org for vulnerability
|
||||
// surface analysis.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SharpCompress.Archives;
|
||||
using SharpCompress.Archives.Tar;
|
||||
using SharpCompress.Common;
|
||||
using SharpCompress.Readers;
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.Download;
|
||||
|
||||
/// <summary>
|
||||
/// Downloads npm packages from registry.npmjs.org or custom registries.
|
||||
/// npm packages are distributed as .tgz (gzipped tarball) files.
|
||||
/// </summary>
|
||||
public sealed class NpmPackageDownloader : IPackageDownloader
|
||||
{
|
||||
private const string DefaultRegistryUrl = "https://registry.npmjs.org";
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<NpmPackageDownloader> _logger;
|
||||
private readonly NpmDownloaderOptions _options;
|
||||
|
||||
public NpmPackageDownloader(
|
||||
HttpClient httpClient,
|
||||
ILogger<NpmPackageDownloader> logger,
|
||||
IOptions<NpmDownloaderOptions> options)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? new NpmDownloaderOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Ecosystem => "npm";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PackageDownloadResult> DownloadAsync(
|
||||
PackageDownloadRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
// Normalize package name (npm uses lowercase, scoped packages have @scope/name)
|
||||
var packageName = request.PackageName;
|
||||
var safePackageName = GetSafeDirectoryName(packageName);
|
||||
var extractedDir = Path.Combine(request.OutputDirectory, $"{safePackageName}-{request.Version}");
|
||||
var archivePath = Path.Combine(request.OutputDirectory, $"{safePackageName}-{request.Version}.tgz");
|
||||
|
||||
// Check cache first
|
||||
if (request.UseCache && Directory.Exists(extractedDir))
|
||||
{
|
||||
sw.Stop();
|
||||
_logger.LogDebug("Using cached npm package {Package} v{Version}", packageName, request.Version);
|
||||
return PackageDownloadResult.Ok(extractedDir, archivePath, sw.Elapsed, fromCache: true);
|
||||
}
|
||||
|
||||
// Get package metadata to find tarball URL
|
||||
var registryUrl = request.RegistryUrl ?? _options.RegistryUrl ?? DefaultRegistryUrl;
|
||||
var tarballUrl = await GetTarballUrlAsync(registryUrl, packageName, request.Version, cancellationToken);
|
||||
|
||||
if (tarballUrl is null)
|
||||
{
|
||||
sw.Stop();
|
||||
var error = $"Version {request.Version} not found for package {packageName}";
|
||||
_logger.LogWarning("npm package not found: {Error}", error);
|
||||
return PackageDownloadResult.Fail(error, sw.Elapsed);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Downloading npm package from {Url}", tarballUrl);
|
||||
|
||||
// Download tarball
|
||||
Directory.CreateDirectory(request.OutputDirectory);
|
||||
|
||||
using var response = await _httpClient.GetAsync(tarballUrl, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
sw.Stop();
|
||||
var error = $"Failed to download: HTTP {(int)response.StatusCode} {response.ReasonPhrase}";
|
||||
_logger.LogWarning("npm download failed for {Package} v{Version}: {Error}",
|
||||
packageName, request.Version, error);
|
||||
return PackageDownloadResult.Fail(error, sw.Elapsed);
|
||||
}
|
||||
|
||||
// Save archive
|
||||
await using (var fs = File.Create(archivePath))
|
||||
{
|
||||
await response.Content.CopyToAsync(fs, cancellationToken);
|
||||
}
|
||||
|
||||
// Extract .tgz (gzipped tarball)
|
||||
if (Directory.Exists(extractedDir))
|
||||
{
|
||||
Directory.Delete(extractedDir, recursive: true);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(extractedDir);
|
||||
ExtractTgz(archivePath, extractedDir);
|
||||
|
||||
sw.Stop();
|
||||
_logger.LogDebug("Downloaded and extracted npm {Package} v{Version} in {Duration}ms",
|
||||
packageName, request.Version, sw.ElapsedMilliseconds);
|
||||
|
||||
return PackageDownloadResult.Ok(extractedDir, archivePath, sw.Elapsed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
_logger.LogWarning(ex, "Failed to download npm package {Package} v{Version}",
|
||||
request.PackageName, request.Version);
|
||||
return PackageDownloadResult.Fail(ex.Message, sw.Elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tarball URL from the npm registry metadata.
|
||||
/// </summary>
|
||||
private async Task<string?> GetTarballUrlAsync(
|
||||
string registryUrl,
|
||||
string packageName,
|
||||
string version,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Encode scoped packages (@scope/name → @scope%2fname)
|
||||
var encodedName = Uri.EscapeDataString(packageName).Replace("%40", "@");
|
||||
var metadataUrl = $"{registryUrl}/{encodedName}";
|
||||
|
||||
using var response = await _httpClient.GetAsync(metadataUrl, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogDebug("Failed to fetch npm metadata for {Package}: HTTP {StatusCode}",
|
||||
packageName, (int)response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
|
||||
|
||||
// Look for versions.<version>.dist.tarball
|
||||
if (doc.RootElement.TryGetProperty("versions", out var versions) &&
|
||||
versions.TryGetProperty(version, out var versionObj) &&
|
||||
versionObj.TryGetProperty("dist", out var dist) &&
|
||||
dist.TryGetProperty("tarball", out var tarball))
|
||||
{
|
||||
return tarball.GetString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a .tgz file (gzipped tarball) to the specified directory.
|
||||
/// </summary>
|
||||
private static void ExtractTgz(string tgzPath, string destinationDir)
|
||||
{
|
||||
using var archive = ArchiveFactory.Open(tgzPath);
|
||||
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
if (entry.IsDirectory)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// npm packages have a "package/" prefix in the tarball
|
||||
var entryPath = entry.Key ?? string.Empty;
|
||||
if (entryPath.StartsWith("package/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
entryPath = entryPath["package/".Length..];
|
||||
}
|
||||
|
||||
var destPath = Path.Combine(destinationDir, entryPath);
|
||||
var destDir = Path.GetDirectoryName(destPath);
|
||||
|
||||
if (!string.IsNullOrEmpty(destDir))
|
||||
{
|
||||
Directory.CreateDirectory(destDir);
|
||||
}
|
||||
|
||||
entry.WriteToFile(destPath, new ExtractionOptions
|
||||
{
|
||||
ExtractFullPath = false,
|
||||
Overwrite = true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a package name to a safe directory name.
|
||||
/// Handles scoped packages like @scope/name → scope-name
|
||||
/// </summary>
|
||||
private static string GetSafeDirectoryName(string packageName)
|
||||
{
|
||||
return packageName
|
||||
.Replace("@", string.Empty)
|
||||
.Replace("/", "-")
|
||||
.Replace("\\", "-");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for npm package downloader.
|
||||
/// </summary>
|
||||
public sealed class NpmDownloaderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Custom registry URL (null for registry.npmjs.org).
|
||||
/// </summary>
|
||||
public string? RegistryUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cache directory for downloaded packages.
|
||||
/// </summary>
|
||||
public string? CacheDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum package size in bytes (0 for unlimited).
|
||||
/// </summary>
|
||||
public long MaxPackageSize { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PyPIPackageDownloader.cs
|
||||
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core (SURF-006)
|
||||
// Description: Downloads Python packages from PyPI for vulnerability surface
|
||||
// analysis. Supports both wheel (.whl) and source distributions.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SharpCompress.Archives;
|
||||
using SharpCompress.Common;
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.Download;
|
||||
|
||||
/// <summary>
|
||||
/// Downloads Python packages from PyPI (Python Package Index).
|
||||
/// Supports wheel (.whl) and source distribution (.tar.gz) formats.
|
||||
/// </summary>
|
||||
public sealed class PyPIPackageDownloader : IPackageDownloader
|
||||
{
|
||||
private const string DefaultRegistryUrl = "https://pypi.org/pypi";
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<PyPIPackageDownloader> _logger;
|
||||
private readonly PyPIDownloaderOptions _options;
|
||||
|
||||
public PyPIPackageDownloader(
|
||||
HttpClient httpClient,
|
||||
ILogger<PyPIPackageDownloader> logger,
|
||||
IOptions<PyPIDownloaderOptions> options)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? new PyPIDownloaderOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Ecosystem => "pypi";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PackageDownloadResult> DownloadAsync(
|
||||
PackageDownloadRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
// Normalize package name (PyPI uses lowercase with hyphens)
|
||||
var normalizedName = NormalizePackageName(request.PackageName);
|
||||
var safePackageName = GetSafeDirectoryName(normalizedName);
|
||||
var extractedDir = Path.Combine(request.OutputDirectory, $"{safePackageName}-{request.Version}");
|
||||
|
||||
// Check cache first
|
||||
if (request.UseCache && Directory.Exists(extractedDir))
|
||||
{
|
||||
sw.Stop();
|
||||
_logger.LogDebug("Using cached PyPI package {Package} v{Version}",
|
||||
request.PackageName, request.Version);
|
||||
return PackageDownloadResult.Ok(extractedDir, string.Empty, sw.Elapsed, fromCache: true);
|
||||
}
|
||||
|
||||
// Get package metadata to find download URL
|
||||
var registryUrl = request.RegistryUrl ?? _options.RegistryUrl ?? DefaultRegistryUrl;
|
||||
var downloadInfo = await GetDownloadUrlAsync(registryUrl, normalizedName, request.Version, cancellationToken);
|
||||
|
||||
if (downloadInfo is null)
|
||||
{
|
||||
sw.Stop();
|
||||
var error = $"Version {request.Version} not found for package {request.PackageName}";
|
||||
_logger.LogWarning("PyPI package not found: {Error}", error);
|
||||
return PackageDownloadResult.Fail(error, sw.Elapsed);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Downloading PyPI package from {Url} (type: {Type})",
|
||||
downloadInfo.Url, downloadInfo.PackageType);
|
||||
|
||||
// Download package
|
||||
Directory.CreateDirectory(request.OutputDirectory);
|
||||
|
||||
using var response = await _httpClient.GetAsync(downloadInfo.Url, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
sw.Stop();
|
||||
var error = $"Failed to download: HTTP {(int)response.StatusCode} {response.ReasonPhrase}";
|
||||
_logger.LogWarning("PyPI download failed for {Package} v{Version}: {Error}",
|
||||
request.PackageName, request.Version, error);
|
||||
return PackageDownloadResult.Fail(error, sw.Elapsed);
|
||||
}
|
||||
|
||||
// Determine archive extension and path
|
||||
var extension = downloadInfo.PackageType == "bdist_wheel" ? ".whl" : ".tar.gz";
|
||||
var archivePath = Path.Combine(request.OutputDirectory, $"{safePackageName}-{request.Version}{extension}");
|
||||
|
||||
// Save archive
|
||||
await using (var fs = File.Create(archivePath))
|
||||
{
|
||||
await response.Content.CopyToAsync(fs, cancellationToken);
|
||||
}
|
||||
|
||||
// Extract
|
||||
if (Directory.Exists(extractedDir))
|
||||
{
|
||||
Directory.Delete(extractedDir, recursive: true);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(extractedDir);
|
||||
|
||||
if (downloadInfo.PackageType == "bdist_wheel")
|
||||
{
|
||||
// Wheel files are ZIP archives
|
||||
ZipFile.ExtractToDirectory(archivePath, extractedDir);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Source distributions are .tar.gz
|
||||
ExtractTarGz(archivePath, extractedDir);
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
_logger.LogDebug("Downloaded and extracted PyPI {Package} v{Version} in {Duration}ms",
|
||||
request.PackageName, request.Version, sw.ElapsedMilliseconds);
|
||||
|
||||
return PackageDownloadResult.Ok(extractedDir, archivePath, sw.Elapsed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
_logger.LogWarning(ex, "Failed to download PyPI package {Package} v{Version}",
|
||||
request.PackageName, request.Version);
|
||||
return PackageDownloadResult.Fail(ex.Message, sw.Elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the download URL from PyPI JSON API.
|
||||
/// Prefers source distributions for better AST analysis.
|
||||
/// </summary>
|
||||
private async Task<PyPIDownloadInfo?> GetDownloadUrlAsync(
|
||||
string registryUrl,
|
||||
string packageName,
|
||||
string version,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var metadataUrl = $"{registryUrl}/{packageName}/{version}/json";
|
||||
|
||||
using var response = await _httpClient.GetAsync(metadataUrl, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogDebug("Failed to fetch PyPI metadata for {Package} v{Version}: HTTP {StatusCode}",
|
||||
packageName, version, (int)response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
|
||||
|
||||
if (!doc.RootElement.TryGetProperty("urls", out var urls))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prefer source distribution for AST analysis, fall back to wheel
|
||||
PyPIDownloadInfo? sourceDistribution = null;
|
||||
PyPIDownloadInfo? wheel = null;
|
||||
|
||||
foreach (var urlEntry in urls.EnumerateArray())
|
||||
{
|
||||
var packageType = urlEntry.TryGetProperty("packagetype", out var pt) ? pt.GetString() : null;
|
||||
var url = urlEntry.TryGetProperty("url", out var u) ? u.GetString() : null;
|
||||
|
||||
if (url is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (packageType == "sdist")
|
||||
{
|
||||
sourceDistribution = new PyPIDownloadInfo(url, "sdist");
|
||||
}
|
||||
else if (packageType == "bdist_wheel" && wheel is null)
|
||||
{
|
||||
wheel = new PyPIDownloadInfo(url, "bdist_wheel");
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer source distribution for better Python AST analysis
|
||||
return _options.PreferSourceDistribution
|
||||
? (sourceDistribution ?? wheel)
|
||||
: (wheel ?? sourceDistribution);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a .tar.gz file to the specified directory.
|
||||
/// </summary>
|
||||
private static void ExtractTarGz(string tarGzPath, string destinationDir)
|
||||
{
|
||||
using var archive = ArchiveFactory.Open(tarGzPath);
|
||||
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
if (entry.IsDirectory)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var entryPath = entry.Key ?? string.Empty;
|
||||
|
||||
// Source distributions typically have a top-level directory like "package-1.0.0/"
|
||||
// Remove it to flatten the structure
|
||||
var pathParts = entryPath.Split('/');
|
||||
if (pathParts.Length > 1)
|
||||
{
|
||||
entryPath = string.Join('/', pathParts.Skip(1));
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(entryPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var destPath = Path.Combine(destinationDir, entryPath);
|
||||
var destDir = Path.GetDirectoryName(destPath);
|
||||
|
||||
if (!string.IsNullOrEmpty(destDir))
|
||||
{
|
||||
Directory.CreateDirectory(destDir);
|
||||
}
|
||||
|
||||
entry.WriteToFile(destPath, new ExtractionOptions
|
||||
{
|
||||
ExtractFullPath = false,
|
||||
Overwrite = true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a PyPI package name (lowercase, hyphens).
|
||||
/// </summary>
|
||||
private static string NormalizePackageName(string packageName)
|
||||
{
|
||||
return packageName.ToLowerInvariant().Replace('_', '-');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a safe directory name from package name.
|
||||
/// </summary>
|
||||
private static string GetSafeDirectoryName(string packageName)
|
||||
{
|
||||
return packageName.Replace('-', '_');
|
||||
}
|
||||
|
||||
private sealed record PyPIDownloadInfo(string Url, string PackageType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for PyPI package downloader.
|
||||
/// </summary>
|
||||
public sealed class PyPIDownloaderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Custom registry URL (null for pypi.org).
|
||||
/// </summary>
|
||||
public string? RegistryUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cache directory for downloaded packages.
|
||||
/// </summary>
|
||||
public string? CacheDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum package size in bytes (0 for unlimited).
|
||||
/// </summary>
|
||||
public long MaxPackageSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to prefer source distributions over wheels.
|
||||
/// Default true for better AST analysis.
|
||||
/// </summary>
|
||||
public bool PreferSourceDistribution { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// JavaBytecodeFingerprinter.cs
|
||||
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core (SURF-010)
|
||||
// Description: Java method fingerprinting using bytecode parsing.
|
||||
// Parses .class files from JAR archives for method extraction.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.Fingerprint;
|
||||
|
||||
/// <summary>
|
||||
/// Computes method fingerprints for Java packages using bytecode hashing.
|
||||
/// Parses .class files from extracted JAR archives.
|
||||
/// </summary>
|
||||
public sealed class JavaBytecodeFingerprinter : IMethodFingerprinter
|
||||
{
|
||||
private readonly ILogger<JavaBytecodeFingerprinter> _logger;
|
||||
|
||||
// Java class file magic number
|
||||
private const uint ClassFileMagic = 0xCAFEBABE;
|
||||
|
||||
public JavaBytecodeFingerprinter(ILogger<JavaBytecodeFingerprinter> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Ecosystem => "maven";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FingerprintResult> FingerprintAsync(
|
||||
FingerprintRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var methods = new Dictionary<string, MethodFingerprint>(StringComparer.Ordinal);
|
||||
|
||||
try
|
||||
{
|
||||
var classFiles = GetClassFiles(request.PackagePath);
|
||||
var filesProcessed = 0;
|
||||
|
||||
foreach (var classPath in classFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
await ProcessClassFileAsync(classPath, request.PackagePath, methods, request, cancellationToken);
|
||||
filesProcessed++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to process class file {Path}", classPath);
|
||||
}
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
_logger.LogDebug(
|
||||
"Fingerprinted {MethodCount} methods from {FileCount} class files in {Duration}ms",
|
||||
methods.Count, filesProcessed, sw.ElapsedMilliseconds);
|
||||
|
||||
return FingerprintResult.Ok(methods, sw.Elapsed, filesProcessed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
_logger.LogWarning(ex, "Failed to fingerprint Java package at {Path}", request.PackagePath);
|
||||
return FingerprintResult.Fail(ex.Message, sw.Elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
private static string[] GetClassFiles(string packagePath)
|
||||
{
|
||||
if (!Directory.Exists(packagePath))
|
||||
return [];
|
||||
|
||||
return Directory.GetFiles(packagePath, "*.class", SearchOption.AllDirectories)
|
||||
.Where(f =>
|
||||
{
|
||||
// Skip META-INF and common non-source directories
|
||||
var relativePath = f.Replace(packagePath, "").TrimStart(Path.DirectorySeparatorChar);
|
||||
return !relativePath.StartsWith("META-INF", StringComparison.OrdinalIgnoreCase);
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private async Task ProcessClassFileAsync(
|
||||
string classPath,
|
||||
string packagePath,
|
||||
Dictionary<string, MethodFingerprint> methods,
|
||||
FingerprintRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bytes = await File.ReadAllBytesAsync(classPath, cancellationToken);
|
||||
|
||||
if (bytes.Length < 10)
|
||||
return;
|
||||
|
||||
// Verify magic number
|
||||
var magic = BinaryPrimitives.ReadUInt32BigEndian(bytes);
|
||||
if (magic != ClassFileMagic)
|
||||
{
|
||||
_logger.LogDebug("Invalid class file magic in {Path}", classPath);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var classInfo = ParseClassFile(bytes);
|
||||
var relativePath = Path.GetRelativePath(packagePath, classPath);
|
||||
|
||||
foreach (var method in classInfo.Methods)
|
||||
{
|
||||
// Skip private methods unless requested
|
||||
if (!request.IncludePrivateMethods && !method.IsPublic && !method.IsProtected)
|
||||
continue;
|
||||
|
||||
// Skip synthetic and bridge methods
|
||||
if (method.IsSynthetic || method.IsBridge)
|
||||
continue;
|
||||
|
||||
var methodKey = $"{classInfo.ClassName}::{method.Name}{method.Descriptor}";
|
||||
|
||||
methods[methodKey] = new MethodFingerprint
|
||||
{
|
||||
MethodKey = methodKey,
|
||||
DeclaringType = classInfo.ClassName,
|
||||
Name = method.Name,
|
||||
Signature = ParseDescriptor(method.Descriptor),
|
||||
BodyHash = method.BodyHash,
|
||||
SignatureHash = ComputeHash(method.Descriptor),
|
||||
IsPublic = method.IsPublic,
|
||||
BodySize = method.CodeLength,
|
||||
SourceFile = relativePath
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error parsing class file {Path}", classPath);
|
||||
}
|
||||
}
|
||||
|
||||
private JavaClassInfo ParseClassFile(byte[] bytes)
|
||||
{
|
||||
var reader = new JavaClassReader(bytes);
|
||||
|
||||
// Skip magic (already verified)
|
||||
reader.Skip(4);
|
||||
|
||||
// Version info
|
||||
_ = reader.ReadU2(); // minor version
|
||||
_ = reader.ReadU2(); // major version
|
||||
|
||||
// Constant pool
|
||||
var constantPool = ParseConstantPool(reader);
|
||||
|
||||
// Access flags
|
||||
var accessFlags = reader.ReadU2();
|
||||
|
||||
// This class
|
||||
var thisClassIndex = reader.ReadU2();
|
||||
var className = ResolveClassName(constantPool, thisClassIndex);
|
||||
|
||||
// Super class
|
||||
_ = reader.ReadU2(); // super class index
|
||||
|
||||
// Interfaces
|
||||
var interfaceCount = reader.ReadU2();
|
||||
reader.Skip(interfaceCount * 2);
|
||||
|
||||
// Fields
|
||||
var fieldCount = reader.ReadU2();
|
||||
for (var i = 0; i < fieldCount; i++)
|
||||
{
|
||||
SkipFieldOrMethod(reader);
|
||||
}
|
||||
|
||||
// Methods
|
||||
var methodCount = reader.ReadU2();
|
||||
var methods = new List<JavaMethodInfo>();
|
||||
|
||||
for (var i = 0; i < methodCount; i++)
|
||||
{
|
||||
var method = ParseMethod(reader, constantPool);
|
||||
methods.Add(method);
|
||||
}
|
||||
|
||||
return new JavaClassInfo
|
||||
{
|
||||
ClassName = className,
|
||||
AccessFlags = accessFlags,
|
||||
Methods = methods
|
||||
};
|
||||
}
|
||||
|
||||
private static List<ConstantPoolEntry> ParseConstantPool(JavaClassReader reader)
|
||||
{
|
||||
var count = reader.ReadU2();
|
||||
var pool = new List<ConstantPoolEntry>(count) { new() }; // Index 0 is unused
|
||||
|
||||
for (var i = 1; i < count; i++)
|
||||
{
|
||||
var tag = reader.ReadU1();
|
||||
var entry = new ConstantPoolEntry { Tag = tag };
|
||||
|
||||
switch (tag)
|
||||
{
|
||||
case 1: // CONSTANT_Utf8
|
||||
var length = reader.ReadU2();
|
||||
entry.StringValue = Encoding.UTF8.GetString(reader.ReadBytes(length));
|
||||
break;
|
||||
case 3: // CONSTANT_Integer
|
||||
case 4: // CONSTANT_Float
|
||||
reader.Skip(4);
|
||||
break;
|
||||
case 5: // CONSTANT_Long
|
||||
case 6: // CONSTANT_Double
|
||||
reader.Skip(8);
|
||||
pool.Add(new ConstantPoolEntry()); // Takes two entries
|
||||
i++;
|
||||
break;
|
||||
case 7: // CONSTANT_Class
|
||||
case 8: // CONSTANT_String
|
||||
entry.NameIndex = reader.ReadU2();
|
||||
break;
|
||||
case 9: // CONSTANT_Fieldref
|
||||
case 10: // CONSTANT_Methodref
|
||||
case 11: // CONSTANT_InterfaceMethodref
|
||||
entry.ClassIndex = reader.ReadU2();
|
||||
entry.NameAndTypeIndex = reader.ReadU2();
|
||||
break;
|
||||
case 12: // CONSTANT_NameAndType
|
||||
entry.NameIndex = reader.ReadU2();
|
||||
entry.DescriptorIndex = reader.ReadU2();
|
||||
break;
|
||||
case 15: // CONSTANT_MethodHandle
|
||||
reader.Skip(3);
|
||||
break;
|
||||
case 16: // CONSTANT_MethodType
|
||||
reader.Skip(2);
|
||||
break;
|
||||
case 17: // CONSTANT_Dynamic
|
||||
case 18: // CONSTANT_InvokeDynamic
|
||||
reader.Skip(4);
|
||||
break;
|
||||
case 19: // CONSTANT_Module
|
||||
case 20: // CONSTANT_Package
|
||||
reader.Skip(2);
|
||||
break;
|
||||
}
|
||||
|
||||
pool.Add(entry);
|
||||
}
|
||||
|
||||
return pool;
|
||||
}
|
||||
|
||||
private static JavaMethodInfo ParseMethod(JavaClassReader reader, List<ConstantPoolEntry> constantPool)
|
||||
{
|
||||
var accessFlags = reader.ReadU2();
|
||||
var nameIndex = reader.ReadU2();
|
||||
var descriptorIndex = reader.ReadU2();
|
||||
|
||||
var name = GetUtf8(constantPool, nameIndex);
|
||||
var descriptor = GetUtf8(constantPool, descriptorIndex);
|
||||
|
||||
// Attributes
|
||||
var attributeCount = reader.ReadU2();
|
||||
var codeBytes = Array.Empty<byte>();
|
||||
var codeLength = 0;
|
||||
|
||||
for (var i = 0; i < attributeCount; i++)
|
||||
{
|
||||
var attrNameIndex = reader.ReadU2();
|
||||
var attrLength = reader.ReadU4();
|
||||
var attrName = GetUtf8(constantPool, attrNameIndex);
|
||||
|
||||
if (attrName == "Code")
|
||||
{
|
||||
// max_stack (2) + max_locals (2) + code_length (4)
|
||||
reader.Skip(4);
|
||||
codeLength = (int)reader.ReadU4();
|
||||
codeBytes = reader.ReadBytes(codeLength);
|
||||
|
||||
// Skip exception table and code attributes
|
||||
var remainingLength = attrLength - 8 - codeLength;
|
||||
reader.Skip((int)remainingLength);
|
||||
}
|
||||
else
|
||||
{
|
||||
reader.Skip((int)attrLength);
|
||||
}
|
||||
}
|
||||
|
||||
return new JavaMethodInfo
|
||||
{
|
||||
Name = name,
|
||||
Descriptor = descriptor,
|
||||
AccessFlags = accessFlags,
|
||||
CodeLength = codeLength,
|
||||
BodyHash = ComputeHash(codeBytes)
|
||||
};
|
||||
}
|
||||
|
||||
private static void SkipFieldOrMethod(JavaClassReader reader)
|
||||
{
|
||||
reader.Skip(6); // access_flags + name_index + descriptor_index
|
||||
|
||||
var attributeCount = reader.ReadU2();
|
||||
for (var i = 0; i < attributeCount; i++)
|
||||
{
|
||||
reader.Skip(2); // attribute_name_index
|
||||
var length = reader.ReadU4();
|
||||
reader.Skip((int)length);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveClassName(List<ConstantPoolEntry> pool, int classIndex)
|
||||
{
|
||||
if (classIndex <= 0 || classIndex >= pool.Count)
|
||||
return "Unknown";
|
||||
|
||||
var classEntry = pool[classIndex];
|
||||
if (classEntry.Tag != 7)
|
||||
return "Unknown";
|
||||
|
||||
return GetUtf8(pool, classEntry.NameIndex).Replace('/', '.');
|
||||
}
|
||||
|
||||
private static string GetUtf8(List<ConstantPoolEntry> pool, int index)
|
||||
{
|
||||
if (index <= 0 || index >= pool.Count)
|
||||
return string.Empty;
|
||||
|
||||
return pool[index].StringValue ?? string.Empty;
|
||||
}
|
||||
|
||||
private static string ParseDescriptor(string descriptor)
|
||||
{
|
||||
// Convert Java method descriptor to readable signature
|
||||
// e.g., (Ljava/lang/String;I)V -> (String, int) void
|
||||
var sb = new StringBuilder();
|
||||
var i = 0;
|
||||
|
||||
if (descriptor.StartsWith('('))
|
||||
{
|
||||
sb.Append('(');
|
||||
i = 1;
|
||||
var first = true;
|
||||
|
||||
while (i < descriptor.Length && descriptor[i] != ')')
|
||||
{
|
||||
if (!first) sb.Append(", ");
|
||||
first = false;
|
||||
|
||||
var (typeName, newIndex) = ParseType(descriptor, i);
|
||||
sb.Append(typeName);
|
||||
i = newIndex;
|
||||
}
|
||||
|
||||
sb.Append(')');
|
||||
i++; // Skip ')'
|
||||
}
|
||||
|
||||
if (i < descriptor.Length)
|
||||
{
|
||||
var (returnType, _) = ParseType(descriptor, i);
|
||||
sb.Append(" -> ");
|
||||
sb.Append(returnType);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static (string typeName, int newIndex) ParseType(string descriptor, int index)
|
||||
{
|
||||
if (index >= descriptor.Length)
|
||||
return ("void", index);
|
||||
|
||||
var c = descriptor[index];
|
||||
|
||||
return c switch
|
||||
{
|
||||
'B' => ("byte", index + 1),
|
||||
'C' => ("char", index + 1),
|
||||
'D' => ("double", index + 1),
|
||||
'F' => ("float", index + 1),
|
||||
'I' => ("int", index + 1),
|
||||
'J' => ("long", index + 1),
|
||||
'S' => ("short", index + 1),
|
||||
'Z' => ("boolean", index + 1),
|
||||
'V' => ("void", index + 1),
|
||||
'[' => ParseArrayType(descriptor, index),
|
||||
'L' => ParseObjectType(descriptor, index),
|
||||
_ => ("?", index + 1)
|
||||
};
|
||||
}
|
||||
|
||||
private static (string typeName, int newIndex) ParseArrayType(string descriptor, int index)
|
||||
{
|
||||
var (elementType, newIndex) = ParseType(descriptor, index + 1);
|
||||
return ($"{elementType}[]", newIndex);
|
||||
}
|
||||
|
||||
private static (string typeName, int newIndex) ParseObjectType(string descriptor, int index)
|
||||
{
|
||||
var semicolonIndex = descriptor.IndexOf(';', index);
|
||||
if (semicolonIndex < 0)
|
||||
return ("Object", index + 1);
|
||||
|
||||
var className = descriptor[(index + 1)..semicolonIndex];
|
||||
var simpleName = className.Split('/')[^1];
|
||||
return (simpleName, semicolonIndex + 1);
|
||||
}
|
||||
|
||||
private static string ComputeHash(byte[] data)
|
||||
{
|
||||
if (data.Length == 0)
|
||||
return "empty";
|
||||
|
||||
var hashBytes = SHA256.HashData(data);
|
||||
return Convert.ToHexStringLower(hashBytes[..16]);
|
||||
}
|
||||
|
||||
private static string ComputeHash(string data)
|
||||
{
|
||||
if (string.IsNullOrEmpty(data))
|
||||
return "empty";
|
||||
|
||||
return ComputeHash(Encoding.UTF8.GetBytes(data));
|
||||
}
|
||||
|
||||
private sealed class JavaClassReader(byte[] data)
|
||||
{
|
||||
private int _position;
|
||||
|
||||
public byte ReadU1() => data[_position++];
|
||||
|
||||
public ushort ReadU2()
|
||||
{
|
||||
var value = BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(_position));
|
||||
_position += 2;
|
||||
return value;
|
||||
}
|
||||
|
||||
public uint ReadU4()
|
||||
{
|
||||
var value = BinaryPrimitives.ReadUInt32BigEndian(data.AsSpan(_position));
|
||||
_position += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
public byte[] ReadBytes(int count)
|
||||
{
|
||||
var result = data[_position..(_position + count)];
|
||||
_position += count;
|
||||
return result;
|
||||
}
|
||||
|
||||
public void Skip(int count) => _position += count;
|
||||
}
|
||||
|
||||
private sealed class ConstantPoolEntry
|
||||
{
|
||||
public byte Tag { get; init; }
|
||||
public string? StringValue { get; set; }
|
||||
public int NameIndex { get; set; }
|
||||
public int DescriptorIndex { get; set; }
|
||||
public int ClassIndex { get; set; }
|
||||
public int NameAndTypeIndex { get; set; }
|
||||
}
|
||||
|
||||
private sealed record JavaClassInfo
|
||||
{
|
||||
public required string ClassName { get; init; }
|
||||
public ushort AccessFlags { get; init; }
|
||||
public required List<JavaMethodInfo> Methods { get; init; }
|
||||
}
|
||||
|
||||
private sealed record JavaMethodInfo
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string Descriptor { get; init; }
|
||||
public ushort AccessFlags { get; init; }
|
||||
public int CodeLength { get; init; }
|
||||
public required string BodyHash { get; init; }
|
||||
|
||||
public bool IsPublic => (AccessFlags & 0x0001) != 0;
|
||||
public bool IsProtected => (AccessFlags & 0x0004) != 0;
|
||||
public bool IsSynthetic => (AccessFlags & 0x1000) != 0;
|
||||
public bool IsBridge => (AccessFlags & 0x0040) != 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// JavaScriptMethodFingerprinter.cs
|
||||
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core (SURF-009)
|
||||
// Description: JavaScript/Node.js method fingerprinting using AST hashing.
|
||||
// Uses Acornima for JavaScript parsing in .NET.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.Fingerprint;
|
||||
|
||||
/// <summary>
|
||||
/// Computes method fingerprints for JavaScript/Node.js packages using AST-based hashing.
|
||||
/// Parses .js/.mjs/.cjs files and extracts function declarations, methods, and arrow functions.
|
||||
/// </summary>
|
||||
public sealed partial class JavaScriptMethodFingerprinter : IMethodFingerprinter
|
||||
{
|
||||
private readonly ILogger<JavaScriptMethodFingerprinter> _logger;
|
||||
|
||||
// Regex patterns for JavaScript function extraction
|
||||
[GeneratedRegex(@"(export\s+)?(async\s+)?function\s+(\w+)\s*\(([^)]*)\)\s*\{", RegexOptions.Compiled)]
|
||||
private static partial Regex FunctionDeclarationRegex();
|
||||
|
||||
[GeneratedRegex(@"(\w+)\s*:\s*(async\s+)?function\s*\(([^)]*)\)\s*\{", RegexOptions.Compiled)]
|
||||
private static partial Regex ObjectMethodRegex();
|
||||
|
||||
[GeneratedRegex(@"(async\s+)?(\w+)\s*\(([^)]*)\)\s*\{", RegexOptions.Compiled)]
|
||||
private static partial Regex ClassMethodRegex();
|
||||
|
||||
[GeneratedRegex(@"(const|let|var)\s+(\w+)\s*=\s*(async\s+)?\(([^)]*)\)\s*=>", RegexOptions.Compiled)]
|
||||
private static partial Regex ArrowFunctionRegex();
|
||||
|
||||
[GeneratedRegex(@"class\s+(\w+)(?:\s+extends\s+(\w+))?\s*\{", RegexOptions.Compiled)]
|
||||
private static partial Regex ClassDeclarationRegex();
|
||||
|
||||
[GeneratedRegex(@"module\.exports\s*=\s*(?:class\s+)?(\w+)", RegexOptions.Compiled)]
|
||||
private static partial Regex ModuleExportsRegex();
|
||||
|
||||
public JavaScriptMethodFingerprinter(ILogger<JavaScriptMethodFingerprinter> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Ecosystem => "npm";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FingerprintResult> FingerprintAsync(
|
||||
FingerprintRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var methods = new Dictionary<string, MethodFingerprint>(StringComparer.Ordinal);
|
||||
|
||||
try
|
||||
{
|
||||
var jsFiles = GetJavaScriptFiles(request.PackagePath);
|
||||
var filesProcessed = 0;
|
||||
|
||||
foreach (var jsPath in jsFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
await ProcessJavaScriptFileAsync(jsPath, request.PackagePath, methods, request, cancellationToken);
|
||||
filesProcessed++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to process JavaScript file {Path}", jsPath);
|
||||
}
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
_logger.LogDebug(
|
||||
"Fingerprinted {MethodCount} functions from {FileCount} files in {Duration}ms",
|
||||
methods.Count, filesProcessed, sw.ElapsedMilliseconds);
|
||||
|
||||
return FingerprintResult.Ok(methods, sw.Elapsed, filesProcessed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
_logger.LogWarning(ex, "Failed to fingerprint JavaScript package at {Path}", request.PackagePath);
|
||||
return FingerprintResult.Fail(ex.Message, sw.Elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
private static string[] GetJavaScriptFiles(string packagePath)
|
||||
{
|
||||
if (!Directory.Exists(packagePath))
|
||||
return [];
|
||||
|
||||
return Directory.GetFiles(packagePath, "*", SearchOption.AllDirectories)
|
||||
.Where(f =>
|
||||
{
|
||||
var ext = Path.GetExtension(f).ToLowerInvariant();
|
||||
return ext is ".js" or ".mjs" or ".cjs" or ".jsx";
|
||||
})
|
||||
.Where(f =>
|
||||
{
|
||||
// Skip common non-source directories
|
||||
var relativePath = f.Replace(packagePath, "").TrimStart(Path.DirectorySeparatorChar);
|
||||
return !relativePath.StartsWith("node_modules", StringComparison.OrdinalIgnoreCase) &&
|
||||
!relativePath.StartsWith("dist", StringComparison.OrdinalIgnoreCase) &&
|
||||
!relativePath.Contains(".min.", StringComparison.OrdinalIgnoreCase);
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private async Task ProcessJavaScriptFileAsync(
|
||||
string jsPath,
|
||||
string packagePath,
|
||||
Dictionary<string, MethodFingerprint> methods,
|
||||
FingerprintRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(jsPath, cancellationToken);
|
||||
var relativePath = Path.GetRelativePath(packagePath, jsPath);
|
||||
var moduleName = GetModuleName(relativePath);
|
||||
|
||||
// Extract function declarations
|
||||
ExtractFunctionDeclarations(content, moduleName, relativePath, methods, request);
|
||||
|
||||
// Extract class methods
|
||||
ExtractClassMethods(content, moduleName, relativePath, methods, request);
|
||||
|
||||
// Extract arrow functions
|
||||
ExtractArrowFunctions(content, moduleName, relativePath, methods, request);
|
||||
|
||||
// Extract object methods
|
||||
ExtractObjectMethods(content, moduleName, relativePath, methods, request);
|
||||
}
|
||||
|
||||
private void ExtractFunctionDeclarations(
|
||||
string content,
|
||||
string moduleName,
|
||||
string filePath,
|
||||
Dictionary<string, MethodFingerprint> methods,
|
||||
FingerprintRequest request)
|
||||
{
|
||||
var matches = FunctionDeclarationRegex().Matches(content);
|
||||
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
var isExported = !string.IsNullOrEmpty(match.Groups[1].Value);
|
||||
var isAsync = !string.IsNullOrEmpty(match.Groups[2].Value);
|
||||
var functionName = match.Groups[3].Value;
|
||||
var parameters = match.Groups[4].Value.Trim();
|
||||
|
||||
// Skip private functions unless requested
|
||||
if (!request.IncludePrivateMethods && !isExported)
|
||||
continue;
|
||||
|
||||
var bodyHash = ComputeFunctionBodyHash(content, match.Index);
|
||||
var methodKey = $"{moduleName}::{functionName}({NormalizeParams(parameters)})";
|
||||
|
||||
methods[methodKey] = new MethodFingerprint
|
||||
{
|
||||
MethodKey = methodKey,
|
||||
DeclaringType = moduleName,
|
||||
Name = functionName,
|
||||
Signature = $"{(isAsync ? "async " : "")}function {functionName}({parameters})",
|
||||
BodyHash = bodyHash,
|
||||
IsPublic = isExported,
|
||||
SourceFile = filePath,
|
||||
LineNumber = GetLineNumber(content, match.Index)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private void ExtractClassMethods(
|
||||
string content,
|
||||
string moduleName,
|
||||
string filePath,
|
||||
Dictionary<string, MethodFingerprint> methods,
|
||||
FingerprintRequest request)
|
||||
{
|
||||
var classMatches = ClassDeclarationRegex().Matches(content);
|
||||
|
||||
foreach (Match classMatch in classMatches)
|
||||
{
|
||||
var className = classMatch.Groups[1].Value;
|
||||
var classBodyStart = content.IndexOf('{', classMatch.Index);
|
||||
if (classBodyStart < 0) continue;
|
||||
|
||||
// Find class body (simple brace matching)
|
||||
var classBody = ExtractBracedBlock(content, classBodyStart);
|
||||
if (string.IsNullOrEmpty(classBody)) continue;
|
||||
|
||||
var methodMatches = ClassMethodRegex().Matches(classBody);
|
||||
|
||||
foreach (Match methodMatch in methodMatches)
|
||||
{
|
||||
var isAsync = !string.IsNullOrEmpty(methodMatch.Groups[1].Value);
|
||||
var methodName = methodMatch.Groups[2].Value;
|
||||
var parameters = methodMatch.Groups[3].Value.Trim();
|
||||
|
||||
// Skip constructor unless specifically requested
|
||||
if (methodName == "constructor" && !request.IncludePrivateMethods)
|
||||
continue;
|
||||
|
||||
// Skip private methods (prefixed with #)
|
||||
if (methodName.StartsWith('#') && !request.IncludePrivateMethods)
|
||||
continue;
|
||||
|
||||
var bodyHash = ComputeFunctionBodyHash(classBody, methodMatch.Index);
|
||||
var methodKey = $"{moduleName}.{className}::{methodName}({NormalizeParams(parameters)})";
|
||||
|
||||
methods[methodKey] = new MethodFingerprint
|
||||
{
|
||||
MethodKey = methodKey,
|
||||
DeclaringType = $"{moduleName}.{className}",
|
||||
Name = methodName,
|
||||
Signature = $"{(isAsync ? "async " : "")}{methodName}({parameters})",
|
||||
BodyHash = bodyHash,
|
||||
IsPublic = !methodName.StartsWith('#'),
|
||||
SourceFile = filePath,
|
||||
LineNumber = GetLineNumber(content, classMatch.Index + methodMatch.Index)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ExtractArrowFunctions(
|
||||
string content,
|
||||
string moduleName,
|
||||
string filePath,
|
||||
Dictionary<string, MethodFingerprint> methods,
|
||||
FingerprintRequest request)
|
||||
{
|
||||
var matches = ArrowFunctionRegex().Matches(content);
|
||||
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
var declarationType = match.Groups[1].Value; // const/let/var
|
||||
var functionName = match.Groups[2].Value;
|
||||
var isAsync = !string.IsNullOrEmpty(match.Groups[3].Value);
|
||||
var parameters = match.Groups[4].Value.Trim();
|
||||
|
||||
// Check if it's exported
|
||||
var lineStart = content.LastIndexOf('\n', match.Index) + 1;
|
||||
var line = content[lineStart..match.Index];
|
||||
var isExported = line.Contains("export", StringComparison.Ordinal);
|
||||
|
||||
if (!request.IncludePrivateMethods && !isExported)
|
||||
continue;
|
||||
|
||||
var bodyHash = ComputeArrowFunctionBodyHash(content, match.Index);
|
||||
var methodKey = $"{moduleName}::{functionName}({NormalizeParams(parameters)})";
|
||||
|
||||
methods[methodKey] = new MethodFingerprint
|
||||
{
|
||||
MethodKey = methodKey,
|
||||
DeclaringType = moduleName,
|
||||
Name = functionName,
|
||||
Signature = $"{(isAsync ? "async " : "")}({parameters}) =>",
|
||||
BodyHash = bodyHash,
|
||||
IsPublic = isExported,
|
||||
SourceFile = filePath,
|
||||
LineNumber = GetLineNumber(content, match.Index)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private void ExtractObjectMethods(
|
||||
string content,
|
||||
string moduleName,
|
||||
string filePath,
|
||||
Dictionary<string, MethodFingerprint> methods,
|
||||
FingerprintRequest request)
|
||||
{
|
||||
var matches = ObjectMethodRegex().Matches(content);
|
||||
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
var methodName = match.Groups[1].Value;
|
||||
var isAsync = !string.IsNullOrEmpty(match.Groups[2].Value);
|
||||
var parameters = match.Groups[3].Value.Trim();
|
||||
|
||||
var bodyHash = ComputeFunctionBodyHash(content, match.Index);
|
||||
var methodKey = $"{moduleName}::obj.{methodName}({NormalizeParams(parameters)})";
|
||||
|
||||
// Object methods are typically exported if they're in module.exports
|
||||
methods[methodKey] = new MethodFingerprint
|
||||
{
|
||||
MethodKey = methodKey,
|
||||
DeclaringType = moduleName,
|
||||
Name = methodName,
|
||||
Signature = $"{(isAsync ? "async " : "")}{methodName}({parameters})",
|
||||
BodyHash = bodyHash,
|
||||
IsPublic = true,
|
||||
SourceFile = filePath,
|
||||
LineNumber = GetLineNumber(content, match.Index)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetModuleName(string relativePath)
|
||||
{
|
||||
// Convert path to module name: src/utils/helper.js -> src.utils.helper
|
||||
var withoutExt = Path.ChangeExtension(relativePath, null);
|
||||
return withoutExt
|
||||
.Replace(Path.DirectorySeparatorChar, '.')
|
||||
.Replace(Path.AltDirectorySeparatorChar, '.');
|
||||
}
|
||||
|
||||
private static string NormalizeParams(string parameters)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(parameters))
|
||||
return "";
|
||||
|
||||
// Remove default values, just keep param names
|
||||
var normalized = string.Join(",", parameters
|
||||
.Split(',')
|
||||
.Select(p => p.Split('=')[0].Trim())
|
||||
.Where(p => !string.IsNullOrEmpty(p)));
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string ComputeFunctionBodyHash(string content, int startIndex)
|
||||
{
|
||||
var braceStart = content.IndexOf('{', startIndex);
|
||||
if (braceStart < 0) return "empty";
|
||||
|
||||
var body = ExtractBracedBlock(content, braceStart);
|
||||
return ComputeHash(NormalizeBody(body));
|
||||
}
|
||||
|
||||
private static string ComputeArrowFunctionBodyHash(string content, int startIndex)
|
||||
{
|
||||
var arrowIndex = content.IndexOf("=>", startIndex);
|
||||
if (arrowIndex < 0) return "empty";
|
||||
|
||||
var bodyStart = arrowIndex + 2;
|
||||
while (bodyStart < content.Length && char.IsWhiteSpace(content[bodyStart]))
|
||||
bodyStart++;
|
||||
|
||||
if (bodyStart >= content.Length) return "empty";
|
||||
|
||||
// Check if it's a block or expression
|
||||
if (content[bodyStart] == '{')
|
||||
{
|
||||
var body = ExtractBracedBlock(content, bodyStart);
|
||||
return ComputeHash(NormalizeBody(body));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Expression body - find end by semicolon or newline
|
||||
var endIndex = content.IndexOfAny([';', '\n'], bodyStart);
|
||||
if (endIndex < 0) endIndex = content.Length;
|
||||
var body = content[bodyStart..endIndex];
|
||||
return ComputeHash(NormalizeBody(body));
|
||||
}
|
||||
}
|
||||
|
||||
private static string ExtractBracedBlock(string content, int braceStart)
|
||||
{
|
||||
if (braceStart >= content.Length || content[braceStart] != '{')
|
||||
return string.Empty;
|
||||
|
||||
var depth = 0;
|
||||
var i = braceStart;
|
||||
|
||||
while (i < content.Length)
|
||||
{
|
||||
var c = content[i];
|
||||
if (c == '{') depth++;
|
||||
else if (c == '}')
|
||||
{
|
||||
depth--;
|
||||
if (depth == 0)
|
||||
return content[(braceStart + 1)..i];
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static string NormalizeBody(string body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
return "empty";
|
||||
|
||||
// Remove comments, normalize whitespace
|
||||
var sb = new StringBuilder();
|
||||
var inLineComment = false;
|
||||
var inBlockComment = false;
|
||||
var inString = false;
|
||||
var stringChar = '\0';
|
||||
|
||||
for (var i = 0; i < body.Length; i++)
|
||||
{
|
||||
var c = body[i];
|
||||
var next = i + 1 < body.Length ? body[i + 1] : '\0';
|
||||
|
||||
if (inLineComment)
|
||||
{
|
||||
if (c == '\n') inLineComment = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inBlockComment)
|
||||
{
|
||||
if (c == '*' && next == '/')
|
||||
{
|
||||
inBlockComment = false;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inString)
|
||||
{
|
||||
sb.Append(c);
|
||||
if (c == stringChar && (i == 0 || body[i - 1] != '\\'))
|
||||
inString = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '/' && next == '/')
|
||||
{
|
||||
inLineComment = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '/' && next == '*')
|
||||
{
|
||||
inBlockComment = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c is '"' or '\'' or '`')
|
||||
{
|
||||
inString = true;
|
||||
stringChar = c;
|
||||
sb.Append(c);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normalize whitespace
|
||||
if (char.IsWhiteSpace(c))
|
||||
{
|
||||
if (sb.Length > 0 && !char.IsWhiteSpace(sb[^1]))
|
||||
sb.Append(' ');
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString().Trim();
|
||||
}
|
||||
|
||||
private static string ComputeHash(string content)
|
||||
{
|
||||
if (string.IsNullOrEmpty(content))
|
||||
return "empty";
|
||||
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return Convert.ToHexStringLower(bytes[..16]); // First 32 hex chars
|
||||
}
|
||||
|
||||
private static int GetLineNumber(string content, int index)
|
||||
{
|
||||
var lineNumber = 1;
|
||||
for (var i = 0; i < index && i < content.Length; i++)
|
||||
{
|
||||
if (content[i] == '\n')
|
||||
lineNumber++;
|
||||
}
|
||||
return lineNumber;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PythonAstFingerprinter.cs
|
||||
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core (SURF-011)
|
||||
// Description: Python method fingerprinting using AST-based hashing.
|
||||
// Parses .py files and extracts function and method definitions.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.Fingerprint;
|
||||
|
||||
/// <summary>
|
||||
/// Computes method fingerprints for Python packages using AST-based hashing.
|
||||
/// Parses .py files and extracts function definitions and class methods.
|
||||
/// </summary>
|
||||
public sealed partial class PythonAstFingerprinter : IMethodFingerprinter
|
||||
{
|
||||
private readonly ILogger<PythonAstFingerprinter> _logger;
|
||||
|
||||
// Regex patterns for Python function extraction
|
||||
[GeneratedRegex(@"^(async\s+)?def\s+(\w+)\s*\(([^)]*)\)\s*(?:->\s*[^:]+)?:", RegexOptions.Multiline | RegexOptions.Compiled)]
|
||||
private static partial Regex FunctionDefRegex();
|
||||
|
||||
[GeneratedRegex(@"^class\s+(\w+)(?:\s*\([^)]*\))?\s*:", RegexOptions.Multiline | RegexOptions.Compiled)]
|
||||
private static partial Regex ClassDefRegex();
|
||||
|
||||
[GeneratedRegex(@"^(\s+)(async\s+)?def\s+(\w+)\s*\(([^)]*)\)\s*(?:->\s*[^:]+)?:", RegexOptions.Multiline | RegexOptions.Compiled)]
|
||||
private static partial Regex MethodDefRegex();
|
||||
|
||||
[GeneratedRegex(@"^(\s*)@\w+(?:\([^)]*\))?$", RegexOptions.Multiline | RegexOptions.Compiled)]
|
||||
private static partial Regex DecoratorRegex();
|
||||
|
||||
public PythonAstFingerprinter(ILogger<PythonAstFingerprinter> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Ecosystem => "pypi";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FingerprintResult> FingerprintAsync(
|
||||
FingerprintRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var methods = new Dictionary<string, MethodFingerprint>(StringComparer.Ordinal);
|
||||
|
||||
try
|
||||
{
|
||||
var pyFiles = GetPythonFiles(request.PackagePath);
|
||||
var filesProcessed = 0;
|
||||
|
||||
foreach (var pyPath in pyFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
await ProcessPythonFileAsync(pyPath, request.PackagePath, methods, request, cancellationToken);
|
||||
filesProcessed++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to process Python file {Path}", pyPath);
|
||||
}
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
_logger.LogDebug(
|
||||
"Fingerprinted {MethodCount} functions from {FileCount} files in {Duration}ms",
|
||||
methods.Count, filesProcessed, sw.ElapsedMilliseconds);
|
||||
|
||||
return FingerprintResult.Ok(methods, sw.Elapsed, filesProcessed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
_logger.LogWarning(ex, "Failed to fingerprint Python package at {Path}", request.PackagePath);
|
||||
return FingerprintResult.Fail(ex.Message, sw.Elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
private static string[] GetPythonFiles(string packagePath)
|
||||
{
|
||||
if (!Directory.Exists(packagePath))
|
||||
return [];
|
||||
|
||||
return Directory.GetFiles(packagePath, "*.py", SearchOption.AllDirectories)
|
||||
.Where(f =>
|
||||
{
|
||||
var relativePath = f.Replace(packagePath, "").TrimStart(Path.DirectorySeparatorChar);
|
||||
return !relativePath.StartsWith("test", StringComparison.OrdinalIgnoreCase) &&
|
||||
!relativePath.Contains("__pycache__", StringComparison.OrdinalIgnoreCase) &&
|
||||
!relativePath.Contains(".egg-info", StringComparison.OrdinalIgnoreCase);
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private async Task ProcessPythonFileAsync(
|
||||
string pyPath,
|
||||
string packagePath,
|
||||
Dictionary<string, MethodFingerprint> methods,
|
||||
FingerprintRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(pyPath, cancellationToken);
|
||||
var lines = content.Split('\n');
|
||||
var relativePath = Path.GetRelativePath(packagePath, pyPath);
|
||||
var moduleName = GetModuleName(relativePath);
|
||||
|
||||
// Extract module-level functions
|
||||
ExtractFunctions(content, lines, moduleName, relativePath, methods, request);
|
||||
|
||||
// Extract class methods
|
||||
ExtractClassMethods(content, lines, moduleName, relativePath, methods, request);
|
||||
}
|
||||
|
||||
private void ExtractFunctions(
|
||||
string content,
|
||||
string[] lines,
|
||||
string moduleName,
|
||||
string filePath,
|
||||
Dictionary<string, MethodFingerprint> methods,
|
||||
FingerprintRequest request)
|
||||
{
|
||||
var matches = FunctionDefRegex().Matches(content);
|
||||
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
// Skip if this is inside a class (has leading whitespace)
|
||||
var lineStart = content.LastIndexOf('\n', Math.Max(0, match.Index - 1)) + 1;
|
||||
if (lineStart < match.Index && !string.IsNullOrWhiteSpace(content[lineStart..match.Index]))
|
||||
continue;
|
||||
|
||||
var isAsync = !string.IsNullOrEmpty(match.Groups[1].Value);
|
||||
var functionName = match.Groups[2].Value;
|
||||
var parameters = match.Groups[3].Value.Trim();
|
||||
|
||||
// Skip private functions unless requested
|
||||
if (!request.IncludePrivateMethods && functionName.StartsWith('_') && !functionName.StartsWith("__"))
|
||||
continue;
|
||||
|
||||
var lineNumber = GetLineNumber(content, match.Index);
|
||||
var bodyHash = ComputeFunctionBodyHash(lines, lineNumber - 1, 0);
|
||||
var methodKey = $"{moduleName}::{functionName}({NormalizeParams(parameters)})";
|
||||
|
||||
// Check for decorators to determine if it's exported
|
||||
var isExported = !functionName.StartsWith('_');
|
||||
|
||||
methods[methodKey] = new MethodFingerprint
|
||||
{
|
||||
MethodKey = methodKey,
|
||||
DeclaringType = moduleName,
|
||||
Name = functionName,
|
||||
Signature = $"{(isAsync ? "async " : "")}def {functionName}({parameters})",
|
||||
BodyHash = bodyHash,
|
||||
IsPublic = isExported,
|
||||
SourceFile = filePath,
|
||||
LineNumber = lineNumber
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private void ExtractClassMethods(
|
||||
string content,
|
||||
string[] lines,
|
||||
string moduleName,
|
||||
string filePath,
|
||||
Dictionary<string, MethodFingerprint> methods,
|
||||
FingerprintRequest request)
|
||||
{
|
||||
var classMatches = ClassDefRegex().Matches(content);
|
||||
|
||||
foreach (Match classMatch in classMatches)
|
||||
{
|
||||
var className = classMatch.Groups[1].Value;
|
||||
var classLineNumber = GetLineNumber(content, classMatch.Index);
|
||||
var classIndent = GetIndentation(lines[classLineNumber - 1]);
|
||||
|
||||
// Find all methods in this class
|
||||
var methodMatches = MethodDefRegex().Matches(content);
|
||||
|
||||
foreach (Match methodMatch in methodMatches)
|
||||
{
|
||||
var methodLineNumber = GetLineNumber(content, methodMatch.Index);
|
||||
|
||||
// Check if this method belongs to this class
|
||||
if (methodLineNumber <= classLineNumber)
|
||||
continue;
|
||||
|
||||
var methodIndent = methodMatch.Groups[1].Value.Length;
|
||||
|
||||
// Method should be indented one level from class
|
||||
if (methodIndent <= classIndent)
|
||||
break; // We've left the class
|
||||
|
||||
// Check if there's another class between
|
||||
var nextClassMatch = classMatches
|
||||
.Cast<Match>()
|
||||
.FirstOrDefault(m => GetLineNumber(content, m.Index) > classLineNumber &&
|
||||
GetLineNumber(content, m.Index) < methodLineNumber);
|
||||
if (nextClassMatch is not null)
|
||||
continue;
|
||||
|
||||
var isAsync = !string.IsNullOrEmpty(methodMatch.Groups[2].Value);
|
||||
var methodName = methodMatch.Groups[3].Value;
|
||||
var parameters = methodMatch.Groups[4].Value.Trim();
|
||||
|
||||
// Skip private methods unless requested
|
||||
if (!request.IncludePrivateMethods && methodName.StartsWith('_') && !methodName.StartsWith("__"))
|
||||
continue;
|
||||
|
||||
var bodyHash = ComputeFunctionBodyHash(lines, methodLineNumber - 1, methodIndent);
|
||||
var methodKey = $"{moduleName}.{className}::{methodName}({NormalizeParams(parameters)})";
|
||||
|
||||
// Determine visibility
|
||||
var isPublic = !methodName.StartsWith('_') || methodName.StartsWith("__") && methodName.EndsWith("__");
|
||||
|
||||
methods[methodKey] = new MethodFingerprint
|
||||
{
|
||||
MethodKey = methodKey,
|
||||
DeclaringType = $"{moduleName}.{className}",
|
||||
Name = methodName,
|
||||
Signature = $"{(isAsync ? "async " : "")}def {methodName}({parameters})",
|
||||
BodyHash = bodyHash,
|
||||
IsPublic = isPublic,
|
||||
SourceFile = filePath,
|
||||
LineNumber = methodLineNumber
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetModuleName(string relativePath)
|
||||
{
|
||||
// Convert path to module name: src/utils/helper.py -> src.utils.helper
|
||||
var withoutExt = Path.ChangeExtension(relativePath, null);
|
||||
var moduleName = withoutExt
|
||||
.Replace(Path.DirectorySeparatorChar, '.')
|
||||
.Replace(Path.AltDirectorySeparatorChar, '.');
|
||||
|
||||
// Remove __init__ from module name
|
||||
if (moduleName.EndsWith(".__init__"))
|
||||
{
|
||||
moduleName = moduleName[..^9];
|
||||
}
|
||||
|
||||
return moduleName;
|
||||
}
|
||||
|
||||
private static string NormalizeParams(string parameters)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(parameters))
|
||||
return "";
|
||||
|
||||
// Remove type hints and default values, keep param names
|
||||
var normalized = string.Join(",", parameters
|
||||
.Split(',')
|
||||
.Select(p =>
|
||||
{
|
||||
// Remove type hints (param: Type)
|
||||
var colonIndex = p.IndexOf(':');
|
||||
if (colonIndex > 0)
|
||||
p = p[..colonIndex];
|
||||
|
||||
// Remove default values (param=value)
|
||||
var equalsIndex = p.IndexOf('=');
|
||||
if (equalsIndex > 0)
|
||||
p = p[..equalsIndex];
|
||||
|
||||
return p.Trim();
|
||||
})
|
||||
.Where(p => !string.IsNullOrEmpty(p)));
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string ComputeFunctionBodyHash(string[] lines, int defLineIndex, int baseIndent)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Find the function body indent
|
||||
var bodyIndent = -1;
|
||||
var inDocstring = false;
|
||||
var docstringQuotes = "";
|
||||
|
||||
for (var i = defLineIndex + 1; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
var trimmedLine = line.TrimStart();
|
||||
|
||||
// Skip empty lines
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
if (bodyIndent > 0)
|
||||
sb.AppendLine();
|
||||
continue;
|
||||
}
|
||||
|
||||
var currentIndent = GetIndentation(line);
|
||||
|
||||
// First non-empty line determines body indent
|
||||
if (bodyIndent < 0)
|
||||
{
|
||||
if (currentIndent <= baseIndent)
|
||||
break; // No body found
|
||||
bodyIndent = currentIndent;
|
||||
}
|
||||
else if (currentIndent <= baseIndent && !string.IsNullOrWhiteSpace(trimmedLine))
|
||||
{
|
||||
// We've left the function body
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle docstrings
|
||||
if (trimmedLine.StartsWith("\"\"\"") || trimmedLine.StartsWith("'''"))
|
||||
{
|
||||
docstringQuotes = trimmedLine[..3];
|
||||
if (!inDocstring)
|
||||
{
|
||||
inDocstring = true;
|
||||
if (trimmedLine.Length > 3 && trimmedLine.EndsWith(docstringQuotes))
|
||||
{
|
||||
inDocstring = false;
|
||||
}
|
||||
continue; // Skip docstring lines
|
||||
}
|
||||
}
|
||||
|
||||
if (inDocstring)
|
||||
{
|
||||
if (trimmedLine.Contains(docstringQuotes))
|
||||
{
|
||||
inDocstring = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip comments
|
||||
if (trimmedLine.StartsWith('#'))
|
||||
continue;
|
||||
|
||||
// Add normalized line to hash input
|
||||
sb.AppendLine(NormalizeLine(trimmedLine));
|
||||
}
|
||||
|
||||
return ComputeHash(sb.ToString());
|
||||
}
|
||||
|
||||
private static string NormalizeLine(string line)
|
||||
{
|
||||
// Remove inline comments
|
||||
var commentIndex = -1;
|
||||
var inString = false;
|
||||
var stringChar = '\0';
|
||||
|
||||
for (var i = 0; i < line.Length; i++)
|
||||
{
|
||||
var c = line[i];
|
||||
|
||||
if (inString)
|
||||
{
|
||||
if (c == stringChar && (i == 0 || line[i - 1] != '\\'))
|
||||
inString = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c is '"' or '\'')
|
||||
{
|
||||
inString = true;
|
||||
stringChar = c;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '#')
|
||||
{
|
||||
commentIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (commentIndex > 0)
|
||||
line = line[..commentIndex];
|
||||
|
||||
// Normalize whitespace
|
||||
return line.Trim();
|
||||
}
|
||||
|
||||
private static int GetIndentation(string line)
|
||||
{
|
||||
var indent = 0;
|
||||
foreach (var c in line)
|
||||
{
|
||||
if (c == ' ') indent++;
|
||||
else if (c == '\t') indent += 4;
|
||||
else break;
|
||||
}
|
||||
return indent;
|
||||
}
|
||||
|
||||
private static int GetLineNumber(string content, int index)
|
||||
{
|
||||
var lineNumber = 1;
|
||||
for (var i = 0; i < index && i < content.Length; i++)
|
||||
{
|
||||
if (content[i] == '\n')
|
||||
lineNumber++;
|
||||
}
|
||||
return lineNumber;
|
||||
}
|
||||
|
||||
private static string ComputeHash(string content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return "empty";
|
||||
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return Convert.ToHexStringLower(bytes[..16]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DotNetMethodKeyBuilder.cs
|
||||
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core (SURF-012)
|
||||
// Description: Method key builder for .NET/NuGet packages.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.MethodKeys;
|
||||
|
||||
/// <summary>
|
||||
/// Builds normalized method keys for .NET assemblies.
|
||||
/// Format: Namespace.TypeName::MethodName(ParamType1,ParamType2)
|
||||
/// </summary>
|
||||
public sealed partial class DotNetMethodKeyBuilder : IMethodKeyBuilder
|
||||
{
|
||||
// Pattern: Namespace.Type::Method(params)
|
||||
[GeneratedRegex(@"^(?:(.+)\.)?([^:.]+)::([^(]+)\(([^)]*)\)$", RegexOptions.Compiled)]
|
||||
private static partial Regex MethodKeyPattern();
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Ecosystem => "nuget";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string BuildKey(MethodKeyRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Namespace.TypeName
|
||||
if (!string.IsNullOrEmpty(request.Namespace))
|
||||
{
|
||||
sb.Append(NormalizeNamespace(request.Namespace));
|
||||
if (!string.IsNullOrEmpty(request.TypeName))
|
||||
{
|
||||
sb.Append('.');
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(request.TypeName))
|
||||
{
|
||||
sb.Append(NormalizeTypeName(request.TypeName));
|
||||
}
|
||||
|
||||
// ::MethodName
|
||||
sb.Append("::");
|
||||
sb.Append(NormalizeMethodName(request.MethodName));
|
||||
|
||||
// (ParamTypes)
|
||||
sb.Append('(');
|
||||
if (request.ParameterTypes is { Count: > 0 })
|
||||
{
|
||||
sb.Append(string.Join(",", request.ParameterTypes.Select(NormalizeTypeName)));
|
||||
}
|
||||
sb.Append(')');
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public MethodKeyComponents? ParseKey(string methodKey)
|
||||
{
|
||||
if (string.IsNullOrEmpty(methodKey))
|
||||
return null;
|
||||
|
||||
var match = MethodKeyPattern().Match(methodKey);
|
||||
if (!match.Success)
|
||||
return null;
|
||||
|
||||
var namespacePart = match.Groups[1].Value;
|
||||
var typeName = match.Groups[2].Value;
|
||||
var methodName = match.Groups[3].Value;
|
||||
var parameters = match.Groups[4].Value;
|
||||
|
||||
var paramTypes = string.IsNullOrEmpty(parameters)
|
||||
? []
|
||||
: parameters.Split(',').Select(p => p.Trim()).ToList();
|
||||
|
||||
return new MethodKeyComponents
|
||||
{
|
||||
Namespace = string.IsNullOrEmpty(namespacePart) ? null : namespacePart,
|
||||
TypeName = typeName,
|
||||
MethodName = methodName,
|
||||
ParameterTypes = paramTypes
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string NormalizeKey(string methodKey)
|
||||
{
|
||||
var components = ParseKey(methodKey);
|
||||
if (components is null)
|
||||
return methodKey;
|
||||
|
||||
return BuildKey(new MethodKeyRequest
|
||||
{
|
||||
Namespace = components.Namespace,
|
||||
TypeName = components.TypeName,
|
||||
MethodName = components.MethodName,
|
||||
ParameterTypes = components.ParameterTypes?.ToList()
|
||||
});
|
||||
}
|
||||
|
||||
private static string NormalizeNamespace(string ns)
|
||||
{
|
||||
// Remove generic arity markers
|
||||
return ns.Replace("`1", "").Replace("`2", "").Replace("`3", "").Replace("`4", "");
|
||||
}
|
||||
|
||||
private static string NormalizeTypeName(string typeName)
|
||||
{
|
||||
// Normalize common type aliases
|
||||
var normalized = typeName switch
|
||||
{
|
||||
"System.String" or "string" => "String",
|
||||
"System.Int32" or "int" => "Int32",
|
||||
"System.Int64" or "long" => "Int64",
|
||||
"System.Boolean" or "bool" => "Boolean",
|
||||
"System.Double" or "double" => "Double",
|
||||
"System.Single" or "float" => "Single",
|
||||
"System.Void" or "void" => "Void",
|
||||
"System.Object" or "object" => "Object",
|
||||
"System.Byte" or "byte" => "Byte",
|
||||
"System.Char" or "char" => "Char",
|
||||
"System.Decimal" or "decimal" => "Decimal",
|
||||
_ => typeName
|
||||
};
|
||||
|
||||
// Remove generic arity and simplify
|
||||
var arityIndex = normalized.IndexOf('`');
|
||||
if (arityIndex > 0)
|
||||
{
|
||||
normalized = normalized[..arityIndex];
|
||||
}
|
||||
|
||||
// Use simple name for common BCL types (e.g., System.String -> String)
|
||||
if (normalized.StartsWith("System.", StringComparison.Ordinal))
|
||||
{
|
||||
var afterSystem = normalized[7..];
|
||||
if (!afterSystem.Contains('.'))
|
||||
{
|
||||
normalized = afterSystem;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string NormalizeMethodName(string methodName)
|
||||
{
|
||||
// Normalize common method name variations
|
||||
return methodName switch
|
||||
{
|
||||
".ctor" => ".ctor",
|
||||
".cctor" => ".cctor",
|
||||
_ => methodName
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IMethodKeyBuilder.cs
|
||||
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core (SURF-012)
|
||||
// Description: Interface for building normalized method keys per ecosystem.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.MethodKeys;
|
||||
|
||||
/// <summary>
|
||||
/// Builds normalized method keys for cross-ecosystem comparison.
|
||||
/// Method keys provide a stable, canonical identifier for methods
|
||||
/// that can be used for diffing between package versions.
|
||||
/// </summary>
|
||||
public interface IMethodKeyBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Ecosystem this builder handles.
|
||||
/// </summary>
|
||||
string Ecosystem { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Builds a normalized method key from components.
|
||||
/// </summary>
|
||||
/// <param name="request">Method key request with components.</param>
|
||||
/// <returns>Normalized method key.</returns>
|
||||
string BuildKey(MethodKeyRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Parses a method key back into components.
|
||||
/// </summary>
|
||||
/// <param name="methodKey">The method key to parse.</param>
|
||||
/// <returns>Parsed components or null if invalid.</returns>
|
||||
MethodKeyComponents? ParseKey(string methodKey);
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a method key to canonical form.
|
||||
/// </summary>
|
||||
/// <param name="methodKey">The method key to normalize.</param>
|
||||
/// <returns>Normalized method key.</returns>
|
||||
string NormalizeKey(string methodKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to build a method key.
|
||||
/// </summary>
|
||||
public sealed record MethodKeyRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Namespace or package path.
|
||||
/// </summary>
|
||||
public string? Namespace { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type or class name.
|
||||
/// </summary>
|
||||
public string? TypeName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Method or function name.
|
||||
/// </summary>
|
||||
public required string MethodName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Parameter types (type names only).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? ParameterTypes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Return type.
|
||||
/// </summary>
|
||||
public string? ReturnType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include return type in key (for overload resolution).
|
||||
/// </summary>
|
||||
public bool IncludeReturnType { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsed components of a method key.
|
||||
/// </summary>
|
||||
public sealed record MethodKeyComponents
|
||||
{
|
||||
/// <summary>
|
||||
/// Full namespace path.
|
||||
/// </summary>
|
||||
public string? Namespace { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type/class name.
|
||||
/// </summary>
|
||||
public string? TypeName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Method/function name.
|
||||
/// </summary>
|
||||
public required string MethodName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Parameter type names.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? ParameterTypes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Full qualified name (namespace.type::method).
|
||||
/// </summary>
|
||||
public string FullQualifiedName =>
|
||||
string.IsNullOrEmpty(Namespace)
|
||||
? (string.IsNullOrEmpty(TypeName) ? MethodName : $"{TypeName}::{MethodName}")
|
||||
: (string.IsNullOrEmpty(TypeName) ? $"{Namespace}::{MethodName}" : $"{Namespace}.{TypeName}::{MethodName}");
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// JavaMethodKeyBuilder.cs
|
||||
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core (SURF-012)
|
||||
// Description: Method key builder for Java/Maven packages.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.MethodKeys;
|
||||
|
||||
/// <summary>
|
||||
/// Builds normalized method keys for Java classes.
|
||||
/// Format: com.package.ClassName::methodName(ParamType1,ParamType2)
|
||||
/// </summary>
|
||||
public sealed partial class JavaMethodKeyBuilder : IMethodKeyBuilder
|
||||
{
|
||||
// Pattern: package.ClassName::methodName(descriptor)
|
||||
[GeneratedRegex(@"^([^:]+)::([^(]+)(\([^)]*\).*)$", RegexOptions.Compiled)]
|
||||
private static partial Regex MethodKeyPattern();
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Ecosystem => "maven";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string BuildKey(MethodKeyRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Package.ClassName
|
||||
if (!string.IsNullOrEmpty(request.Namespace))
|
||||
{
|
||||
sb.Append(NormalizePackage(request.Namespace));
|
||||
sb.Append('.');
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(request.TypeName))
|
||||
{
|
||||
sb.Append(request.TypeName);
|
||||
}
|
||||
|
||||
// ::methodName
|
||||
sb.Append("::");
|
||||
sb.Append(NormalizeMethodName(request.MethodName));
|
||||
|
||||
// (ParamTypes) - using Java descriptor format
|
||||
sb.Append('(');
|
||||
if (request.ParameterTypes is { Count: > 0 })
|
||||
{
|
||||
sb.Append(string.Join(",", request.ParameterTypes.Select(NormalizeTypeName)));
|
||||
}
|
||||
sb.Append(')');
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public MethodKeyComponents? ParseKey(string methodKey)
|
||||
{
|
||||
if (string.IsNullOrEmpty(methodKey))
|
||||
return null;
|
||||
|
||||
var match = MethodKeyPattern().Match(methodKey);
|
||||
if (!match.Success)
|
||||
return null;
|
||||
|
||||
var fullClassName = match.Groups[1].Value;
|
||||
var methodName = match.Groups[2].Value;
|
||||
var descriptor = match.Groups[3].Value;
|
||||
|
||||
// Split package from class name
|
||||
string? packageName = null;
|
||||
var typeName = fullClassName;
|
||||
|
||||
var lastDot = fullClassName.LastIndexOf('.');
|
||||
if (lastDot > 0)
|
||||
{
|
||||
packageName = fullClassName[..lastDot];
|
||||
typeName = fullClassName[(lastDot + 1)..];
|
||||
}
|
||||
|
||||
// Parse descriptor to get parameter types
|
||||
var paramTypes = ParseDescriptor(descriptor);
|
||||
|
||||
return new MethodKeyComponents
|
||||
{
|
||||
Namespace = packageName,
|
||||
TypeName = typeName,
|
||||
MethodName = methodName,
|
||||
ParameterTypes = paramTypes
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string NormalizeKey(string methodKey)
|
||||
{
|
||||
var components = ParseKey(methodKey);
|
||||
if (components is null)
|
||||
return methodKey;
|
||||
|
||||
return BuildKey(new MethodKeyRequest
|
||||
{
|
||||
Namespace = components.Namespace,
|
||||
TypeName = components.TypeName,
|
||||
MethodName = components.MethodName,
|
||||
ParameterTypes = components.ParameterTypes?.ToList()
|
||||
});
|
||||
}
|
||||
|
||||
private static string NormalizePackage(string package)
|
||||
{
|
||||
// Java packages are lowercase
|
||||
return package.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string NormalizeMethodName(string methodName)
|
||||
{
|
||||
// Handle constructor and static initializer
|
||||
return methodName switch
|
||||
{
|
||||
"<init>" => "<init>",
|
||||
"<clinit>" => "<clinit>",
|
||||
_ => methodName
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeTypeName(string typeName)
|
||||
{
|
||||
// Simplify common Java types
|
||||
return typeName switch
|
||||
{
|
||||
"java.lang.String" => "String",
|
||||
"java.lang.Object" => "Object",
|
||||
"java.lang.Integer" => "Integer",
|
||||
"java.lang.Long" => "Long",
|
||||
"java.lang.Boolean" => "Boolean",
|
||||
"java.lang.Double" => "Double",
|
||||
"java.lang.Float" => "Float",
|
||||
"java.lang.Byte" => "Byte",
|
||||
"java.lang.Short" => "Short",
|
||||
"java.lang.Character" => "Character",
|
||||
"java.util.List" => "List",
|
||||
"java.util.Map" => "Map",
|
||||
"java.util.Set" => "Set",
|
||||
_ => typeName.Contains('.') ? typeName.Split('.')[^1] : typeName
|
||||
};
|
||||
}
|
||||
|
||||
private static List<string> ParseDescriptor(string descriptor)
|
||||
{
|
||||
var result = new List<string>();
|
||||
|
||||
if (string.IsNullOrEmpty(descriptor) || !descriptor.StartsWith('('))
|
||||
return result;
|
||||
|
||||
var i = 1; // Skip opening paren
|
||||
while (i < descriptor.Length && descriptor[i] != ')')
|
||||
{
|
||||
var (typeName, newIndex) = ParseTypeDescriptor(descriptor, i);
|
||||
if (!string.IsNullOrEmpty(typeName))
|
||||
{
|
||||
result.Add(typeName);
|
||||
}
|
||||
i = newIndex;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static (string typeName, int newIndex) ParseTypeDescriptor(string descriptor, int index)
|
||||
{
|
||||
if (index >= descriptor.Length)
|
||||
return (string.Empty, index);
|
||||
|
||||
var c = descriptor[index];
|
||||
|
||||
return c switch
|
||||
{
|
||||
'B' => ("byte", index + 1),
|
||||
'C' => ("char", index + 1),
|
||||
'D' => ("double", index + 1),
|
||||
'F' => ("float", index + 1),
|
||||
'I' => ("int", index + 1),
|
||||
'J' => ("long", index + 1),
|
||||
'S' => ("short", index + 1),
|
||||
'Z' => ("boolean", index + 1),
|
||||
'V' => ("void", index + 1),
|
||||
'[' => ParseArrayDescriptor(descriptor, index),
|
||||
'L' => ParseObjectDescriptor(descriptor, index),
|
||||
_ => (string.Empty, index + 1)
|
||||
};
|
||||
}
|
||||
|
||||
private static (string typeName, int newIndex) ParseArrayDescriptor(string descriptor, int index)
|
||||
{
|
||||
var (elementType, newIndex) = ParseTypeDescriptor(descriptor, index + 1);
|
||||
return ($"{elementType}[]", newIndex);
|
||||
}
|
||||
|
||||
private static (string typeName, int newIndex) ParseObjectDescriptor(string descriptor, int index)
|
||||
{
|
||||
var semicolonIndex = descriptor.IndexOf(';', index);
|
||||
if (semicolonIndex < 0)
|
||||
return ("Object", index + 1);
|
||||
|
||||
var className = descriptor[(index + 1)..semicolonIndex];
|
||||
var simpleName = className.Split('/')[^1];
|
||||
return (simpleName, semicolonIndex + 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// NodeMethodKeyBuilder.cs
|
||||
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core (SURF-012)
|
||||
// Description: Method key builder for Node.js/npm packages.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.MethodKeys;
|
||||
|
||||
/// <summary>
|
||||
/// Builds normalized method keys for JavaScript/Node.js modules.
|
||||
/// Format: module.path::functionName(param1,param2) or module.path.ClassName::methodName(params)
|
||||
/// </summary>
|
||||
public sealed partial class NodeMethodKeyBuilder : IMethodKeyBuilder
|
||||
{
|
||||
// Pattern: module.path[.ClassName]::methodName(params)
|
||||
[GeneratedRegex(@"^([^:]+)::([^(]+)\(([^)]*)\)$", RegexOptions.Compiled)]
|
||||
private static partial Regex MethodKeyPattern();
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Ecosystem => "npm";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string BuildKey(MethodKeyRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Module path
|
||||
if (!string.IsNullOrEmpty(request.Namespace))
|
||||
{
|
||||
sb.Append(NormalizeModulePath(request.Namespace));
|
||||
}
|
||||
|
||||
// Class name (if any)
|
||||
if (!string.IsNullOrEmpty(request.TypeName))
|
||||
{
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
sb.Append('.');
|
||||
}
|
||||
sb.Append(request.TypeName);
|
||||
}
|
||||
|
||||
// ::functionName
|
||||
sb.Append("::");
|
||||
sb.Append(request.MethodName);
|
||||
|
||||
// (params)
|
||||
sb.Append('(');
|
||||
if (request.ParameterTypes is { Count: > 0 })
|
||||
{
|
||||
sb.Append(string.Join(",", request.ParameterTypes));
|
||||
}
|
||||
sb.Append(')');
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public MethodKeyComponents? ParseKey(string methodKey)
|
||||
{
|
||||
if (string.IsNullOrEmpty(methodKey))
|
||||
return null;
|
||||
|
||||
var match = MethodKeyPattern().Match(methodKey);
|
||||
if (!match.Success)
|
||||
return null;
|
||||
|
||||
var modulePath = match.Groups[1].Value;
|
||||
var methodName = match.Groups[2].Value;
|
||||
var parameters = match.Groups[3].Value;
|
||||
|
||||
// Try to extract class name from module path
|
||||
string? typeName = null;
|
||||
var lastDot = modulePath.LastIndexOf('.');
|
||||
if (lastDot > 0)
|
||||
{
|
||||
var lastPart = modulePath[(lastDot + 1)..];
|
||||
// Check if it looks like a class name (starts with uppercase)
|
||||
if (char.IsUpper(lastPart[0]))
|
||||
{
|
||||
typeName = lastPart;
|
||||
modulePath = modulePath[..lastDot];
|
||||
}
|
||||
}
|
||||
|
||||
var paramTypes = string.IsNullOrEmpty(parameters)
|
||||
? []
|
||||
: parameters.Split(',').Select(p => p.Trim()).ToList();
|
||||
|
||||
return new MethodKeyComponents
|
||||
{
|
||||
Namespace = modulePath,
|
||||
TypeName = typeName,
|
||||
MethodName = methodName,
|
||||
ParameterTypes = paramTypes
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string NormalizeKey(string methodKey)
|
||||
{
|
||||
var components = ParseKey(methodKey);
|
||||
if (components is null)
|
||||
return methodKey;
|
||||
|
||||
return BuildKey(new MethodKeyRequest
|
||||
{
|
||||
Namespace = components.Namespace,
|
||||
TypeName = components.TypeName,
|
||||
MethodName = components.MethodName,
|
||||
ParameterTypes = components.ParameterTypes?.ToList()
|
||||
});
|
||||
}
|
||||
|
||||
private static string NormalizeModulePath(string path)
|
||||
{
|
||||
// Normalize path separators and common patterns
|
||||
var normalized = path
|
||||
.Replace('/', '.')
|
||||
.Replace('\\', '.')
|
||||
.Replace("..", ".");
|
||||
|
||||
// Remove leading/trailing dots
|
||||
normalized = normalized.Trim('.');
|
||||
|
||||
// Remove 'index' from module paths
|
||||
if (normalized.EndsWith(".index", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
normalized = normalized[..^6];
|
||||
}
|
||||
|
||||
// Remove common prefixes like 'src.' or 'lib.'
|
||||
foreach (var prefix in new[] { "src.", "lib.", "dist." })
|
||||
{
|
||||
if (normalized.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
normalized = normalized[prefix.Length..];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PythonMethodKeyBuilder.cs
|
||||
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core (SURF-012)
|
||||
// Description: Method key builder for Python/PyPI packages.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.MethodKeys;
|
||||
|
||||
/// <summary>
|
||||
/// Builds normalized method keys for Python modules.
|
||||
/// Format: package.module.ClassName::method_name(param1,param2) or package.module::function_name(params)
|
||||
/// </summary>
|
||||
public sealed partial class PythonMethodKeyBuilder : IMethodKeyBuilder
|
||||
{
|
||||
// Pattern: module.path[.ClassName]::function_name(params)
|
||||
[GeneratedRegex(@"^([^:]+)::([^(]+)\(([^)]*)\)$", RegexOptions.Compiled)]
|
||||
private static partial Regex MethodKeyPattern();
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Ecosystem => "pypi";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string BuildKey(MethodKeyRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Module path
|
||||
if (!string.IsNullOrEmpty(request.Namespace))
|
||||
{
|
||||
sb.Append(NormalizeModulePath(request.Namespace));
|
||||
}
|
||||
|
||||
// Class name (if any)
|
||||
if (!string.IsNullOrEmpty(request.TypeName))
|
||||
{
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
sb.Append('.');
|
||||
}
|
||||
sb.Append(request.TypeName);
|
||||
}
|
||||
|
||||
// ::function_name
|
||||
sb.Append("::");
|
||||
sb.Append(NormalizeFunctionName(request.MethodName));
|
||||
|
||||
// (params) - just param names for Python
|
||||
sb.Append('(');
|
||||
if (request.ParameterTypes is { Count: > 0 })
|
||||
{
|
||||
sb.Append(string.Join(",", request.ParameterTypes));
|
||||
}
|
||||
sb.Append(')');
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public MethodKeyComponents? ParseKey(string methodKey)
|
||||
{
|
||||
if (string.IsNullOrEmpty(methodKey))
|
||||
return null;
|
||||
|
||||
var match = MethodKeyPattern().Match(methodKey);
|
||||
if (!match.Success)
|
||||
return null;
|
||||
|
||||
var modulePath = match.Groups[1].Value;
|
||||
var functionName = match.Groups[2].Value;
|
||||
var parameters = match.Groups[3].Value;
|
||||
|
||||
// Try to extract class name from module path
|
||||
string? typeName = null;
|
||||
var lastDot = modulePath.LastIndexOf('.');
|
||||
if (lastDot > 0)
|
||||
{
|
||||
var lastPart = modulePath[(lastDot + 1)..];
|
||||
// Check if it looks like a class name (starts with uppercase)
|
||||
if (lastPart.Length > 0 && char.IsUpper(lastPart[0]))
|
||||
{
|
||||
typeName = lastPart;
|
||||
modulePath = modulePath[..lastDot];
|
||||
}
|
||||
}
|
||||
|
||||
var paramNames = string.IsNullOrEmpty(parameters)
|
||||
? []
|
||||
: parameters.Split(',').Select(p => p.Trim()).ToList();
|
||||
|
||||
return new MethodKeyComponents
|
||||
{
|
||||
Namespace = modulePath,
|
||||
TypeName = typeName,
|
||||
MethodName = functionName,
|
||||
ParameterTypes = paramNames
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string NormalizeKey(string methodKey)
|
||||
{
|
||||
var components = ParseKey(methodKey);
|
||||
if (components is null)
|
||||
return methodKey;
|
||||
|
||||
return BuildKey(new MethodKeyRequest
|
||||
{
|
||||
Namespace = components.Namespace,
|
||||
TypeName = components.TypeName,
|
||||
MethodName = components.MethodName,
|
||||
ParameterTypes = components.ParameterTypes?.ToList()
|
||||
});
|
||||
}
|
||||
|
||||
private static string NormalizeModulePath(string path)
|
||||
{
|
||||
// Python module paths use dots
|
||||
var normalized = path
|
||||
.Replace('/', '.')
|
||||
.Replace('\\', '.')
|
||||
.Replace("..", ".");
|
||||
|
||||
// Remove leading/trailing dots
|
||||
normalized = normalized.Trim('.');
|
||||
|
||||
// Remove __init__ from module paths
|
||||
if (normalized.EndsWith(".__init__", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
normalized = normalized[..^9];
|
||||
}
|
||||
|
||||
// Normalize common variations
|
||||
normalized = normalized
|
||||
.Replace("_", "_"); // Keep underscores as-is
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string NormalizeFunctionName(string name)
|
||||
{
|
||||
// Python method names
|
||||
return name switch
|
||||
{
|
||||
"__init__" => "__init__",
|
||||
"__new__" => "__new__",
|
||||
"__del__" => "__del__",
|
||||
"__str__" => "__str__",
|
||||
"__repr__" => "__repr__",
|
||||
"__call__" => "__call__",
|
||||
"__getitem__" => "__getitem__",
|
||||
"__setitem__" => "__setitem__",
|
||||
"__len__" => "__len__",
|
||||
"__iter__" => "__iter__",
|
||||
"__next__" => "__next__",
|
||||
"__enter__" => "__enter__",
|
||||
"__exit__" => "__exit__",
|
||||
_ => name
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Mono.Cecil" Version="0.11.6" />
|
||||
<PackageReference Include="Npgsql" Version="9.0.3" />
|
||||
<PackageReference Include="SharpCompress" Version="0.41.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user