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:
master
2025-12-19 15:35:00 +02:00
parent 43882078a4
commit 951a38d561
192 changed files with 27550 additions and 2611 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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; } = [];
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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